Skip to content

Commit 676df00

Browse files
committed
feat(sync): announce collection schema before shape and delta data
Emit side of the announce-precedes-data invariant: a session now tracks which collections it has already announced, and a shared helper builds the CollectionSchema frame from the catalog descriptor on demand. Both the shape-subscribe path and the delta-push path send this announce frame before their first data frame for a collection, so peers always receive the descriptor ahead of any data referencing it.
1 parent f0bbfb6 commit 676df00

6 files changed

Lines changed: 317 additions & 10 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
//! The EMIT side of collection-schema sync: a `CollectionSchema` frame
3+
//! (opcode 0x13) is written to the peer STRICTLY BEFORE the first data frame
4+
//! for a collection.
5+
//!
6+
//! ## What this guards
7+
//!
8+
//! The announce-precedes-data invariant. Before the server ships a shape
9+
//! snapshot (or a delta) for a collection to a sync peer, it must first send
10+
//! that collection's `CollectionDescriptor` so the peer can materialize the
11+
//! collection with the correct engine before any rows arrive. The announce is
12+
//! idempotent per session.
13+
//!
14+
//! The test drives the sync WebSocket end-to-end: it connects to node 0's
15+
//! sync listener, materializes a document collection on the server (via a
16+
//! client-side `CollectionSchema` announce, which does NOT mark the server
17+
//! session's emit-announce set), then subscribes to a Document shape for that
18+
//! collection and asserts the FIRST frame back is `CollectionSchema` and the
19+
//! NEXT is `ShapeSnapshot`.
20+
21+
mod common;
22+
use common::cluster_harness::{TestCluster, TestClusterNode};
23+
24+
use std::time::{Duration, Instant};
25+
26+
use nodedb::control::server::sync::listener::{SyncListenerConfig, start_sync_listener};
27+
use nodedb_test_support::sync_client::SyncTestClient;
28+
use nodedb_types::collection_config::{PartitionStrategy, PrimaryEngine};
29+
use nodedb_types::sync::wire::{CollectionDescriptor, SyncMessageType};
30+
use nodedb_types::{CollectionType, DatabaseId, Hlc};
31+
32+
/// Trust-mode sync sessions authenticate as tenant 1.
33+
const TENANT: u64 = 1;
34+
35+
/// Build a plain-document descriptor for `name`, mirroring what a local
36+
/// `CREATE COLLECTION` would emit over sync.
37+
fn document_descriptor(name: &str) -> CollectionDescriptor {
38+
let collection_type = CollectionType::document();
39+
CollectionDescriptor {
40+
tenant_id: TENANT,
41+
database_id: DatabaseId::DEFAULT,
42+
name: name.to_string(),
43+
partition_strategy: PartitionStrategy::default_for_collection_type(&collection_type),
44+
collection_type,
45+
bitemporal: false,
46+
fields: Vec::new(),
47+
primary: PrimaryEngine::Document,
48+
vector_primary: None,
49+
declared_primary_key: None,
50+
descriptor_version: 0,
51+
}
52+
}
53+
54+
/// Poll `node`'s local catalog until the collection is visible. Panics if it
55+
/// does not converge — a bounded retry loop, not a single fixed sleep.
56+
async fn await_collection(node: &TestClusterNode, name: &str) {
57+
let deadline = Instant::now() + Duration::from_secs(30);
58+
loop {
59+
let found = node.shared.credentials.catalog().as_ref().and_then(|c| {
60+
c.get_collection(DatabaseId::DEFAULT, TENANT, name)
61+
.ok()
62+
.flatten()
63+
});
64+
if found.is_some() {
65+
return;
66+
}
67+
if Instant::now() >= deadline {
68+
panic!("collection '{name}' did not become catalog-visible within 30s");
69+
}
70+
tokio::time::sleep(Duration::from_millis(100)).await;
71+
}
72+
}
73+
74+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
75+
async fn collection_schema_precedes_shape_snapshot() {
76+
let cluster = TestCluster::spawn_three()
77+
.await
78+
.expect("spawn three-node cluster");
79+
80+
let cfg = SyncListenerConfig {
81+
listen_addr: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
82+
..Default::default()
83+
};
84+
let state = start_sync_listener(cfg, Some(std::sync::Arc::clone(&cluster.nodes[0].shared)))
85+
.await
86+
.expect("start sync listener on node 0");
87+
let addr = state.config.listen_addr;
88+
89+
let mut client = SyncTestClient::connect(addr)
90+
.await
91+
.expect("sync handshake with node 0");
92+
93+
// Materialize the collection on the server. This is the client→server
94+
// announce (receive side); it does NOT mark the server session's
95+
// emit-announce set, so the subscription below still triggers an emit.
96+
let name = "emit_doc";
97+
client
98+
.push_collection_schema(document_descriptor(name), Hlc::new(1, 0))
99+
.await
100+
.expect("push CollectionSchema to materialize collection");
101+
102+
// Wait until the receiving node's catalog can resolve the collection —
103+
// the emit path resolves the descriptor from exactly this catalog.
104+
await_collection(&cluster.nodes[0], name).await;
105+
106+
// Subscribe to a Document shape for the collection.
107+
client
108+
.subscribe_document_shape("emit_shape", name, TENANT as u32)
109+
.await
110+
.expect("send ShapeSubscribe");
111+
112+
// The first frame back MUST be the collection-schema announce.
113+
let first = client
114+
.recv_next_frame()
115+
.await
116+
.expect("receive first frame after subscribe");
117+
assert_eq!(
118+
first.msg_type,
119+
SyncMessageType::CollectionSchema,
120+
"expected CollectionSchema announce before any shape data, got {:?}",
121+
first.msg_type
122+
);
123+
let announced: nodedb_types::sync::wire::CollectionSchemaSyncMsg =
124+
first.decode_body().expect("decode CollectionSchemaSyncMsg");
125+
assert_eq!(
126+
announced.descriptor.name, name,
127+
"announced descriptor is for the wrong collection"
128+
);
129+
130+
// The next frame MUST be the shape snapshot — data strictly after schema.
131+
let second = client
132+
.recv_next_frame()
133+
.await
134+
.expect("receive second frame after subscribe");
135+
assert_eq!(
136+
second.msg_type,
137+
SyncMessageType::ShapeSnapshot,
138+
"expected ShapeSnapshot after the CollectionSchema announce, got {:?}",
139+
second.msg_type
140+
);
141+
142+
cluster.shutdown().await;
143+
}

nodedb-test-support/src/sync_client.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,47 @@ impl SyncTestClient {
155155
.map_err(|e| format!("failed to send CollectionSchema frame: {e}"))?;
156156
Ok(())
157157
}
158+
159+
/// Subscribe to a Document shape covering all documents in `collection`
160+
/// (empty predicate). Fire-and-forget: the server's response frames — a
161+
/// `CollectionSchema` announce followed by the `ShapeSnapshot` — are read
162+
/// by the caller via `recv_next_frame`.
163+
pub async fn subscribe_document_shape(
164+
&mut self,
165+
shape_id: &str,
166+
collection: &str,
167+
tenant_id: u32,
168+
) -> Result<(), String> {
169+
use nodedb_types::sync::shape::{ShapeDefinition, ShapeType};
170+
use nodedb_types::sync::wire::ShapeSubscribeMsg;
171+
172+
let shape = ShapeDefinition {
173+
shape_id: shape_id.to_string(),
174+
tenant_id,
175+
shape_type: ShapeType::Document {
176+
collection: collection.to_string(),
177+
predicate: Vec::new(),
178+
},
179+
description: String::new(),
180+
field_filter: Vec::new(),
181+
};
182+
let msg = ShapeSubscribeMsg { shape };
183+
let frame = SyncFrame::new_msgpack(SyncMessageType::ShapeSubscribe, &msg)
184+
.ok_or_else(|| "failed to encode ShapeSubscribeMsg".to_string())?;
185+
self.ws
186+
.send(Message::Binary(frame.to_bytes().into()))
187+
.await
188+
.map_err(|e| format!("failed to send ShapeSubscribe frame: {e}"))?;
189+
Ok(())
190+
}
191+
192+
/// Receive the next binary sync frame from the server, decoded as a
193+
/// `SyncFrame`. Non-binary frames are skipped. Bounded internally by
194+
/// `RECV_TIMEOUT`, so callers do not need to wrap this in a timeout to
195+
/// avoid hanging on a silent server.
196+
pub async fn recv_next_frame(&mut self) -> Result<SyncFrame, String> {
197+
recv_any_frame(&mut self.ws).await
198+
}
158199
}
159200

160201
/// Receive the next binary frame, decode it as a `SyncFrame`, and error out

nodedb/src/control/server/sync/session/state.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ pub struct SyncSession {
4848
/// Origin `CollectionPurged` broadcast to decide which sessions
4949
/// need to be notified when a collection is hard-deleted.
5050
pub tracked_collections: std::collections::HashSet<(u64, String)>,
51+
/// Collections whose descriptor has already been announced to the peer
52+
/// this session via a `CollectionSchema` frame. Enforces the
53+
/// announce-precedes-data guard: a collection's schema is emitted at most
54+
/// once per session, strictly before its first shape snapshot or delta.
55+
pub announced_collections: std::collections::HashSet<String>,
5156
/// Last WAL LSN the client advertised in its vector clock at
5257
/// handshake. Used by offline-client replay to identify
5358
/// `CollectionPurged` events that committed while the client
@@ -86,6 +91,7 @@ impl SyncSession {
8691
rate_limiter: SyncRateLimiter::new(rate_config),
8792
device_metadata: DeviceMetadata::default(),
8893
tracked_collections: std::collections::HashSet::new(),
94+
announced_collections: std::collections::HashSet::new(),
8995
last_seen_lsn: 0,
9096
producer_id: 0,
9197
accepted_epoch: 0,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Collection-schema announce helper.
4+
//!
5+
//! Builds the `CollectionSchema` frame that must precede the first shape
6+
//! snapshot or delta for a collection on a sync session. The emit side of
7+
//! the announce-precedes-data invariant: any peer that receives a delta or
8+
//! snapshot for a collection has already received its descriptor.
9+
10+
use nodedb_types::sync::wire::{CollectionDescriptor, CollectionSchemaSyncMsg};
11+
12+
use tracing::warn;
13+
14+
use crate::control::state::SharedState;
15+
16+
use super::super::session::SyncSession;
17+
use super::super::wire::{SyncFrame, SyncMessageType};
18+
19+
/// Build a `CollectionSchema` announce frame for `collection` if it has not
20+
/// already been announced to the peer this session.
21+
///
22+
/// Returns `None` when the collection was already announced (idempotent) or
23+
/// cannot be resolved in the catalog (logged; the caller then proceeds to
24+
/// send the data frame anyway so delivery never regresses). The caller is
25+
/// responsible for marking the collection announced only after the returned
26+
/// frame has been sent successfully.
27+
pub(in crate::control::server::sync) fn build_collection_schema_frame(
28+
shared: &SharedState,
29+
session: &SyncSession,
30+
tenant_id: u64,
31+
collection: &str,
32+
) -> Option<SyncFrame> {
33+
if session.announced_collections.contains(collection) {
34+
return None;
35+
}
36+
37+
let stored = shared
38+
.credentials
39+
.catalog()
40+
.as_ref()
41+
.and_then(|c| {
42+
c.get_collection(crate::types::DatabaseId::DEFAULT, tenant_id, collection)
43+
.ok()
44+
})
45+
.flatten();
46+
47+
let Some(stored) = stored else {
48+
warn!(
49+
session = %session.session_id,
50+
collection,
51+
tenant_id,
52+
"sync: collection not found in catalog; cannot announce schema before data"
53+
);
54+
return None;
55+
};
56+
57+
let msg = CollectionSchemaSyncMsg {
58+
descriptor: CollectionDescriptor::from(&stored),
59+
// Not correctness-load-bearing: the receive side materializes the
60+
// collection create-only and does not consume this timestamp.
61+
creation_hlc: nodedb_types::hlc::Hlc::ZERO,
62+
};
63+
64+
SyncFrame::new_msgpack(SyncMessageType::CollectionSchema, &msg)
65+
}

nodedb/src/control/server/sync/session_handler/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-License-Identifier: BUSL-1.1
22

3+
mod announce;
34
mod array;
45
mod engine_dispatch;
56
mod session_loop;

nodedb/src/control/server/sync/session_handler/session_loop.rs

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,46 @@ pub(in crate::control::server::sync) async fn handle_sync_session(
129129
)
130130
.await
131131
{
132-
if session.authenticated
133-
&& presence_registered
134-
&& let Some(sub_msg) =
135-
frame.decode_body::<super::super::wire::ShapeSubscribeMsg>()
132+
// Decode once, reused for both the presence-channel
133+
// subscribe and the schema-announce below (avoids a
134+
// redundant second msgpack decode of the same body).
135+
let shape_sub_msg = if session.authenticated {
136+
frame.decode_body::<super::super::wire::ShapeSubscribeMsg>()
137+
} else {
138+
None
139+
};
140+
141+
if let Some(sub_msg) = shape_sub_msg.as_ref()
136142
&& let Some(coll) = sub_msg.shape.collection()
137143
{
138-
let channel = format!("shape:{coll}");
139-
shared
140-
.presence
141-
.write()
142-
.await
143-
.subscribe_to_channel(&session_id, &channel);
144+
if presence_registered {
145+
let channel = format!("shape:{coll}");
146+
shared
147+
.presence
148+
.write()
149+
.await
150+
.subscribe_to_channel(&session_id, &channel);
151+
}
152+
153+
// Announce the collection descriptor before the shape
154+
// snapshot so schema strictly precedes data on the
155+
// subscription path. Idempotent per session; skips shape
156+
// variants that carry no single collection.
157+
let tenant_id = session.tenant_id.map(|t| t.as_u64()).unwrap_or(0);
158+
if let Some(schema_frame) =
159+
super::announce::build_collection_schema_frame(
160+
shared, &session, tenant_id, coll,
161+
)
162+
{
163+
if ws
164+
.send(Message::Binary(schema_frame.to_bytes().into()))
165+
.await
166+
.is_err()
167+
{
168+
break;
169+
}
170+
session.announced_collections.insert(coll.to_string());
171+
}
144172
}
145173

146174
if ws
@@ -351,6 +379,29 @@ pub(in crate::control::server::sync) async fn handle_sync_session(
351379

352380
if let Some(ref mut rx) = crdt_delivery_rx {
353381
while let Ok(delta) = rx.try_recv() {
382+
// Announce the collection descriptor before its first delta so
383+
// schema strictly precedes data on the peer. Idempotent per
384+
// session; a lookup miss warns and proceeds without marking.
385+
if let Some(shared) = shared.as_ref()
386+
&& let Some(schema_frame) = super::announce::build_collection_schema_frame(
387+
shared,
388+
&session,
389+
delta.tenant_id,
390+
&delta.collection,
391+
)
392+
{
393+
if ws
394+
.send(Message::Binary(schema_frame.to_bytes().into()))
395+
.await
396+
.is_err()
397+
{
398+
break;
399+
}
400+
session
401+
.announced_collections
402+
.insert(delta.collection.clone());
403+
}
404+
354405
let push_msg = nodedb_types::sync::wire::DeltaPushMsg {
355406
collection: delta.collection,
356407
document_id: delta.document_id,

0 commit comments

Comments
 (0)