Skip to content

Commit f0bbfb6

Browse files
committed
feat(sync): materialize peer-announced collections into local catalog
Add the receive-side handler for the CollectionSchema wire message: a peer's announced descriptor is converted to a stored collection and proposed as a create-only PutCollectionIfAbsent entry, then applied locally, mirroring the pgwire CREATE path. Post-apply side effects for PutCollectionIfAbsent now read the canonical collection back from the catalog instead of trusting the carried entry, so a no-op re-announce (collection already existed) can't clobber the pre-existing owner or register a divergent config into the Data Plane.
1 parent 32c07f9 commit f0bbfb6

9 files changed

Lines changed: 334 additions & 23 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
//! A peer-announced `CollectionSchema` (opcode 0x13) materializes the
3+
//! collection into the cluster catalog on EVERY node.
4+
//!
5+
//! ## What this guards
6+
//!
7+
//! When a sync peer announces a `CollectionSchemaSyncMsg`, the receiving
8+
//! node must materialize the collection into the system catalog — create-only,
9+
//! never clobbering an existing collection — and, via the shared post-apply
10+
//! path, register the Data-Plane engine state on every node that applies the
11+
//! Raft entry. The end result: the synced collection is catalog-visible and
12+
//! queryable cluster-wide, carrying the correct engine type and bitemporal
13+
//! flag.
14+
//!
15+
//! The test drives the sync WebSocket end-to-end: it connects to ONE node's
16+
//! sync listener, announces a descriptor for each supported engine, then
17+
//! asserts the collection appears with the right `collection_type` +
18+
//! `bitemporal` on a DIFFERENT node — proving Raft propagation plus per-node
19+
//! catalog materialization, not just a local write on the receiving node.
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::columnar::{ColumnDef, ColumnType, StrictSchema};
30+
use nodedb_types::sync::wire::CollectionDescriptor;
31+
use nodedb_types::{CollectionType, DatabaseId, Hlc};
32+
33+
/// Trust-mode sync sessions authenticate as tenant 1 (see the sync
34+
/// handshake), so announced descriptors must carry tenant 1.
35+
const TENANT: u64 = 1;
36+
37+
/// A single strict-schema column set reused by strict + kv descriptors.
38+
fn pk_schema() -> StrictSchema {
39+
StrictSchema {
40+
columns: vec![ColumnDef::required("id", ColumnType::Int64).with_primary_key()],
41+
version: 1,
42+
dropped_columns: Vec::new(),
43+
bitemporal: false,
44+
}
45+
}
46+
47+
/// Build a descriptor for `name` with `collection_type` and `bitemporal`,
48+
/// mirroring the shape a local `CREATE COLLECTION` would emit over sync.
49+
fn descriptor(
50+
name: &str,
51+
collection_type: CollectionType,
52+
bitemporal: bool,
53+
) -> CollectionDescriptor {
54+
CollectionDescriptor {
55+
tenant_id: TENANT,
56+
database_id: DatabaseId::DEFAULT,
57+
name: name.to_string(),
58+
partition_strategy: PartitionStrategy::default_for_collection_type(&collection_type),
59+
collection_type,
60+
bitemporal,
61+
fields: Vec::new(),
62+
primary: PrimaryEngine::Document,
63+
vector_primary: None,
64+
declared_primary_key: None,
65+
descriptor_version: 0,
66+
}
67+
}
68+
69+
/// Poll `node`'s local catalog until the collection is visible, then return
70+
/// its `(collection_type, bitemporal)`. Panics if it does not converge —
71+
/// a bounded retry loop, not a single fixed sleep.
72+
async fn await_collection(node: &TestClusterNode, name: &str) -> (CollectionType, bool) {
73+
let deadline = Instant::now() + Duration::from_secs(30);
74+
loop {
75+
let found = node.shared.credentials.catalog().as_ref().and_then(|c| {
76+
c.get_collection(DatabaseId::DEFAULT, TENANT, name)
77+
.ok()
78+
.flatten()
79+
});
80+
if let Some(coll) = found {
81+
return (coll.collection_type, coll.bitemporal);
82+
}
83+
if Instant::now() >= deadline {
84+
panic!("collection '{name}' did not become catalog-visible on the follower within 30s");
85+
}
86+
tokio::time::sleep(Duration::from_millis(100)).await;
87+
}
88+
}
89+
90+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
91+
async fn announced_schema_materializes_on_every_node() {
92+
let cluster = TestCluster::spawn_three()
93+
.await
94+
.expect("spawn three-node cluster");
95+
96+
// Start the sync listener on node 0; the client connects here, so node 0
97+
// is the RECEIVING node. Assertions run on node 1 (a different node) to
98+
// prove Raft propagation + per-node catalog materialization.
99+
let cfg = SyncListenerConfig {
100+
listen_addr: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
101+
..Default::default()
102+
};
103+
let state = start_sync_listener(cfg, Some(std::sync::Arc::clone(&cluster.nodes[0].shared)))
104+
.await
105+
.expect("start sync listener on node 0");
106+
let addr = state.config.listen_addr;
107+
108+
let mut client = SyncTestClient::connect(addr)
109+
.await
110+
.expect("sync handshake with node 0");
111+
112+
// One descriptor per engine, plus a bitemporal document variant.
113+
let cases: Vec<(&str, CollectionType, bool)> = vec![
114+
("sync_doc", CollectionType::document(), false),
115+
("sync_strict", CollectionType::strict(pk_schema()), false),
116+
("sync_kv", CollectionType::kv(pk_schema()), false),
117+
("sync_columnar", CollectionType::columnar(), false),
118+
("sync_ts", CollectionType::timeseries("ts", "1m"), false),
119+
("sync_spatial", CollectionType::spatial("geom"), false),
120+
("sync_bitemporal", CollectionType::document(), true),
121+
];
122+
123+
let hlc = Hlc::new(1, 0);
124+
for (name, ct, bitemporal) in &cases {
125+
client
126+
.push_collection_schema(descriptor(name, ct.clone(), *bitemporal), hlc)
127+
.await
128+
.unwrap_or_else(|e| panic!("push CollectionSchema for '{name}': {e}"));
129+
}
130+
131+
// Assert on node 1 — a node the client never talked to.
132+
let follower = &cluster.nodes[1];
133+
for (name, expected_ct, expected_bitemporal) in &cases {
134+
let (got_ct, got_bitemporal) = await_collection(follower, name).await;
135+
assert_eq!(
136+
&got_ct, expected_ct,
137+
"collection '{name}' materialized with the wrong engine type on the follower"
138+
);
139+
assert_eq!(
140+
got_bitemporal, *expected_bitemporal,
141+
"collection '{name}' materialized with the wrong bitemporal flag on the follower"
142+
);
143+
}
144+
145+
cluster.shutdown().await;
146+
}

nodedb-test-support/src/sync_client.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ use futures::{SinkExt, StreamExt};
1616
use tokio::net::TcpStream;
1717
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message};
1818

19+
use nodedb_types::Hlc;
1920
use nodedb_types::sync::wire::{
20-
DeltaAckMsg, DeltaPushMsg, DeltaRejectMsg, HandshakeAckMsg, HandshakeMsg, SyncFrame,
21-
SyncMessageType,
21+
CollectionDescriptor, CollectionSchemaSyncMsg, DeltaAckMsg, DeltaPushMsg, DeltaRejectMsg,
22+
HandshakeAckMsg, HandshakeMsg, SyncFrame, SyncMessageType,
2223
};
2324

2425
/// Bound on how long a single frame receive may take before the test client
@@ -133,6 +134,27 @@ impl SyncTestClient {
133134
}
134135
}
135136
}
137+
138+
/// Announce a collection descriptor to the server. Fire-and-forget:
139+
/// the CollectionSchema announce has no ack frame, so this only sends
140+
/// the frame and returns once it is flushed onto the socket.
141+
pub async fn push_collection_schema(
142+
&mut self,
143+
descriptor: CollectionDescriptor,
144+
creation_hlc: Hlc,
145+
) -> Result<(), String> {
146+
let msg = CollectionSchemaSyncMsg {
147+
descriptor,
148+
creation_hlc,
149+
};
150+
let frame = SyncFrame::new_msgpack(SyncMessageType::CollectionSchema, &msg)
151+
.ok_or_else(|| "failed to encode CollectionSchemaSyncMsg".to_string())?;
152+
self.ws
153+
.send(Message::Binary(frame.to_bytes().into()))
154+
.await
155+
.map_err(|e| format!("failed to send CollectionSchema frame: {e}"))?;
156+
Ok(())
157+
}
136158
}
137159

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

nodedb/src/control/catalog_entry/post_apply/async_dispatch/dispatcher.rs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,43 @@ pub fn spawn_post_apply_async_side_effects(
5050
});
5151
}
5252
CatalogEntry::PutCollectionIfAbsent(stored) => {
53-
// SYNCHRONOUS: Register must complete before the applied-index
54-
// watcher bumps so any subsequent scan on this node finds the
55-
// collection in doc_configs. block_in_place is valid because
56-
// the raft tick loop runs on a tokio worker thread.
57-
tokio::task::block_in_place(|| {
58-
tokio::runtime::Handle::current().block_on(async move {
59-
collection::put_async(*stored, shared).await;
60-
});
53+
// Register from the CANONICAL collection read back from the
54+
// catalog after apply — never from the carried entry. On the
55+
// no-op path (the collection already existed) the carried
56+
// `stored` may hold a divergent incoming config; the catalog
57+
// holds the authoritative pre-existing one. Post-apply the
58+
// collection always exists (created or pre-existing), so the
59+
// read-back is always Some; a None here would mean the redb
60+
// write silently failed, so warn and skip rather than register
61+
// a divergent config.
62+
let canonical = shared.credentials.catalog().as_ref().and_then(|catalog| {
63+
catalog
64+
.get_collection(stored.database_id, stored.tenant_id, &stored.name)
65+
.ok()
66+
.flatten()
6167
});
68+
match canonical {
69+
Some(canonical) => {
70+
// SYNCHRONOUS: Register must complete before the
71+
// applied-index watcher bumps so any subsequent scan on
72+
// this node finds the collection in doc_configs.
73+
// block_in_place is valid because the raft tick loop
74+
// runs on a tokio worker thread.
75+
tokio::task::block_in_place(|| {
76+
tokio::runtime::Handle::current().block_on(async move {
77+
collection::put_async(canonical, shared).await;
78+
});
79+
});
80+
}
81+
None => {
82+
tracing::warn!(
83+
collection = %stored.name,
84+
tenant = stored.tenant_id,
85+
"PutCollectionIfAbsent post-apply: canonical collection not found in \
86+
catalog after apply; skipping Data Plane register"
87+
);
88+
}
89+
}
6290
}
6391
CatalogEntry::PurgeCollection { tenant_id, name } => {
6492
tokio::spawn(async move {

nodedb/src/control/catalog_entry/post_apply/sync.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,23 @@ pub fn apply_post_apply_side_effects_sync(entry: &CatalogEntry, shared: &Arc<Sha
4545
collection::put_owner_sync(stored, Arc::clone(shared));
4646
}
4747
CatalogEntry::PutCollectionIfAbsent(stored) => {
48+
// Install owner from the CANONICAL catalog collection, not the
49+
// carried entry: a no-op re-announce must not overwrite the
50+
// pre-existing owner. Post-apply the collection always exists,
51+
// so the read-back is Some; the carried entry is only a
52+
// best-effort fallback if the redb write silently failed.
4853
// Owner record install is sync; Data Plane register is
4954
// the async part, handled by `spawn_post_apply_async_side_effects`.
50-
collection::put_owner_sync(stored, Arc::clone(shared));
55+
let canonical = shared.credentials.catalog().as_ref().and_then(|catalog| {
56+
catalog
57+
.get_collection(stored.database_id, stored.tenant_id, &stored.name)
58+
.ok()
59+
.flatten()
60+
});
61+
match canonical {
62+
Some(canonical) => collection::put_owner_sync(&canonical, Arc::clone(shared)),
63+
None => collection::put_owner_sync(stored, Arc::clone(shared)),
64+
}
5165
}
5266
CatalogEntry::DeactivateCollection { tenant_id, name } => {
5367
collection::deactivate(*tenant_id, name.clone(), Arc::clone(shared));

nodedb/src/control/security/catalog/collection_descriptor_convert.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,6 @@ impl From<&StoredCollection> for CollectionDescriptor {
4141
/// sync-wire one. All fields not carried by the descriptor (field_defs,
4242
/// event_defs, indexes, constraints, `is_active`, etc.) are left at the
4343
/// [`StoredCollection::new`] defaults.
44-
// Consumed by the sync receive handler that materializes announced
45-
// collections into the catalog; only the round-trip tests call it today.
46-
#[allow(dead_code)]
4744
pub(crate) fn stored_from_descriptor(
4845
descriptor: &CollectionDescriptor,
4946
owner: &str,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! CollectionSchema receive handler.
4+
//!
5+
//! When a sync peer announces a [`CollectionSchemaSyncMsg`], the receiving
6+
//! cluster materializes the collection into its system catalog (create-only,
7+
//! via `PutCollectionIfAbsent` — never clobbering an existing collection).
8+
//! The Data-Plane engine register happens in the shared post-apply path on
9+
//! **every** node that applies the Raft entry, exactly as it does for a
10+
//! local `CREATE COLLECTION`. This handler is therefore symmetric with the
11+
//! pgwire CREATE handler: it only `stored_from_descriptor` → propose
12+
//! `PutCollectionIfAbsent` → `apply_locally_if_needed`, and never dispatches
13+
//! the register itself.
14+
15+
use std::sync::Arc;
16+
17+
use tracing::warn;
18+
19+
use crate::control::catalog_entry::CatalogEntry;
20+
use crate::control::state::SharedState;
21+
22+
use super::super::wire::{CollectionSchemaSyncMsg, SyncFrame};
23+
use super::state::SyncSession;
24+
25+
impl SyncSession {
26+
/// Materialize a peer-announced collection descriptor into the local
27+
/// catalog. Returns `None`: this is a fire-and-forget announce with no
28+
/// ack frame (mirrors `ShapeUnsubscribe`).
29+
pub fn handle_collection_schema(
30+
&mut self,
31+
msg: &CollectionSchemaSyncMsg,
32+
shared: Option<&Arc<SharedState>>,
33+
) -> Option<SyncFrame> {
34+
let Some(shared) = shared else {
35+
warn!(
36+
session = %self.session_id,
37+
collection = %msg.descriptor.name,
38+
"CollectionSchema received without SharedState (permissive/test path); dropping"
39+
);
40+
return None;
41+
};
42+
43+
let Some(tenant) = self.tenant_id else {
44+
warn!(
45+
session = %self.session_id,
46+
collection = %msg.descriptor.name,
47+
"CollectionSchema received before handshake established a tenant; dropping"
48+
);
49+
return None;
50+
};
51+
52+
// Security: a peer must not materialize a collection into a tenant
53+
// other than the one it authenticated as.
54+
if msg.descriptor.tenant_id != tenant.as_u64() {
55+
warn!(
56+
session = %self.session_id,
57+
collection = %msg.descriptor.name,
58+
descriptor_tenant = msg.descriptor.tenant_id,
59+
session_tenant = tenant.as_u64(),
60+
"CollectionSchema tenant mismatch; refusing to materialize"
61+
);
62+
return None;
63+
}
64+
65+
// Owner is the receiving peer's authenticated principal — the same
66+
// identity a local CREATE records as owner.
67+
let owner = self.username.as_deref().unwrap_or("sync");
68+
69+
let stored =
70+
crate::control::security::catalog::collection_descriptor_convert::stored_from_descriptor(
71+
&msg.descriptor,
72+
owner,
73+
);
74+
75+
let entry = CatalogEntry::PutCollectionIfAbsent(Box::new(stored));
76+
let log_index =
77+
match crate::control::metadata_proposer::propose_catalog_entry(shared, &entry) {
78+
Ok(idx) => idx,
79+
Err(e) => {
80+
warn!(
81+
session = %self.session_id,
82+
collection = %msg.descriptor.name,
83+
error = %e,
84+
"CollectionSchema: failed to propose PutCollectionIfAbsent; \
85+
collection not materialized"
86+
);
87+
return None;
88+
}
89+
};
90+
crate::control::catalog_entry::apply::local::apply_locally_if_needed(
91+
shared, &entry, log_index,
92+
);
93+
None
94+
}
95+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,14 @@ impl SyncSession {
216216
);
217217
None
218218
}
219+
SyncMessageType::CollectionSchema => {
220+
// A peer announces a collection descriptor. Materialize it
221+
// into the local catalog (create-only) so it becomes
222+
// catalog-visible and queryable cluster-wide; the handler
223+
// handles the permissive `shared == None` path by warn+skip.
224+
let msg: CollectionSchemaSyncMsg = frame.decode_body()?;
225+
self.handle_collection_schema(&msg, shared)
226+
}
219227
_ => {
220228
warn!(
221229
session = %self.session_id,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//! - `dispatch.rs` — `process_frame` (match on `msg_type`, route).
1616
1717
pub mod clock_ping;
18+
pub mod collection_schema;
1819
pub mod delta;
1920
pub mod dispatch;
2021
pub mod handshake;

0 commit comments

Comments
 (0)