|
1 | 1 | // SPDX-License-Identifier: BUSL-1.1 |
2 | 2 |
|
3 | | -//! Transaction control: BEGIN, COMMIT, ROLLBACK. |
| 3 | +//! Transaction control adapters for the native protocol: BEGIN, COMMIT, |
| 4 | +//! ROLLBACK — thin shims over the protocol-neutral orchestrator in |
| 5 | +//! `control/server/shared/session/`. |
| 6 | +//! |
| 7 | +//! Driving the neutral core means native GAINS everything pgwire already did: |
| 8 | +//! Calvin multi-shard COMMIT, read-your-own-write SI exclusion, deferred offset |
| 9 | +//! / GAP_FREE / DDL / notify flush on COMMIT, and DDL-buffer + GAP_FREE + cursor |
| 10 | +//! + notify cleanup on ROLLBACK. |
| 11 | +
|
| 12 | +use std::future::Future; |
| 13 | +use std::pin::Pin; |
4 | 14 |
|
5 | 15 | use nodedb_types::TraceId; |
6 | | -use nodedb_types::id::DatabaseId; |
7 | 16 | use nodedb_types::protocol::NativeResponse; |
8 | 17 |
|
9 | | -use crate::bridge::envelope::PhysicalPlan; |
10 | | -use crate::control::gateway::GatewayErrorMap; |
| 18 | +use crate::bridge::envelope::{ErrorCode, Payload, Response, Status}; |
11 | 19 | use crate::control::gateway::core::QueryContext as GatewayQueryContext; |
12 | | -use nodedb_physical::physical_plan::MetaOp; |
13 | | -use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; |
| 20 | +use crate::control::server::shared::ddl::sqlstate::error_code_to_sqlstate; |
| 21 | +use crate::control::server::shared::session::{ |
| 22 | + AbortReason, CommitOutcome, TxnDataPlane, commit, lifecycle, |
| 23 | +}; |
| 24 | +use crate::control::state::SharedState; |
| 25 | +use crate::types::{Lsn, RequestId}; |
| 26 | +use nodedb_physical::physical_task::PhysicalTask; |
14 | 27 |
|
15 | 28 | use super::super::super::dispatch_utils; |
16 | | -use super::{DispatchCtx, error_to_native}; |
17 | | - |
18 | | -pub(crate) fn handle_begin(ctx: &DispatchCtx<'_>, seq: u64) -> NativeResponse { |
19 | | - let snapshot_lsn = { |
20 | | - let next = ctx.state.wal.next_lsn(); |
21 | | - crate::types::Lsn::new(next.as_u64().saturating_sub(1)) |
22 | | - }; |
23 | | - match ctx.sessions.begin(ctx.peer_addr, snapshot_lsn) { |
24 | | - Ok(()) => NativeResponse::status_row(seq, "BEGIN"), |
25 | | - Err(msg) => NativeResponse::error(seq, "25P02", msg), |
26 | | - } |
| 29 | +use super::DispatchCtx; |
| 30 | + |
| 31 | +/// Native Data-Plane dispatch seam for the neutral transaction orchestrator. |
| 32 | +/// |
| 33 | +/// Routes a task through the cluster gateway when one is configured, otherwise |
| 34 | +/// through the direct SPSC dispatch path — the exact branch native COMMIT used |
| 35 | +/// before extraction. The gateway path synthesizes an `Ok` [`Response`] on |
| 36 | +/// success (carrying the first vShard payload so overlay-marker meta-ops still |
| 37 | +/// decode), and surfaces gateway errors as a Rust `Err`. |
| 38 | +pub(crate) struct NativeTxnDp<'a> { |
| 39 | + pub(crate) state: &'a SharedState, |
27 | 40 | } |
28 | 41 |
|
29 | | -pub(crate) async fn handle_commit(ctx: &DispatchCtx<'_>, seq: u64) -> NativeResponse { |
30 | | - // Snapshot isolation conflict check. |
31 | | - let read_set = ctx.sessions.take_read_set(ctx.peer_addr); |
32 | | - if let Some(snapshot_lsn) = ctx.sessions.snapshot_lsn(ctx.peer_addr) { |
33 | | - let current_lsn = ctx.state.wal.next_lsn(); |
34 | | - let current = crate::types::Lsn::new(current_lsn.as_u64().saturating_sub(1)); |
35 | | - for (_collection, _doc_id, read_lsn) in &read_set { |
36 | | - if current > *read_lsn && current > snapshot_lsn { |
37 | | - let _ = ctx.sessions.rollback(ctx.peer_addr); |
38 | | - return NativeResponse::error( |
39 | | - seq, |
40 | | - "40001", |
41 | | - "could not serialize access due to concurrent update", |
42 | | - ); |
43 | | - } |
44 | | - } |
45 | | - } |
46 | | - |
47 | | - let buffered = match ctx.sessions.commit(ctx.peer_addr) { |
48 | | - Ok(b) => b, |
49 | | - Err(msg) => return NativeResponse::error(seq, "25000", msg), |
50 | | - }; |
51 | | - |
52 | | - if !buffered.is_empty() { |
53 | | - let tenant_id = ctx.identity.tenant_id; |
54 | | - let vshard_id = buffered[0].vshard_id; |
55 | | - |
56 | | - // WAL transaction record. |
57 | | - let mut sub_records: Vec<(u16, Vec<u8>)> = Vec::with_capacity(buffered.len()); |
58 | | - for task in &buffered { |
59 | | - if let Some(entry) = crate::control::wal_replication::to_replicated_entry( |
60 | | - task.tenant_id, |
61 | | - task.database_id, |
62 | | - task.vshard_id, |
63 | | - &task.plan, |
64 | | - ) { |
65 | | - let bytes = entry.to_bytes(); |
66 | | - sub_records.push((nodedb_wal::record::RecordType::Put as u16, bytes)); |
67 | | - } |
68 | | - } |
69 | | - |
70 | | - if !sub_records.is_empty() { |
71 | | - match zerompk::to_msgpack_vec(&sub_records) { |
72 | | - Ok(tx_payload) => { |
73 | | - if let Err(e) = ctx.state.wal.append_transaction( |
74 | | - tenant_id, |
75 | | - vshard_id, |
76 | | - DatabaseId::DEFAULT, |
77 | | - &tx_payload, |
78 | | - ) { |
79 | | - return error_to_native(seq, &e); |
80 | | - } |
| 42 | +impl TxnDataPlane for NativeTxnDp<'_> { |
| 43 | + fn dispatch_no_wal<'a>( |
| 44 | + &'a self, |
| 45 | + task: PhysicalTask, |
| 46 | + ) -> Pin<Box<dyn Future<Output = crate::Result<Response>> + Send + 'a>> { |
| 47 | + let state = self.state; |
| 48 | + Box::pin(async move { |
| 49 | + match state.gateway.as_ref() { |
| 50 | + Some(gw) => { |
| 51 | + let gw_ctx = GatewayQueryContext { |
| 52 | + tenant_id: task.tenant_id, |
| 53 | + trace_id: TraceId::generate(), |
| 54 | + database_id: task.database_id, |
| 55 | + }; |
| 56 | + let payloads = gw.execute(&gw_ctx, task.plan).await?; |
| 57 | + Ok(Response { |
| 58 | + request_id: RequestId::new(0), |
| 59 | + status: Status::Ok, |
| 60 | + attempt: 0, |
| 61 | + partial: false, |
| 62 | + payload: Payload::from_vec(payloads.into_iter().next().unwrap_or_default()), |
| 63 | + watermark_lsn: Lsn::new(0), |
| 64 | + error_code: None, |
| 65 | + }) |
81 | 66 | } |
82 | | - Err(e) => { |
83 | | - return NativeResponse::error( |
84 | | - seq, |
85 | | - "XX000", |
86 | | - format!("transaction WAL serialization failed: {e}"), |
87 | | - ); |
88 | | - } |
89 | | - } |
90 | | - } |
91 | | - |
92 | | - // Dispatch as atomic TransactionBatch. |
93 | | - let plans: Vec<PhysicalPlan> = buffered.iter().map(|t| t.plan.clone()).collect(); |
94 | | - let batch_plan = PhysicalPlan::Meta(MetaOp::TransactionBatch { plans }); |
95 | | - |
96 | | - let dispatch_err: Option<(&'static str, String)> = match ctx.state.gateway.as_ref() { |
97 | | - Some(gw) => { |
98 | | - let gw_ctx = GatewayQueryContext { |
99 | | - tenant_id, |
100 | | - trace_id: TraceId::generate(), |
101 | | - database_id: nodedb_types::id::DatabaseId::DEFAULT, |
102 | | - }; |
103 | | - gw.execute(&gw_ctx, batch_plan).await.err().map(|e| { |
104 | | - let (_code, msg) = GatewayErrorMap::to_native(&e); |
105 | | - ("40001", msg) |
106 | | - }) |
107 | | - } |
108 | | - None => { |
109 | | - let batch_task = PhysicalTask { |
110 | | - tenant_id, |
111 | | - vshard_id, |
112 | | - database_id: DatabaseId::DEFAULT, |
113 | | - plan: batch_plan, |
114 | | - post_set_op: PostSetOp::None, |
115 | | - txn_id: None, |
116 | | - }; |
117 | | - match dispatch_utils::dispatch_to_data_plane( |
118 | | - ctx.state, |
119 | | - batch_task.tenant_id, |
120 | | - batch_task.database_id, |
121 | | - batch_task.vshard_id, |
122 | | - batch_task.plan, |
123 | | - TraceId::ZERO, |
124 | | - ) |
125 | | - .await |
126 | | - { |
127 | | - Err(e) => Some(("40001", e.to_string())), |
128 | | - Ok(resp) if resp.status != crate::bridge::envelope::Status::Ok => { |
129 | | - let code = resp.error_code.clone().unwrap_or( |
130 | | - crate::bridge::envelope::ErrorCode::RejectedPrevalidation { |
131 | | - reason: "transaction commit failed".to_owned(), |
132 | | - }, |
133 | | - ); |
134 | | - let (_severity, sqlstate, message) = |
135 | | - crate::control::server::shared::ddl::sqlstate::error_code_to_sqlstate( |
136 | | - &code, |
137 | | - ); |
138 | | - Some((sqlstate, message)) |
139 | | - } |
140 | | - Ok(_) => None, |
| 67 | + None => { |
| 68 | + dispatch_utils::dispatch_to_data_plane( |
| 69 | + state, |
| 70 | + task.tenant_id, |
| 71 | + task.database_id, |
| 72 | + task.vshard_id, |
| 73 | + task.plan, |
| 74 | + TraceId::ZERO, |
| 75 | + ) |
| 76 | + .await |
141 | 77 | } |
142 | 78 | } |
143 | | - }; |
144 | | - |
145 | | - if let Some((sqlstate, msg)) = dispatch_err { |
146 | | - return NativeResponse::error( |
147 | | - seq, |
148 | | - sqlstate, |
149 | | - format!("transaction commit failed: {msg}"), |
150 | | - ); |
151 | | - } |
| 79 | + }) |
| 80 | + } |
| 81 | +} |
152 | 82 |
|
153 | | - // Release the staging overlay now that the durable batch has flushed. |
154 | | - if let Some(txn_id) = buffered[0].txn_id { |
155 | | - drop_txn_overlay(ctx, tenant_id, vshard_id, txn_id).await; |
| 83 | +pub(crate) fn handle_begin(ctx: &DispatchCtx<'_>, seq: u64) -> NativeResponse { |
| 84 | + match lifecycle::run_begin(ctx.sessions, ctx.peer_addr, ctx.state) { |
| 85 | + Ok(()) => NativeResponse::status_row(seq, "BEGIN"), |
| 86 | + Err(e) => { |
| 87 | + let message = match &e { |
| 88 | + crate::Error::BadRequest { detail } => detail.clone(), |
| 89 | + other => other.to_string(), |
| 90 | + }; |
| 91 | + NativeResponse::error(seq, "25P02", message) |
156 | 92 | } |
157 | 93 | } |
| 94 | +} |
158 | 95 |
|
159 | | - NativeResponse::status_row(seq, "COMMIT") |
| 96 | +pub(crate) async fn handle_commit(ctx: &DispatchCtx<'_>, seq: u64) -> NativeResponse { |
| 97 | + let dp = NativeTxnDp { state: ctx.state }; |
| 98 | + match commit::run_commit(ctx.sessions, ctx.peer_addr, ctx.identity, ctx.state, &dp).await { |
| 99 | + CommitOutcome::Committed => NativeResponse::status_row(seq, "COMMIT"), |
| 100 | + CommitOutcome::Aborted { reason } => commit_abort_to_native(seq, &reason), |
| 101 | + } |
160 | 102 | } |
161 | 103 |
|
162 | 104 | pub(crate) async fn handle_rollback(ctx: &DispatchCtx<'_>, seq: u64) -> NativeResponse { |
163 | | - // Snapshot overlay identity BEFORE rollback() clears session state. |
164 | | - let (overlay_txn_id, overlay_vshard) = ctx.sessions.txn_identity(ctx.peer_addr); |
165 | | - let _ = ctx.sessions.rollback(ctx.peer_addr); |
166 | | - if let (Some(txn_id), Some(vshard_id)) = (overlay_txn_id, overlay_vshard) { |
167 | | - drop_txn_overlay(ctx, ctx.identity.tenant_id, vshard_id, txn_id).await; |
168 | | - } |
| 105 | + let dp = NativeTxnDp { state: ctx.state }; |
| 106 | + lifecycle::run_rollback(ctx.sessions, ctx.peer_addr, ctx.identity, ctx.state, &dp).await; |
169 | 107 | NativeResponse::status_row(seq, "ROLLBACK") |
170 | 108 | } |
171 | 109 |
|
172 | | -/// Best-effort release of a transaction's staging overlay on its home vShard. |
173 | | -async fn drop_txn_overlay( |
174 | | - ctx: &DispatchCtx<'_>, |
175 | | - tenant_id: crate::types::TenantId, |
176 | | - vshard_id: crate::types::VShardId, |
177 | | - txn_id: crate::types::TxnId, |
178 | | -) { |
179 | | - let plan = PhysicalPlan::Meta(MetaOp::DropTxnOverlay { txn_id }); |
180 | | - if let Err(e) = dispatch_utils::dispatch_to_data_plane( |
181 | | - ctx.state, |
182 | | - tenant_id, |
183 | | - DatabaseId::DEFAULT, |
184 | | - vshard_id, |
185 | | - plan, |
186 | | - TraceId::ZERO, |
187 | | - ) |
188 | | - .await |
189 | | - { |
190 | | - tracing::warn!(error = %e, "failed to drop per-transaction staging overlay"); |
191 | | - } |
| 110 | +/// Map a neutral commit abort reason to the native error frame native emitted |
| 111 | +/// before extraction (batch/dispatch failures collapse to `40001`, batch |
| 112 | +/// rejections carry the Data-Plane SQLSTATE). |
| 113 | +fn commit_abort_to_native(seq: u64, reason: &AbortReason) -> NativeResponse { |
| 114 | + let (code, message): (&'static str, String) = match reason { |
| 115 | + AbortReason::Serialization => ( |
| 116 | + "40001", |
| 117 | + "could not serialize access due to concurrent update".to_owned(), |
| 118 | + ), |
| 119 | + AbortReason::NoTransaction => ( |
| 120 | + "25000", |
| 121 | + "current transaction is aborted, commands ignored until end of transaction block" |
| 122 | + .to_owned(), |
| 123 | + ), |
| 124 | + AbortReason::BatchRejected { code } => { |
| 125 | + let code = code.clone().unwrap_or(ErrorCode::RejectedPrevalidation { |
| 126 | + reason: "transaction commit failed".to_owned(), |
| 127 | + }); |
| 128 | + let (_severity, sqlstate, message) = error_code_to_sqlstate(&code); |
| 129 | + (sqlstate, format!("transaction commit failed: {message}")) |
| 130 | + } |
| 131 | + AbortReason::CalvinCancelled => ( |
| 132 | + "57014", |
| 133 | + "Calvin coordinator cancelled (deadline exceeded)".to_owned(), |
| 134 | + ), |
| 135 | + AbortReason::CalvinTimeout => { |
| 136 | + ("57014", "timed out waiting for Calvin sequencer".to_owned()) |
| 137 | + } |
| 138 | + AbortReason::Dispatch(e) => ("40001", format!("transaction commit failed: {e}")), |
| 139 | + AbortReason::DdlPropose(e) => ("XX000", format!("{e}")), |
| 140 | + }; |
| 141 | + NativeResponse::error(seq, code, message) |
192 | 142 | } |
0 commit comments