Skip to content

Commit 9a1ed2c

Browse files
committed
fix(sync): register synced collections under Origin's default database
Announced collection descriptors were tagged with database_id=1, but inbound deltas apply under DatabaseId::DEFAULT (0), leaving synced collections registered but unqueryable on Origin. Add an interop test covering lite-to-Origin collection registration end to end, plus a non-panicking query helper for bounded-retry polling while the collection becomes visible.
1 parent f72b6d7 commit 9a1ed2c

3 files changed

Lines changed: 223 additions & 1 deletion

File tree

nodedb-lite/src/sync/collection_schema_builder.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,13 @@ pub(crate) fn descriptor_from_meta(meta: &CollectionMeta) -> Option<CollectionDe
4747
let partition_strategy = PartitionStrategy::default_for_collection_type(&collection_type);
4848

4949
Some(CollectionDescriptor {
50+
// Lite is single-tenant (tenant 1, matching the sync session identity)
51+
// and single-database. The announced descriptor MUST carry the same
52+
// (tenant, database) the synced data lands under on Origin — deltas
53+
// apply under `DatabaseId::DEFAULT` (0), so registering the collection
54+
// under any other database would make it invisible to queries.
5055
tenant_id: 1,
51-
database_id: DatabaseId::new(1),
56+
database_id: DatabaseId::DEFAULT,
5257
name: meta.name.clone(),
5358
collection_type,
5459
bitemporal: meta.bitemporal,

nodedb-lite/tests/common/sql.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ impl OriginPgwire {
6464
.unwrap_or_else(|e| panic!("Origin query failed: {e}\nSQL: {sql}"))
6565
}
6666

67+
/// Execute a query, returning the raw `tokio_postgres` error instead of
68+
/// panicking. Useful for bounded-retry polling where "relation does not
69+
/// exist yet" is an expected transient state to be retried, not a failure.
70+
pub async fn try_query(&self, sql: &str) -> Result<Vec<Row>, tokio_postgres::Error> {
71+
self.client.query(sql, &[]).await
72+
}
73+
6774
/// Execute a SQL statement that returns no rows (DDL/DML).
6875
pub async fn execute(&self, sql: &str) {
6976
self.client.execute(sql, &[]).await.unwrap_or_else(|e| {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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

Comments
 (0)