|
| 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 | +} |
0 commit comments