Skip to content

Commit 433690e

Browse files
fix(native): dispatch txn commit batch to the owning vshard
A row committed inside an explicit native-protocol transaction (Begin op, SQL DML, Commit op) was durably applied on the wrong core: the native COMMIT seam routed the commit's ResolveTxn/TransactionBatch meta-ops through the gateway, discarding the task's pre-classified vshard_id. The gateway router cannot derive a route for collection-less Meta plans and fell back to vShard 0, so the commit batch (heap put + index maintenance) landed on vShard 0's core while point lookups and index-assisted reads consulted the collection's owning vShard — the row was visible to full scans but permanently invisible to PK point lookups and filtered aggregates, across connections (#193). NativeTxnDp::dispatch_no_wal now always dispatches through the direct write path with task.vshard_id, matching pgwire's dispatch_task_no_wal and native's own pre-gateway branch. The gateway router additionally rejects ResolveTxn/TransactionBatch plans outright instead of silently misrouting them to vShard 0. Fixes #193
1 parent 4828165 commit 433690e

4 files changed

Lines changed: 295 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ NodeDB uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
---
99

10+
## [Unreleased]
11+
12+
### Fixed
13+
14+
- **Native protocol transactions** — a row committed inside an explicit transaction over the native binary protocol (`Begin` op → SQL DML → `Commit` op) is now visible to PK point lookups and filtered aggregates, not just full scans (#193). The native COMMIT seam routed the commit's `ResolveTxn`/`TransactionBatch` meta-ops through the gateway, which cannot derive a route for collection-less meta plans and fell back to vShard 0 — durably applying the commit batch on the wrong core. Native COMMIT now dispatches with the task's pre-classified vShard, matching pgwire, and the gateway router rejects commit meta-ops outright instead of silently misrouting them. **Note:** rows committed through native-protocol transactions on affected multi-core builds were applied to vShard 0's core and remain there after upgrading — they stay visible to full scans but invisible to point lookups; re-inserting them (or dump/reload) re-homes them.
15+
16+
---
17+
1018
## [0.4.0] - 2026-07-20
1119

1220
NodeDB 0.4.0 is a substantial distributed-correctness and durability release. It adds cross-shard transactional execution and distributed query/graph processing, extends temporal and sparse-vector SQL, and hardens replication, recovery, indexing, authentication, and sync across the storage engines.

nodedb/src/control/gateway/router.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,26 @@ pub fn route_plan(
6363
strategy_fn: impl Fn(&str) -> PartitionStrategy,
6464
extractor: &dyn KeyExtractor,
6565
) -> Result<Vec<TaskRoute>> {
66+
// Commit-time meta-ops (ResolveTxn / TransactionBatch) carry no collection
67+
// name, so their vShard cannot be derived here — the primary_vshard
68+
// fallback would silently send them to vShard 0 and durably apply the
69+
// commit batch on the wrong core (#193). They are dispatched with the
70+
// task's pre-classified `vshard_id` (see `dispatch_single_shard`), never
71+
// through the gateway.
72+
{
73+
use nodedb_physical::physical_plan::MetaOp;
74+
if matches!(
75+
&plan,
76+
PhysicalPlan::Meta(MetaOp::ResolveTxn { .. } | MetaOp::TransactionBatch { .. })
77+
) {
78+
return Err(crate::Error::Internal {
79+
detail: "commit meta-op cannot be routed by the gateway; \
80+
dispatch it with the task's explicit vshard_id"
81+
.to_owned(),
82+
});
83+
}
84+
}
85+
6686
// In single-node mode every plan runs locally.
6787
let Some(routing) = routing else {
6888
let vshard_id = primary_vshard(&plan, database_id);
@@ -440,4 +460,39 @@ mod tests {
440460
}
441461
unreachable!()
442462
}
463+
464+
/// Commit-time meta-ops carry no collection name, so the router cannot
465+
/// derive their vShard — silently falling back to vShard 0 durably applies
466+
/// the commit batch on the wrong core (#193). They must be rejected here;
467+
/// callers dispatch them with the task's pre-classified `vshard_id`.
468+
#[test]
469+
fn commit_meta_ops_are_rejected() {
470+
use nodedb_physical::physical_plan::MetaOp;
471+
472+
for plan in [
473+
PhysicalPlan::Meta(MetaOp::TransactionBatch {
474+
plans: vec![],
475+
txn_id: None,
476+
}),
477+
PhysicalPlan::Meta(MetaOp::ResolveTxn {
478+
txn_id: nodedb_types::id::TxnId::new(7),
479+
plans: vec![],
480+
}),
481+
] {
482+
for table in [None, Some(single_node_table())] {
483+
let result = route_plan(
484+
plan.clone(),
485+
1,
486+
table.as_ref(),
487+
DatabaseId::DEFAULT,
488+
|_| PartitionStrategy::CollectionHomed,
489+
&crate::control::gateway::UnwiredKeyExtractor,
490+
);
491+
assert!(
492+
result.is_err(),
493+
"commit meta-op must not be routable via the gateway: {plan:?}"
494+
);
495+
}
496+
}
497+
}
443498
}

nodedb/src/control/server/native/dispatch/transaction.rs

Lines changed: 25 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,26 @@ use std::pin::Pin;
1515
use nodedb_types::TraceId;
1616
use nodedb_types::protocol::NativeResponse;
1717

18-
use crate::bridge::envelope::{ErrorCode, Payload, Response, Status};
19-
use crate::control::gateway::core::QueryContext as GatewayQueryContext;
18+
use crate::bridge::envelope::{ErrorCode, Response};
2019
use crate::control::server::shared::ddl::sqlstate::error_code_to_sqlstate;
2120
use crate::control::server::shared::session::{
2221
AbortReason, CommitOutcome, TxnDataPlane, commit, lifecycle,
2322
};
2423
use crate::control::state::SharedState;
25-
use crate::types::{Lsn, RequestId};
24+
use crate::types::Lsn;
2625
use nodedb_physical::physical_task::PhysicalTask;
2726

2827
use super::super::super::dispatch_utils;
2928
use super::DispatchCtx;
3029

3130
/// Native Data-Plane dispatch seam for the neutral transaction orchestrator.
3231
///
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`.
32+
/// Always dispatches through the direct SPSC write path using the task's
33+
/// pre-classified `vshard_id`, mirroring pgwire's `dispatch_task_no_wal`.
34+
/// The gateway must NOT be used here: commit-time tasks carry `MetaOp` plans
35+
/// (`ResolveTxn`, `TransactionBatch`) with no named collection, so the
36+
/// gateway's router cannot derive a route for them and falls back to
37+
/// vShard 0 — durably applying the commit batch on the wrong core (#193).
3838
pub(crate) struct NativeTxnDp<'a> {
3939
pub(crate) state: &'a SharedState,
4040
}
@@ -47,48 +47,23 @@ impl TxnDataPlane for NativeTxnDp<'_> {
4747
) -> Pin<Box<dyn Future<Output = crate::Result<Response>> + Send + 'a>> {
4848
let state = self.state;
4949
Box::pin(async move {
50-
match state.gateway.get() {
51-
Some(gw) => {
52-
let gw_ctx = GatewayQueryContext {
53-
tenant_id: task.tenant_id,
54-
trace_id: TraceId::generate(),
55-
database_id: task.database_id,
56-
txn_id: task.txn_id,
57-
};
58-
let payloads = gw.execute(&gw_ctx, task.plan).await?;
59-
Ok(Response {
60-
request_id: RequestId::new(0),
61-
status: Status::Ok,
62-
attempt: 0,
63-
partial: false,
64-
payload: Payload::from_vec(payloads.into_iter().next().unwrap_or_default()),
65-
watermark_lsn: Lsn::new(0),
66-
error_code: None,
67-
read_set_valid: None,
68-
read_version_lsn: crate::types::Lsn::ZERO,
69-
write_set: Vec::new(),
70-
})
71-
}
72-
None => {
73-
dispatch_utils::dispatch_write_to_data_plane(
74-
state,
75-
dispatch_utils::WriteDispatch {
76-
tenant_id: task.tenant_id,
77-
database_id: task.database_id,
78-
vshard_id: task.vshard_id,
79-
plan: task.plan,
80-
trace_id: TraceId::ZERO,
81-
event_source: crate::event::EventSource::User,
82-
txn_id: None,
83-
wal_lsn,
84-
// Batch COMMIT record, not per-task WAL append — see
85-
// `dispatch_task_no_wal`'s equivalent limitation.
86-
resolved_now_ms: None,
87-
},
88-
)
89-
.await
90-
}
91-
}
50+
dispatch_utils::dispatch_write_to_data_plane(
51+
state,
52+
dispatch_utils::WriteDispatch {
53+
tenant_id: task.tenant_id,
54+
database_id: task.database_id,
55+
vshard_id: task.vshard_id,
56+
plan: task.plan,
57+
trace_id: TraceId::ZERO,
58+
event_source: crate::event::EventSource::User,
59+
txn_id: None,
60+
wal_lsn,
61+
// Batch COMMIT record, not per-task WAL append — see
62+
// `dispatch_task_no_wal`'s equivalent limitation.
63+
resolved_now_ms: None,
64+
},
65+
)
66+
.await
9267
})
9368
}
9469
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Issue #193: a row committed inside an explicit native-protocol transaction
4+
//! (OpCode::Begin → SQL INSERT → OpCode::Commit) must be visible to every
5+
//! read path — PK point lookups and filtered aggregates, not just full scans —
6+
//! on the writing connection and on fresh connections.
7+
//!
8+
//! Pre-fix, `NativeTxnDp::dispatch_no_wal` routed the commit's `MetaOp`
9+
//! tasks through the gateway without `task.vshard_id`; the gateway's
10+
//! `primary_vshard` fallback sent them to vShard 0, so the commit batch was
11+
//! durably applied on the wrong core. The bug needs (a) the gateway wired,
12+
//! as production boot does, and (b) more than one Data Plane core, so that
13+
//! vShard 0 and the collection's owning vShard live on different cores.
14+
15+
mod common;
16+
17+
use std::time::Duration;
18+
19+
use common::native_harness::{do_handshake, read_frame, write_frame};
20+
use common::pgwire_harness::TestServer;
21+
22+
use nodedb_types::id::VShardId;
23+
use nodedb_types::protocol::opcodes::ResponseStatus;
24+
use nodedb_types::protocol::text_fields::TextFields;
25+
use nodedb_types::protocol::{HelloFrame, NativeRequest, NativeResponse, OpCode, RequestFields};
26+
use nodedb_types::value::Value;
27+
use tokio::net::TcpStream;
28+
29+
/// Send one request and read frames until the response for `seq` completes
30+
/// (non-`Partial`), accumulating streamed rows. Unlike the harness
31+
/// `send_request`, this cannot desync on multi-frame responses.
32+
async fn request_drain(
33+
stream: &mut TcpStream,
34+
seq: u64,
35+
op: OpCode,
36+
fields: TextFields,
37+
) -> NativeResponse {
38+
let req = NativeRequest {
39+
op,
40+
seq,
41+
fields: RequestFields::Text(fields),
42+
};
43+
let json = sonic_rs::to_vec(&req).expect("json encode");
44+
write_frame(stream, &json).await;
45+
46+
let mut acc_rows: Vec<Vec<Value>> = Vec::new();
47+
loop {
48+
let payload = tokio::time::timeout(Duration::from_secs(5), read_frame(stream))
49+
.await
50+
.expect("timeout waiting for response")
51+
.expect("response frame");
52+
let mut resp: NativeResponse =
53+
sonic_rs::from_slice(&payload).expect("json decode NativeResponse");
54+
assert_eq!(resp.seq, seq, "response seq mismatch: {resp:?}");
55+
if let Some(rows) = resp.rows.take() {
56+
acc_rows.extend(rows);
57+
}
58+
if resp.status != ResponseStatus::Partial {
59+
resp.rows = Some(acc_rows);
60+
return resp;
61+
}
62+
}
63+
}
64+
65+
async fn sql_drain(stream: &mut TcpStream, seq: u64, sql: &str) -> NativeResponse {
66+
request_drain(
67+
stream,
68+
seq,
69+
OpCode::Sql,
70+
TextFields {
71+
sql: Some(sql.into()),
72+
..Default::default()
73+
},
74+
)
75+
.await
76+
}
77+
78+
const NUM_CORES: usize = 4;
79+
80+
/// Pick a collection name whose owning vShard maps to a core other than
81+
/// core 0 (round-robin: core = vshard % NUM_CORES), so a commit misrouted
82+
/// to vShard 0 lands on a different core than the one reads consult.
83+
fn collection_on_nonzero_core() -> String {
84+
for i in 0..64u32 {
85+
let name = format!("native_txn_vis_{i}");
86+
let vshard =
87+
VShardId::from_collection_in_database(nodedb::types::DatabaseId::DEFAULT, &name)
88+
.as_u32();
89+
if vshard as usize % NUM_CORES != 0 {
90+
return name;
91+
}
92+
}
93+
unreachable!("no candidate collection hashed off core 0");
94+
}
95+
96+
async fn native_session(srv: &TestServer) -> TcpStream {
97+
let addr = format!("127.0.0.1:{}", srv.native_port)
98+
.parse()
99+
.expect("native addr");
100+
let (stream, _ack) = do_handshake(addr, &HelloFrame::current())
101+
.await
102+
.expect("native handshake");
103+
stream
104+
}
105+
106+
fn first_cell(resp: &nodedb_types::protocol::NativeResponse) -> Option<Value> {
107+
resp.rows
108+
.as_ref()
109+
.and_then(|rows| rows.first())
110+
.and_then(|r| r.first())
111+
.cloned()
112+
}
113+
114+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
115+
async fn native_committed_txn_row_visible_to_point_and_filtered_reads() {
116+
let server = TestServer::start_multicores(NUM_CORES).await;
117+
118+
// Wire the gateway exactly as production boot does (state_wiring.rs).
119+
// The bug only manifests on the gateway branch of
120+
// `NativeTxnDp::dispatch_no_wal`; without this, tests exercise the
121+
// non-gateway fallback, which routes commits correctly.
122+
{
123+
use std::sync::Arc;
124+
let gateway = Arc::new(nodedb::control::gateway::Gateway::new(Arc::clone(
125+
&server.shared,
126+
)));
127+
let invalidator = Arc::new(nodedb::control::gateway::PlanCacheInvalidator::new(
128+
&gateway.plan_cache,
129+
));
130+
let _ = server.shared.gateway.set(gateway);
131+
let _ = server.shared.gateway_invalidator.set(invalidator);
132+
}
133+
134+
let coll = collection_on_nonzero_core();
135+
136+
server
137+
.exec(&format!(
138+
"CREATE COLLECTION {coll} (id STRING PRIMARY KEY, name STRING) \
139+
WITH (engine='document_strict')"
140+
))
141+
.await
142+
.unwrap();
143+
144+
let mut stream = native_session(&server).await;
145+
146+
// Explicit transaction via protocol opcodes, as in the issue repro.
147+
let begin = request_drain(&mut stream, 1, OpCode::Begin, TextFields::default()).await;
148+
assert_eq!(begin.status, ResponseStatus::Ok, "BEGIN op: {begin:?}");
149+
150+
let insert = sql_drain(
151+
&mut stream,
152+
2,
153+
&format!("INSERT INTO {coll} (id, name) VALUES ('a1', 'alpha')"),
154+
)
155+
.await;
156+
assert_eq!(insert.status, ResponseStatus::Ok, "in-tx INSERT: {insert:?}");
157+
158+
let commit = request_drain(&mut stream, 3, OpCode::Commit, TextFields::default()).await;
159+
assert_eq!(commit.status, ResponseStatus::Ok, "COMMIT op: {commit:?}");
160+
161+
// PK point lookup on the writing connection.
162+
let point = sql_drain(&mut stream, 4, &format!("SELECT id FROM {coll} WHERE id = 'a1'")).await;
163+
assert_ne!(point.status, ResponseStatus::Error, "point lookup: {point:?}");
164+
assert_eq!(
165+
first_cell(&point),
166+
Some(Value::String("a1".into())),
167+
"PK point lookup must see the committed row: {point:?}"
168+
);
169+
170+
// Filtered aggregate.
171+
let count = sql_drain(
172+
&mut stream,
173+
5,
174+
&format!("SELECT count(*) FROM {coll} WHERE name = 'alpha'"),
175+
)
176+
.await;
177+
assert_ne!(count.status, ResponseStatus::Error, "filtered count: {count:?}");
178+
assert_eq!(
179+
first_cell(&count),
180+
Some(Value::Integer(1)),
181+
"filtered count(*) must see the committed row: {count:?}"
182+
);
183+
184+
// Full scan — control: the row is durably stored. Streams (Partial
185+
// frames), so it runs last on this connection.
186+
let scan = sql_drain(&mut stream, 6, &format!("SELECT id FROM {coll}")).await;
187+
assert_ne!(scan.status, ResponseStatus::Error, "scan: {scan:?}");
188+
assert_eq!(
189+
first_cell(&scan),
190+
Some(Value::String("a1".into())),
191+
"full scan must see the committed row: {scan:?}"
192+
);
193+
194+
// Fresh connection — the miss must not persist across sessions.
195+
let mut fresh = native_session(&server).await;
196+
let point2 = sql_drain(&mut fresh, 1, &format!("SELECT id FROM {coll} WHERE id = 'a1'")).await;
197+
assert_ne!(
198+
point2.status,
199+
ResponseStatus::Error,
200+
"fresh point lookup: {point2:?}"
201+
);
202+
assert_eq!(
203+
first_cell(&point2),
204+
Some(Value::String("a1".into())),
205+
"PK point lookup on a fresh connection must see the committed row: {point2:?}"
206+
);
207+
}

0 commit comments

Comments
 (0)