|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | + |
| 3 | +//! Gate test: collection registration via the outbound `CollectionSchema` |
| 4 | +//! (opcode `0x13`) announce — Lite → Origin, with NO pre-creation on Origin. |
| 5 | +//! |
| 6 | +//! Proves that a schemaless DOCUMENT collection created ONLY on Lite becomes |
| 7 | +//! registered and queryable on a real Origin server purely through Lite's |
| 8 | +//! outbound schema announce that fires when documents are synced. Origin |
| 9 | +//! never runs `CREATE COLLECTION` for this collection — its existence in the |
| 10 | +//! Origin catalog (`pg_class`) and its rows (`SELECT id FROM ...`) must come |
| 11 | +//! entirely from the sync protocol. |
| 12 | +//! |
| 13 | +//! The collection is created explicitly on Lite via |
| 14 | +//! [`nodedb_lite::NodeDbLite::create_collection`] (not implicitly via |
| 15 | +//! `document_put`, and not via `CREATE COLLECTION ... WITH (engine=...)` |
| 16 | +//! SQL) because the outbound emit path reads the collection's persisted |
| 17 | +//! [`nodedb_lite::nodedb::collection::CollectionMeta`] to build the |
| 18 | +//! `CollectionDescriptor` carried in the announce; an implicitly-created |
| 19 | +//! collection has no `CollectionMeta` row and would not be announced. The |
| 20 | +//! `CREATE COLLECTION ... WITH (engine='document_schemaless')` SQL DDL does |
| 21 | +//! not intercept this shape (it only intercepts `storage='strict'`, |
| 22 | +//! `storage='columnar'`, `storage='kv'`, and `bitemporal=true` forms — see |
| 23 | +//! `src/query/ddl/mod.rs::try_handle_ddl`), so it falls through to |
| 24 | +//! DataFusion without persisting a `CollectionMeta`. The `create_collection` |
| 25 | +//! API call is the mechanism that reliably persists one. |
| 26 | +//! |
| 27 | +//! ## How to run |
| 28 | +//! |
| 29 | +//! Build the Origin binary first: |
| 30 | +//! ```text |
| 31 | +//! cd <project-root>/nodedb && cargo build -p nodedb |
| 32 | +//! ``` |
| 33 | +//! Then run from the nodedb-lite workspace root: |
| 34 | +//! ```text |
| 35 | +//! cargo nextest run -p nodedb-lite --test sync_interop_collection_registration |
| 36 | +//! ``` |
| 37 | +//! |
| 38 | +//! The test is placed in the `heavy` nextest group (serialized) by the |
| 39 | +//! `binary(/sync_interop/)` filter in `.config/nextest.toml`. |
| 40 | +
|
| 41 | +mod common; |
| 42 | + |
| 43 | +use std::sync::Arc; |
| 44 | +use std::time::Duration; |
| 45 | + |
| 46 | +use nodedb_client::NodeDb; |
| 47 | +use nodedb_lite::sync::{SyncClient, SyncConfig, run_sync_loop}; |
| 48 | +use nodedb_lite::{NodeDbLite, PagedbStorageMem}; |
| 49 | +use nodedb_types::document::Document; |
| 50 | +use nodedb_types::value::Value; |
| 51 | + |
| 52 | +use common::origin::OriginServer; |
| 53 | +use common::sql::OriginPgwire; |
| 54 | + |
| 55 | +// ── Collection identity ───────────────────────────────────────────────────── |
| 56 | + |
| 57 | +/// Schemaless document collection, created ONLY on Lite. Origin must learn of |
| 58 | +/// it purely via the outbound `CollectionSchema` (0x13) announce. |
| 59 | +const COLLECTION: &str = "doc_reg_test"; |
| 60 | + |
| 61 | +// ── Helper: open a Lite DB backed by in-memory storage ───────────────────────── |
| 62 | + |
| 63 | +async fn open_lite() -> Arc<NodeDbLite<PagedbStorageMem>> { |
| 64 | + let storage = PagedbStorageMem::open_in_memory() |
| 65 | + .await |
| 66 | + .expect("open_in_memory"); |
| 67 | + Arc::new( |
| 68 | + NodeDbLite::open(storage, 1) |
| 69 | + .await |
| 70 | + .expect("NodeDbLite::open"), |
| 71 | + ) |
| 72 | +} |
| 73 | + |
| 74 | +/// Wire up the sync transport and wait until the connection is established. |
| 75 | +async fn start_sync(lite: Arc<NodeDbLite<PagedbStorageMem>>, peer_id: u64) -> Arc<SyncClient> { |
| 76 | + let sync_config = SyncConfig::new(common::origin::ORIGIN_WS, ""); |
| 77 | + let sync_client = Arc::new(SyncClient::new(sync_config, peer_id)); |
| 78 | + let delegate = Arc::clone(&lite) as Arc<dyn nodedb_lite::sync::SyncDelegate>; |
| 79 | + let client_clone = Arc::clone(&sync_client); |
| 80 | + tokio::spawn(async move { |
| 81 | + run_sync_loop(client_clone, delegate).await; |
| 82 | + }); |
| 83 | + |
| 84 | + // Wait up to 10 s for the connection to become established. |
| 85 | + let deadline = tokio::time::sleep(Duration::from_secs(10)); |
| 86 | + tokio::pin!(deadline); |
| 87 | + loop { |
| 88 | + tokio::select! { |
| 89 | + _ = &mut deadline => panic!("sync connection did not establish within 10 seconds"), |
| 90 | + _ = tokio::time::sleep(Duration::from_millis(50)) => { |
| 91 | + if sync_client.state().await == nodedb_lite::sync::SyncState::Connected { |
| 92 | + break; |
| 93 | + } |
| 94 | + } |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + sync_client |
| 99 | +} |
| 100 | + |
| 101 | +/// Build a document with a `body` field containing distinguishing content. |
| 102 | +fn make_doc(id: &str, body: &str) -> Document { |
| 103 | + let mut doc = Document::new(id); |
| 104 | + doc.set("body", Value::String(body.to_owned())); |
| 105 | + doc |
| 106 | +} |
| 107 | + |
| 108 | +// ── Tests ───────────────────────────────────────────────────────────────────── |
| 109 | + |
| 110 | +/// Creates `doc_reg_test` ONLY on Lite (no Origin pre-create), inserts 3 |
| 111 | +/// documents, and asserts that Origin — having received only the sync |
| 112 | +/// stream — registers the collection in its catalog and serves the rows. |
| 113 | +/// |
| 114 | +/// This is the end-to-end proof for issue #146: collection registration must |
| 115 | +/// happen purely via the outbound `CollectionSchema` announce, not via any |
| 116 | +/// side-channel DDL against Origin. |
| 117 | +#[tokio::test] |
| 118 | +async fn document_collection_registers_on_origin_via_announce() { |
| 119 | + let Some(_origin) = OriginServer::try_spawn_with_pgwire() else { |
| 120 | + eprintln!("SKIP: Origin binary unavailable (set NODEDB_BIN or run via `cargo nextest`)"); |
| 121 | + return; |
| 122 | + }; |
| 123 | + let pg = OriginPgwire::connect().await; |
| 124 | + |
| 125 | + // Deliberately NOT creating the collection on Origin. Origin must learn |
| 126 | + // of `doc_reg_test` solely from the sync announce triggered below. |
| 127 | + |
| 128 | + let lite = open_lite().await; |
| 129 | + |
| 130 | + // Create the collection explicitly on Lite so a `CollectionMeta` is |
| 131 | + // persisted — this is what makes the outbound `CollectionSchema` |
| 132 | + // announce fire (the emit path loads the collection's meta; an |
| 133 | + // implicitly-created collection has no meta and would be skipped). |
| 134 | + lite.create_collection(COLLECTION, &[]) |
| 135 | + .await |
| 136 | + .expect("Lite create_collection doc_reg_test"); |
| 137 | + |
| 138 | + let _sync = start_sync(Arc::clone(&lite), 20).await; |
| 139 | + |
| 140 | + // Insert 3 documents with distinct ids. |
| 141 | + let ids = ["doc-a", "doc-b", "doc-c"]; |
| 142 | + for id in ids { |
| 143 | + let doc = make_doc(id, &format!("registration probe content for {id}")); |
| 144 | + lite.document_put(COLLECTION, doc) |
| 145 | + .await |
| 146 | + .unwrap_or_else(|e| panic!("Lite document_put {id}: {e}")); |
| 147 | + } |
| 148 | + |
| 149 | + // PROOF 1 — registration via the announce: the collection must become |
| 150 | + // catalog-visible on Origin with NO pre-create. `pg_class` always exists, |
| 151 | + // so this query returns an empty set (not an error) until the announced |
| 152 | + // `PutCollectionIfAbsent` lands — a tolerant poll target. |
| 153 | + let mut catalog_visible = false; |
| 154 | + let deadline = tokio::time::sleep(Duration::from_secs(10)); |
| 155 | + tokio::pin!(deadline); |
| 156 | + loop { |
| 157 | + tokio::select! { |
| 158 | + _ = &mut deadline => break, |
| 159 | + _ = tokio::time::sleep(Duration::from_millis(200)) => { |
| 160 | + if let Ok(rows) = pg |
| 161 | + .try_query("SELECT relname FROM pg_class WHERE relname = 'doc_reg_test'") |
| 162 | + .await |
| 163 | + && !rows.is_empty() |
| 164 | + { |
| 165 | + catalog_visible = true; |
| 166 | + break; |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | + } |
| 171 | + assert!( |
| 172 | + catalog_visible, |
| 173 | + "doc_reg_test must become visible in Origin's pg_class catalog via the \ |
| 174 | + CollectionSchema announce (no Origin pre-create) within the deadline" |
| 175 | + ); |
| 176 | + |
| 177 | + // PROOF 2 — data served: the synced documents must be queryable on Origin. |
| 178 | + // Synced schemaless documents are read via the always-on BM25 index |
| 179 | + // (`text_match`), the same read path the FTS interop test exercises; each |
| 180 | + // body contains the term "registration". |
| 181 | + let mut origin_row_count: usize = 0; |
| 182 | + let deadline = tokio::time::sleep(Duration::from_secs(8)); |
| 183 | + tokio::pin!(deadline); |
| 184 | + loop { |
| 185 | + tokio::select! { |
| 186 | + _ = &mut deadline => break, |
| 187 | + _ = tokio::time::sleep(Duration::from_millis(200)) => { |
| 188 | + if let Ok(rows) = pg |
| 189 | + .try_query( |
| 190 | + "SELECT id FROM doc_reg_test \ |
| 191 | + WHERE text_match(body, 'registration') LIMIT 10", |
| 192 | + ) |
| 193 | + .await |
| 194 | + && rows.len() >= 3 |
| 195 | + { |
| 196 | + origin_row_count = rows.len(); |
| 197 | + break; |
| 198 | + } |
| 199 | + } |
| 200 | + } |
| 201 | + } |
| 202 | + assert_eq!( |
| 203 | + origin_row_count, 3, |
| 204 | + "Origin must serve 3 synced documents for doc_reg_test after registration \ |
| 205 | + via the CollectionSchema announce (no Origin pre-create); got {origin_row_count}" |
| 206 | + ); |
| 207 | + |
| 208 | + // Cleanup. |
| 209 | + pg.execute("DROP COLLECTION doc_reg_test").await; |
| 210 | +} |
0 commit comments