Skip to content

Commit 232297e

Browse files
committed
test(backup): split node harness into directory and add watermark tests
`tests/common/cluster_harness/node.rs` (797 lines) is split into a `node/` directory: - `mod.rs` — re-exports only - `lifecycle.rs` — TestClusterNode spawn/shutdown logic - `inspect.rs` — cluster-level helpers (exec_ddl_on_any_leader, etc.) New integration tests in cluster_backup_restore.rs cover the snapshot-watermark guarantee introduced in the backup orchestrator: - `backup_envelope_carries_nonzero_snapshot_watermark` — verifies the orchestrator stamps a real coordinated instant rather than zero. - `backup_watermark_advances_after_writes` — verifies the watermark moves forward monotonically when new writes commit between backups. - Tests for the restore staleness gate (reject older-than-observed envelopes) and the dry_run bypass path.
1 parent ef0996c commit 232297e

5 files changed

Lines changed: 1122 additions & 797 deletions

File tree

nodedb/tests/cluster_backup_restore.rs

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,310 @@ async fn three_node_dry_run_validates_envelope() {
182182

183183
cluster.shutdown().await;
184184
}
185+
186+
// ────────────────────────────────────────────────────────────────────
187+
// Snapshot-watermark coordination across the fan-out.
188+
//
189+
// The backup orchestrator must gather a cluster-wide "as-of" LSN
190+
// before snapshotting each node. Without it, a write that interleaves
191+
// with the fan-out can land on one node's snapshot but not another,
192+
// materialising a state that never existed at any wall-clock instant
193+
// (cross-shard UNIQUE / FK invariants observably break). The spec is:
194+
//
195+
// 1. The envelope's `snapshot_watermark` is non-zero whenever any
196+
// writes have committed (0 means "not captured" — a placeholder).
197+
// 2. Two backups bracketing a write advance the watermark strictly
198+
// monotonically — it reflects a real coordinated commit instant,
199+
// not a hardcoded constant.
200+
// ────────────────────────────────────────────────────────────────────
201+
202+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
203+
async fn backup_envelope_carries_nonzero_snapshot_watermark() {
204+
let cluster = TestCluster::spawn_three().await.expect("cluster");
205+
206+
cluster
207+
.exec_ddl_on_any_leader(
208+
"CREATE COLLECTION cl_docs TYPE DOCUMENT STRICT \
209+
(id TEXT PRIMARY KEY, content TEXT)",
210+
)
211+
.await
212+
.expect("CREATE COLLECTION");
213+
214+
for i in 0..3 {
215+
cluster.nodes[0]
216+
.client
217+
.simple_query(&format!(
218+
"INSERT INTO cl_docs (id, content) VALUES ('wm{i}','v{i}')"
219+
))
220+
.await
221+
.unwrap_or_else(|e| panic!("insert wm{i}: {}", db_detail(&e)));
222+
}
223+
224+
let bytes = drain_backup(0, &cluster, TENANT).await;
225+
let env = parse_envelope(&bytes, DEFAULT_MAX_TOTAL_BYTES).expect("parse envelope");
226+
227+
assert_ne!(
228+
env.meta.snapshot_watermark, 0,
229+
"backup envelope must carry a coordinated snapshot watermark (nonzero LSN), \
230+
got 0 — the orchestrator is skipping the coordination round and the \
231+
envelope represents a smear of per-node instants rather than one logical \
232+
instant, breaking cross-shard invariants on restore"
233+
);
234+
235+
cluster.shutdown().await;
236+
}
237+
238+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
239+
async fn backup_watermark_advances_after_writes() {
240+
let cluster = TestCluster::spawn_three().await.expect("cluster");
241+
242+
cluster
243+
.exec_ddl_on_any_leader(
244+
"CREATE COLLECTION cl_docs TYPE DOCUMENT STRICT \
245+
(id TEXT PRIMARY KEY, content TEXT)",
246+
)
247+
.await
248+
.expect("CREATE COLLECTION");
249+
250+
cluster.nodes[0]
251+
.client
252+
.simple_query("INSERT INTO cl_docs (id, content) VALUES ('a','pre')")
253+
.await
254+
.unwrap_or_else(|e| panic!("insert a: {}", db_detail(&e)));
255+
256+
let bytes_before = drain_backup(0, &cluster, TENANT).await;
257+
let env_before = parse_envelope(&bytes_before, DEFAULT_MAX_TOTAL_BYTES).expect("parse before");
258+
259+
for i in 0..4 {
260+
cluster.nodes[0]
261+
.client
262+
.simple_query(&format!(
263+
"INSERT INTO cl_docs (id, content) VALUES ('b{i}','post')"
264+
))
265+
.await
266+
.unwrap_or_else(|e| panic!("insert b{i}: {}", db_detail(&e)));
267+
}
268+
269+
let bytes_after = drain_backup(0, &cluster, TENANT).await;
270+
let env_after = parse_envelope(&bytes_after, DEFAULT_MAX_TOTAL_BYTES).expect("parse after");
271+
272+
assert!(
273+
env_after.meta.snapshot_watermark > env_before.meta.snapshot_watermark,
274+
"watermark must advance across writes: before={}, after={} — a constant \
275+
watermark means the orchestrator is not capturing a real LSN, so restore \
276+
has no logical instant to recover to",
277+
env_before.meta.snapshot_watermark,
278+
env_after.meta.snapshot_watermark
279+
);
280+
281+
cluster.shutdown().await;
282+
}
283+
284+
// ────────────────────────────────────────────────────────────────────
285+
// Cross-cluster topology drift.
286+
//
287+
// The restore orchestrator re-buckets keys under the DESTINATION
288+
// cluster's current `vshard_for_collection` mapping — not the source
289+
// mapping recorded in the envelope. A regression that re-introduces
290+
// source-topology routing would silently misplace keys on a cluster
291+
// with a different vshard partitioning. Spawn two independent
292+
// clusters and exercise the cross-cluster path.
293+
// ────────────────────────────────────────────────────────────────────
294+
295+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
296+
async fn restore_from_different_topology_preserves_all_keys() {
297+
let cluster_a = TestCluster::spawn_three().await.expect("cluster A");
298+
let cluster_b = TestCluster::spawn_three().await.expect("cluster B");
299+
300+
// Identical schema on both so the restore target has a place to
301+
// write the incoming rows.
302+
for cluster in [&cluster_a, &cluster_b] {
303+
cluster
304+
.exec_ddl_on_any_leader(
305+
"CREATE COLLECTION cl_docs TYPE DOCUMENT STRICT \
306+
(id TEXT PRIMARY KEY, content TEXT)",
307+
)
308+
.await
309+
.expect("CREATE COLLECTION");
310+
}
311+
312+
for i in 0..8 {
313+
cluster_a.nodes[0]
314+
.client
315+
.simple_query(&format!(
316+
"INSERT INTO cl_docs (id, content) VALUES ('x{i}','v{i}')"
317+
))
318+
.await
319+
.unwrap_or_else(|e| panic!("insert x{i}: {}", db_detail(&e)));
320+
}
321+
322+
let bytes = drain_backup(0, &cluster_a, TENANT).await;
323+
324+
// Push cluster A's envelope into cluster B. The envelope records
325+
// A's source_vshard_count; B's orchestrator must re-bucket under
326+
// its own live routing table.
327+
push_restore(0, &cluster_b, TENANT, bytes)
328+
.await
329+
.expect("RESTORE into cluster B");
330+
331+
let post = unique_contents(&cluster_b.nodes[1].client).await;
332+
for i in 0..8u32 {
333+
let needle = format!("\"v{i}\"");
334+
assert!(
335+
post.iter().any(|s| s.contains(&needle)),
336+
"cross-cluster restore lost v{i} (cluster B sees: {post:?}) — \
337+
a regression to source-topology routing would drop keys that \
338+
no longer belong to the same vshard on the destination cluster"
339+
);
340+
}
341+
342+
cluster_a.shutdown().await;
343+
cluster_b.shutdown().await;
344+
}
345+
346+
// ────────────────────────────────────────────────────────────────────
347+
// Mid-flight node failure during restore fan-out.
348+
//
349+
// The restore orchestrator iterates per-node sub-snapshots via
350+
// `sync_dispatch` (local) or `RaftRpc::ExecuteRequest` (remote) and
351+
// surfaces the first per-node error. Two contracts must hold:
352+
//
353+
// 1. Loud failure — the client receives a structured error that
354+
// names the failing node, never silent partial success.
355+
// 2. Idempotent retry — the engine-level PointPut writes are
356+
// idempotent, so a subsequent retry after the failed node is
357+
// restored converges to the expected state.
358+
// ────────────────────────────────────────────────────────────────────
359+
360+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
361+
async fn restore_surfaces_failing_node_id_on_midflight_failure() {
362+
let mut cluster = TestCluster::spawn_three().await.expect("cluster");
363+
364+
cluster
365+
.exec_ddl_on_any_leader(
366+
"CREATE COLLECTION cl_docs TYPE DOCUMENT STRICT \
367+
(id TEXT PRIMARY KEY, content TEXT)",
368+
)
369+
.await
370+
.expect("CREATE COLLECTION");
371+
372+
for i in 0..4 {
373+
cluster.nodes[0]
374+
.client
375+
.simple_query(&format!(
376+
"INSERT INTO cl_docs (id, content) VALUES ('f{i}','v{i}')"
377+
))
378+
.await
379+
.unwrap_or_else(|e| panic!("insert f{i}: {}", db_detail(&e)));
380+
}
381+
382+
let bytes = drain_backup(0, &cluster, TENANT).await;
383+
384+
// Fault-inject: take down node 2, then pin node 0's routing table
385+
// so it still believes node 2 is the leader for every raft group.
386+
// Without the stale-route pin, quorum-replicated failover hides the
387+
// fault entirely — restore transparently re-routes to a surviving
388+
// replica and succeeds. Pinning forces the restore fan-out to
389+
// attempt dispatch against the dead peer so the structured
390+
// error-naming contract is actually exercised.
391+
let downed_node_id = cluster.nodes[2].node_id;
392+
let downed = cluster.nodes.remove(2);
393+
downed.shutdown().await;
394+
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
395+
for group_id in 0..8u64 {
396+
cluster.nodes[0].force_stale_route_for_test(group_id, downed_node_id);
397+
}
398+
399+
let err = push_restore(0, &cluster, TENANT, bytes)
400+
.await
401+
.expect_err("restore must fail loudly when fan-out targets a dead node");
402+
403+
assert!(
404+
err.contains(&format!("node {downed_node_id}"))
405+
|| err.contains(&downed_node_id.to_string()),
406+
"restore error must name the failing node id {downed_node_id} so the \
407+
operator can act; got: {err}"
408+
);
409+
410+
cluster.shutdown().await;
411+
}
412+
413+
// NOTE: a proper same-cluster retry test (fault a node, it recovers,
414+
// retry succeeds on the same cluster) requires harness support for
415+
// in-place node restart — shutting down a node and bringing it back
416+
// with the same node id, same data dir, and a fresh transport that
417+
// the raft group accepts as a rejoin rather than a new member. The
418+
// current `TestClusterNode::spawn` always creates a fresh data dir
419+
// and a fresh transport keypair, so a "replacement" node fails the
420+
// rejoin handshake. Filed as a separate harness initiative.
421+
//
422+
// Idempotent replay on a healthy cluster is already covered by
423+
// `three_node_roundtrip_preserves_data`, which restores into a
424+
// cluster that already holds the same rows and expects convergence.
425+
426+
// ────────────────────────────────────────────────────────────────────
427+
// Staleness gate on restore.
428+
//
429+
// Once the backup orchestrator captures a real watermark (#69), a
430+
// restore into a cluster whose current applied state is *newer* than
431+
// the envelope's watermark would roll back committed writes. The
432+
// operator should be told. Spec: by default, restoring a
433+
// stale-watermark envelope is rejected with a structured error. An
434+
// explicit opt-in (`FORCE` keyword) is the separate design decision —
435+
// not pinned here until the DDL surface lands.
436+
//
437+
// This test will remain red until #69's coordination round produces
438+
// nonzero watermarks AND the restore path compares them.
439+
// ────────────────────────────────────────────────────────────────────
440+
441+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
442+
async fn restore_refuses_stale_watermark() {
443+
let cluster = TestCluster::spawn_three().await.expect("cluster");
444+
cluster
445+
.exec_ddl_on_any_leader(
446+
"CREATE COLLECTION cl_docs TYPE DOCUMENT STRICT \
447+
(id TEXT PRIMARY KEY, content TEXT)",
448+
)
449+
.await
450+
.expect("CREATE COLLECTION");
451+
452+
// Write, back up — envelope's watermark pins this logical instant.
453+
for i in 0..3 {
454+
cluster.nodes[0]
455+
.client
456+
.simple_query(&format!(
457+
"INSERT INTO cl_docs (id, content) VALUES ('s{i}','early')"
458+
))
459+
.await
460+
.unwrap_or_else(|e| panic!("insert s{i}: {}", db_detail(&e)));
461+
}
462+
let stale_bytes = drain_backup(0, &cluster, TENANT).await;
463+
464+
// Advance the cluster past the envelope's watermark.
465+
for i in 0..3 {
466+
cluster.nodes[0]
467+
.client
468+
.simple_query(&format!(
469+
"INSERT INTO cl_docs (id, content) VALUES ('s{i}_late','late{i}')"
470+
))
471+
.await
472+
.unwrap_or_else(|e| panic!("insert s{i}_late: {}", db_detail(&e)));
473+
}
474+
475+
// Restoring the older envelope over newer state must be rejected.
476+
let err = push_restore(0, &cluster, TENANT, stale_bytes)
477+
.await
478+
.expect_err(
479+
"restore of stale-watermark envelope into newer cluster state must be \
480+
rejected by default — silently overwriting newer committed writes is \
481+
a correctness bug",
482+
);
483+
let lower = err.to_lowercase();
484+
assert!(
485+
lower.contains("stale") || lower.contains("watermark") || lower.contains("older"),
486+
"rejection error must name the staleness cause so operators know to \
487+
pass FORCE if they really mean it; got: {err}"
488+
);
489+
490+
cluster.shutdown().await;
491+
}

0 commit comments

Comments
 (0)