Skip to content

Commit f5d1c2c

Browse files
committed
refactor(control): extract protocol-neutral transaction orchestration
Move commit, savepoint, and lifecycle logic shared by pgwire and native dispatch out of the pgwire-specific handlers into control/server/shared/session, leaving thin per-protocol adapters that shape the neutral CommitOutcome/AbortReason into pgwire or native responses.
1 parent 25c7f20 commit f5d1c2c

16 files changed

Lines changed: 1289 additions & 1046 deletions

File tree

Lines changed: 117 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -1,192 +1,142 @@
11
// SPDX-License-Identifier: BUSL-1.1
22

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;
414

515
use nodedb_types::TraceId;
6-
use nodedb_types::id::DatabaseId;
716
use nodedb_types::protocol::NativeResponse;
817

9-
use crate::bridge::envelope::PhysicalPlan;
10-
use crate::control::gateway::GatewayErrorMap;
18+
use crate::bridge::envelope::{ErrorCode, Payload, Response, Status};
1119
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;
1427

1528
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,
2740
}
2841

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+
})
8166
}
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
14177
}
14278
}
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+
}
15282

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)
15692
}
15793
}
94+
}
15895

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+
}
160102
}
161103

162104
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;
169107
NativeResponse::status_row(seq, "ROLLBACK")
170108
}
171109

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)
192142
}

0 commit comments

Comments
 (0)