Skip to content

Commit 9e60ef9

Browse files
committed
test: add encryption, auto-flush, and dedup tests; wire nextest setup script for sync tests
New test files: - `encryption_roundtrip.rs`: verifies passphrase and raw-key encryption round-trip across reopen. - `auto_flush.rs`: verifies that the background flush task persists writes within the configured interval. - `semantics/dedup.rs`: verifies that reconnect re-sends are deduplicated by Origin (idempotent-producer gate). Nextest configuration: - Add `scripts/ensure-origin.sh` setup script that builds the Origin binary before sync-interop and SQL parity test runs. - Wire the setup script into both the `default` and `ci` profiles for all `sync_interop_*`, `sync_load`, `definition_sync_interop`, and `sql_parity` test binaries. Existing integration and sync-interop tests updated throughout to match the new async delegate API, durable outbound queue signatures, and `Encryption` parameter on `PagedbStorage::open`.
1 parent 0bd7e6a commit 9e60ef9

44 files changed

Lines changed: 1060 additions & 195 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.config/nextest.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
#
66
# Run with: cargo nextest run -p nodedb-lite
77

8+
experimental = ["setup-scripts"]
9+
10+
[scripts.setup.build-origin]
11+
command = { command-line = "scripts/ensure-origin.sh", relative-to = "workspace-root" }
12+
slow-timeout = { period = "600s", terminate-after = 1 }
13+
capture-stderr = true
14+
815
[profile.default]
916
# Hard ceiling per test. Anything above this is a bug, not a slow test.
1017
slow-timeout = { period = "30s", terminate-after = 4 }
@@ -47,6 +54,10 @@ binary(/concurrency/)
4754
test-group = 'heavy'
4855
threads-required = 'num-test-threads'
4956

57+
[[profile.default.scripts]]
58+
filter = 'binary(/sync_interop/) | binary(/sync_load/) | binary(/definition_sync_interop/) | binary(/sql_parity/)'
59+
setup = 'build-origin'
60+
5061
[test-groups]
5162
heavy = { max-threads = 1 }
5263

@@ -56,3 +67,7 @@ fail-fast = false
5667

5768
[profile.ci.junit]
5869
path = "junit.xml"
70+
71+
[[profile.ci.scripts]]
72+
filter = 'binary(/sync_interop/) | binary(/sync_load/) | binary(/definition_sync_interop/) | binary(/sql_parity/)'
73+
setup = 'build-origin'

nodedb-lite/tests/array_sync_catchup.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ async fn snapshot_stream_applies_all_ops() {
195195
/// `CatchupTracker::record` persists across a reload from the same storage.
196196
#[tokio::test(flavor = "multi_thread")]
197197
async fn catchup_last_seen_persists_across_reload() {
198-
use nodedb_lite::PagedbStorageDefault;
199198
use nodedb_lite::sync::array::catchup::CatchupTracker;
199+
use nodedb_lite::{Encryption, PagedbStorageDefault};
200200
use std::sync::Arc;
201201

202202
let dir = tempfile::tempdir().expect("tempdir");
@@ -205,15 +205,23 @@ async fn catchup_last_seen_persists_across_reload() {
205205
let target_hlc = common::hlc1(77_000);
206206

207207
{
208-
let storage = Arc::new(PagedbStorageDefault::open(&path).await.expect("open"));
208+
let storage = Arc::new(
209+
PagedbStorageDefault::open(&path, Encryption::Plaintext)
210+
.await
211+
.expect("open"),
212+
);
209213
let tracker = CatchupTracker::load(Arc::clone(&storage))
210214
.await
211215
.expect("load");
212216
tracker.record("arr", target_hlc).await.expect("record");
213217
}
214218

215219
{
216-
let storage = Arc::new(PagedbStorageDefault::open(&path).await.expect("reopen"));
220+
let storage = Arc::new(
221+
PagedbStorageDefault::open(&path, Encryption::Plaintext)
222+
.await
223+
.expect("reopen"),
224+
);
217225
let tracker = CatchupTracker::load(storage)
218226
.await
219227
.expect("load after restart");

nodedb-lite/tests/array_sync_interop.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ mod common;
1717
#[test]
1818
#[ignore = "array sync over real Origin transport not yet wired; see module doc"]
1919
fn array_interop_put_roundtrip() {
20-
let _origin = common::origin::OriginServer::spawn();
20+
let Some(_origin) = common::origin::OriginServer::try_spawn() else {
21+
eprintln!("SKIP: Origin binary unavailable (set NODEDB_BIN or run via `cargo nextest`)");
22+
return;
23+
};
2124
// When unignored this test should:
2225
// 1. Connect a Lite sync client to _origin.sync_addr().
2326
// 2. Subscribe to an array shape.
@@ -36,7 +39,10 @@ fn array_interop_put_roundtrip() {
3639
#[test]
3740
#[ignore = "array sync over real Origin transport not yet wired; see module doc"]
3841
fn array_interop_catchup_after_gap() {
39-
let _origin = common::origin::OriginServer::spawn();
42+
let Some(_origin) = common::origin::OriginServer::try_spawn() else {
43+
eprintln!("SKIP: Origin binary unavailable (set NODEDB_BIN or run via `cargo nextest`)");
44+
return;
45+
};
4046
// When unignored this test should:
4147
// 1. Seed Origin with array ops via a Lite client that then disconnects.
4248
// 2. Connect a fresh Lite client with a stale cursor.

nodedb-lite/tests/array_sync_interop_real.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ async fn array_delta_apply_and_ack() {
8282
let msg = ArrayDeltaMsg {
8383
array: "arr".into(),
8484
op_payload: payload,
85+
producer_id: 0,
86+
epoch: 0,
87+
seq: 0,
8588
};
8689

8790
// This is the exact call `dispatch_frame` makes.
@@ -145,6 +148,9 @@ async fn array_delta_idempotent_no_ack() {
145148
let msg = ArrayDeltaMsg {
146149
array: "idem".into(),
147150
op_payload: payload,
151+
producer_id: 0,
152+
epoch: 0,
153+
seq: 0,
148154
};
149155

150156
// First application — ack expected.
@@ -242,6 +248,9 @@ fn array_delta_frame_roundtrip() {
242248
let msg = ArrayDeltaMsg {
243249
array: "rt_arr".into(),
244250
op_payload: vec![0xDE, 0xAD, 0xBE, 0xEF],
251+
producer_id: 0,
252+
epoch: 0,
253+
seq: 0,
245254
};
246255

247256
let frame = SyncFrame::try_encode(SyncMessageType::ArrayDelta, &msg)

nodedb-lite/tests/array_sync_reject.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ async fn schema_too_new_surfaces_as_rejected_outcome() {
159159
let msg = ArrayDeltaMsg {
160160
array: "schema_rej".into(),
161161
op_payload: payload,
162+
producer_id: 0,
163+
epoch: 0,
164+
seq: 0,
162165
};
163166

164167
let outcome = harness.inbound.handle_delta(&msg).expect("handle_delta");

nodedb-lite/tests/auto_flush.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Integration tests for the auto-flush background task.
4+
//!
5+
//! Verifies the bounded-durability contract: writes are durable within
6+
//! `auto_flush_ms` milliseconds even without an explicit `flush()` call.
7+
8+
use std::sync::Arc;
9+
use std::time::Duration;
10+
11+
use nodedb_lite::{Encryption, LiteConfig, NodeDbLite, PagedbStorageDefault};
12+
13+
// ---------------------------------------------------------------------------
14+
// auto_flush_persists_without_explicit_flush
15+
// ---------------------------------------------------------------------------
16+
17+
/// A key written while auto-flush is active (interval 200 ms) survives a
18+
/// drop + reopen without any explicit `flush()` call, provided we wait long
19+
/// enough for at least one tick to fire.
20+
#[tokio::test]
21+
async fn auto_flush_persists_without_explicit_flush() {
22+
let dir = tempfile::tempdir().expect("tempdir");
23+
let path = dir.path().join("auto_flush_persist.pagedb");
24+
25+
{
26+
let storage = PagedbStorageDefault::open(&path, Encryption::Plaintext)
27+
.await
28+
.expect("open storage");
29+
let config = LiteConfig {
30+
auto_flush_ms: 200,
31+
..LiteConfig::default()
32+
};
33+
let db = Arc::new(
34+
NodeDbLite::open_with_config(storage, 1, config)
35+
.await
36+
.expect("open db"),
37+
);
38+
db.start_auto_flush(200);
39+
40+
db.kv_put("col", "key", b"auto_flushed")
41+
.await
42+
.expect("kv_put");
43+
44+
// Wait long enough for at least one auto-flush tick (200 ms interval,
45+
// first tick is immediate on native Tokio; second tick fires at ~200 ms).
46+
tokio::time::sleep(Duration::from_millis(450)).await;
47+
48+
// Drop without explicit flush — the auto-flush task already ran.
49+
}
50+
51+
{
52+
let storage = PagedbStorageDefault::open(&path, Encryption::Plaintext)
53+
.await
54+
.expect("reopen storage");
55+
let db = NodeDbLite::open(storage, 1).await.expect("reopen db");
56+
let got = db.kv_get("col", "key").await.expect("kv_get after reopen");
57+
assert_eq!(
58+
got.as_deref(),
59+
Some(b"auto_flushed".as_slice()),
60+
"key must survive reopen when auto-flush fired before drop"
61+
);
62+
}
63+
}
64+
65+
// ---------------------------------------------------------------------------
66+
// disabled_auto_flush_does_not_persist
67+
// ---------------------------------------------------------------------------
68+
69+
/// With `auto_flush_ms: 0` (disabled) and no explicit `flush()`, a write is
70+
/// NOT durable — a drop + immediate reopen finds nothing. This documents the
71+
/// bounded-window contract: callers must either enable auto-flush or call
72+
/// `flush()` explicitly.
73+
#[tokio::test]
74+
async fn disabled_auto_flush_does_not_persist() {
75+
let dir = tempfile::tempdir().expect("tempdir");
76+
let path = dir.path().join("auto_flush_disabled.pagedb");
77+
78+
{
79+
let storage = PagedbStorageDefault::open(&path, Encryption::Plaintext)
80+
.await
81+
.expect("open storage");
82+
let config = LiteConfig {
83+
auto_flush_ms: 0,
84+
..LiteConfig::default()
85+
};
86+
let db = Arc::new(
87+
NodeDbLite::open_with_config(storage, 1, config)
88+
.await
89+
.expect("open db"),
90+
);
91+
// auto_flush_ms=0 → start_auto_flush is a no-op.
92+
db.start_auto_flush(0);
93+
94+
db.kv_put("col", "key", b"unflushed").await.expect("kv_put");
95+
96+
// Drop immediately without flush — no auto-flush task was started.
97+
}
98+
99+
{
100+
let storage = PagedbStorageDefault::open(&path, Encryption::Plaintext)
101+
.await
102+
.expect("reopen storage");
103+
let db = NodeDbLite::open(storage, 1).await.expect("reopen db");
104+
let got = db.kv_get("col", "key").await.expect("kv_get after reopen");
105+
assert!(
106+
got.is_none(),
107+
"key must NOT survive reopen when auto-flush is disabled and flush() was not called; \
108+
got: {got:?}"
109+
);
110+
}
111+
}

nodedb-lite/tests/common/harness.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ impl SyncHarness {
155155
let msg = ArrayDeltaMsg {
156156
array: op.header.array.clone(),
157157
op_payload: payload,
158+
producer_id: 0,
159+
epoch: 0,
160+
seq: 0,
158161
};
159162
self.inbound.handle_delta(&msg).expect("handle_delta")
160163
}

nodedb-lite/tests/common/origin.rs

Lines changed: 53 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33
//! Tests that need a live Origin sync endpoint use [`OriginServer`].
44
//! The guard kills the process on drop.
55
//!
6-
//! The Origin binary is located relative to the nodedb workspace target dir.
7-
//! If the binary is not present the test fails immediately with a clear message.
6+
//! The Origin binary is located in one of three ways (in priority order):
7+
//! 1. `NODEDB_BIN` env var — set by the nextest setup script.
8+
//! 2. `<project-root>/nodedb/target/release/nodedb` (pre-built release).
9+
//! 3. `<project-root>/nodedb/target/debug/nodedb` (pre-built debug).
10+
//!
11+
//! If no binary is found, [`OriginServer::try_spawn`] returns `None` and
12+
//! the calling test should print a skip message and return early.
813
//!
914
//! The sync WebSocket listener always binds to `0.0.0.0:9090` (the
1015
//! `SyncListenerConfig` default). All interop test files are placed in the
@@ -19,47 +24,46 @@ use std::path::{Path, PathBuf};
1924
use std::process::{Child, Command, Stdio};
2025
use std::time::{Duration, Instant};
2126

22-
/// Locate the nodedb Origin binary.
27+
/// Locate the nodedb Origin binary, if available.
28+
///
29+
/// Returns `None` when no binary can be found — interop tests treat that as a
30+
/// skip (see [`OriginServer::try_spawn`]), so a Lite-only checkout still passes.
2331
///
2432
/// Search order:
25-
/// 1. `NODEDB_BIN` env var (CI override).
26-
/// 2. `<project-root>/nodedb/target/release/nodedb`
27-
/// 3. `<project-root>/nodedb/target/debug/nodedb`
33+
/// 1. `NODEDB_BIN` env var — exported by the `build-origin` nextest setup
34+
/// script (`scripts/ensure-origin.sh`), which runs `cargo build -p nodedb`
35+
/// against this workspace's dev-dependency. This is the normal path under
36+
/// `cargo nextest run`.
37+
/// 2. This workspace's own `target/{debug,release}/nodedb` — for a manual
38+
/// `cargo build -p nodedb` outside nextest.
2839
///
29-
/// Project root is inferred by walking up from `CARGO_MANIFEST_DIR`.
30-
pub fn find_origin_binary() -> PathBuf {
31-
if let Ok(path) = env::var("NODEDB_BIN") {
32-
return PathBuf::from(path);
33-
}
34-
35-
// Walk up from CARGO_MANIFEST_DIR (nodedb-lite/nodedb-lite/).
36-
let manifest =
37-
env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set in test environment");
38-
let manifest = Path::new(&manifest);
39-
// Up two levels: nodedb-lite/nodedb-lite → nodedb-lite → project root.
40-
let project_root = manifest
41-
.parent()
42-
.and_then(|p| p.parent())
43-
.expect("could not determine project root from CARGO_MANIFEST_DIR");
44-
45-
let release = project_root.join("nodedb/target/release/nodedb");
46-
if release.exists() {
47-
return release;
40+
/// There is deliberately NO hardcoded sibling-repo path: the Origin crate is
41+
/// resolved through cargo (crates.io or the local `[patch.crates-io]`), so its
42+
/// binary always lands in this workspace's own target directory.
43+
pub fn find_origin_binary() -> Option<PathBuf> {
44+
if let Ok(val) = env::var("NODEDB_BIN") {
45+
let p = PathBuf::from(&val);
46+
if p.is_file() {
47+
return Some(p);
48+
}
4849
}
4950

50-
let debug = project_root.join("nodedb/target/debug/nodedb");
51-
if debug.exists() {
52-
return debug;
51+
// This workspace's own cargo target dir. CARGO_MANIFEST_DIR is
52+
// nodedb-lite/nodedb-lite/; its parent is the workspace root nodedb-lite/.
53+
let manifest = env::var("CARGO_MANIFEST_DIR").ok()?;
54+
let workspace_root = Path::new(&manifest).parent()?.to_path_buf();
55+
let target = env::var("CARGO_TARGET_DIR")
56+
.map(PathBuf::from)
57+
.unwrap_or_else(|_| workspace_root.join("target"));
58+
59+
for profile in ["debug", "release"] {
60+
let candidate = target.join(profile).join("nodedb");
61+
if candidate.is_file() {
62+
return Some(candidate);
63+
}
5364
}
5465

55-
panic!(
56-
"Origin binary not found. Expected one of:\n {}\n {}\n\
57-
Build with: cd {}/nodedb && cargo build -p nodedb\n\
58-
Or set NODEDB_BIN=/path/to/nodedb",
59-
release.display(),
60-
debug.display(),
61-
project_root.display(),
62-
)
66+
None
6367
}
6468

6569
/// The sync WebSocket URL that Origin always listens on.
@@ -71,7 +75,7 @@ pub const ORIGIN_PGWIRE_ADDR: &str = "127.0.0.1:6432";
7175
/// Guard for a running Origin server process.
7276
///
7377
/// Kills the process on drop. Tests obtain an instance via
74-
/// [`OriginServer::spawn`] or [`OriginServer::spawn_with_pgwire`].
78+
/// [`OriginServer::try_spawn`] or [`OriginServer::try_spawn_with_pgwire`].
7579
///
7680
/// Each instance has its own temporary data directory so WAL / storage
7781
/// state from previous runs cannot interfere.
@@ -88,22 +92,28 @@ pub struct OriginServer {
8892
impl OriginServer {
8993
/// Spawn a fresh Origin server with a private temp data directory.
9094
///
95+
/// Returns `None` if the Origin binary cannot be found (Origin repo absent
96+
/// or not built). The caller should print a skip message and return early.
97+
///
9198
/// Blocks (up to 30 s) until the sync WebSocket port is accepting TCP
9299
/// connections.
93-
pub fn spawn() -> Self {
94-
Self::spawn_inner(false)
100+
pub fn try_spawn() -> Option<Self> {
101+
let binary = find_origin_binary()?;
102+
Some(Self::spawn_inner(binary, false))
95103
}
96104

97105
/// Spawn a fresh Origin server with both the sync WebSocket (port 9090)
98106
/// and the pgwire listener (port 6432) enabled in trust auth mode.
99107
///
108+
/// Returns `None` if the Origin binary cannot be found.
109+
///
100110
/// Blocks until **both** ports are accepting TCP connections (up to 30 s).
101-
pub fn spawn_with_pgwire() -> Self {
102-
Self::spawn_inner(true)
111+
pub fn try_spawn_with_pgwire() -> Option<Self> {
112+
let binary = find_origin_binary()?;
113+
Some(Self::spawn_inner(binary, true))
103114
}
104115

105-
fn spawn_inner(with_pgwire: bool) -> Self {
106-
let binary = find_origin_binary();
116+
fn spawn_inner(binary: PathBuf, with_pgwire: bool) -> Self {
107117
let data_dir = tempfile::tempdir().expect("create temp data dir for Origin");
108118

109119
let (mut cmd, config_dir) = if with_pgwire {

0 commit comments

Comments
 (0)