@@ -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