@@ -76,6 +76,46 @@ async fn labeled_match_rows(
7676 }
7777}
7878
79+ /// Poll a label-filtered MATCH until `expected` appears, or `timeout` elapses.
80+ ///
81+ /// This is the confound-free survivor read after a leader kill. A survivor can
82+ /// answer the MATCH *successfully* while its data group is still catching up
83+ /// post-failover — returning an empty (stale) result before the replicated
84+ /// label/edge state has been applied. `labeled_match_rows` returns on the first
85+ /// `Ok`, so reading it once races that catch-up window and can observe empty
86+ /// even though the write replicated durably. Polling until `expected` is
87+ /// present (retrying BOTH transient errors and empty results) closes that race:
88+ /// only a genuine failure to replicate — the label truly absent for the entire
89+ /// window — survives to the caller as a still-missing row. Returns the last
90+ /// observed rows so a real loss produces an informative assertion.
91+ async fn wait_for_labeled_match (
92+ client : & tokio_postgres:: Client ,
93+ sql : & str ,
94+ expected : & ( String , String ) ,
95+ timeout : Duration ,
96+ ) -> Vec < ( String , String ) > {
97+ let deadline = Instant :: now ( ) + timeout;
98+ let mut last: Vec < ( String , String ) > = Vec :: new ( ) ;
99+ loop {
100+ // Each probe gets a short read budget for transient post-failover
101+ // errors; the outer loop owns the catch-up budget for empty results.
102+ match labeled_match_rows ( client, sql, Duration :: from_millis ( 500 ) ) . await {
103+ Ok ( rows) => {
104+ if rows. contains ( expected) {
105+ return rows;
106+ }
107+ last = rows;
108+ }
109+ Err ( _) if Instant :: now ( ) < deadline => { }
110+ Err ( _) => return last,
111+ }
112+ if Instant :: now ( ) >= deadline {
113+ return last;
114+ }
115+ tokio:: time:: sleep ( Duration :: from_millis ( 150 ) ) . await ;
116+ }
117+ }
118+
79119#[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
80120async fn node_labels_replicate_and_survive_leader_loss ( ) {
81121 let cluster = TestCluster :: spawn_three ( )
@@ -113,18 +153,31 @@ async fn node_labels_replicate_and_survive_leader_loss() {
113153 const MATCH_LABELED : & str = "MATCH (a:Person)-[:knows]->(b) RETURN a, b" ;
114154 let expected = ( "alice" . to_string ( ) , "bob" . to_string ( ) ) ;
115155
116- // Sanity: the labeled path is readable before the failover.
117- let landed = labeled_match_rows (
118- & cluster. nodes [ 0 ] . client ,
119- MATCH_LABELED ,
120- Duration :: from_secs ( 10 ) ,
121- )
122- . await
123- . expect ( "labeled MATCH on node 0" ) ;
124- assert ! (
125- landed. contains( & expected) ,
126- "labeled MATCH produced no path before failover; got {landed:?}"
127- ) ;
156+ // Hard precondition: the labeled path must be readable on EVERY node before
157+ // the failover. `wait_for_full_apply_convergence` falls through silently on
158+ // timeout, so it does not by itself guarantee the "all three replicas have
159+ // applied the label" state this test depends on. Proving the label is
160+ // present on all three replicas here is what makes the post-kill assertion
161+ // meaningful: with every replica holding it before the kill, Raft election
162+ // safety guarantees a survivor keeps it, so a still-empty survivor read
163+ // afterward is genuine loss — not a lagging replica that never converged
164+ // pre-kill racing the post-failover catch-up window.
165+ for node in & cluster. nodes {
166+ let rows = wait_for_labeled_match (
167+ & node. client ,
168+ MATCH_LABELED ,
169+ & expected,
170+ Duration :: from_secs ( 15 ) ,
171+ )
172+ . await ;
173+ assert ! (
174+ rows. contains( & expected) ,
175+ "labeled path did not replicate to node {} before failover — the label \
176+ never converged to every replica, so the leader-loss assertion below would \
177+ be testing a non-converged cluster; got {rows:?}",
178+ node. node_id,
179+ ) ;
180+ }
128181
129182 // Resolve the data group owning `alice`'s home vShard (node labels are
130183 // single-keyed on the node id, homed at `from_key(node_id)`) and its leader.
@@ -160,25 +213,24 @@ async fn node_labels_replicate_and_survive_leader_loss() {
160213 . expect ( "leader node present" ) ;
161214 nodes. remove ( leader_idx) . shutdown ( ) . await ;
162215
163- // Survivors re-elect a new leader; give the group a moment to settle.
164- tokio:: time:: sleep ( Duration :: from_secs ( 3 ) ) . await ;
165-
216+ // Survivors re-elect a new leader and apply the replicated label; the poll
217+ // below owns the settle + catch-up window, so no fixed pre-sleep is needed.
166218 for node in & nodes {
167- let rows = labeled_match_rows ( & node. client , MATCH_LABELED , Duration :: from_secs ( 20 ) )
168- . await
169- . unwrap_or_else ( |e| {
170- panic ! (
171- "survivor node {} could not run labeled MATCH after leader death: {e}" ,
172- node. node_id
173- )
174- } ) ;
219+ let rows = wait_for_labeled_match (
220+ & node. client ,
221+ MATCH_LABELED ,
222+ & expected,
223+ Duration :: from_secs ( 20 ) ,
224+ )
225+ . await ;
175226 assert ! (
176227 rows. contains( & expected) ,
177- "BUG: survivor node {} found no 'Person' label after the data-group leader \
178- was killed — SetNodeLabels was dispatched LOCAL-ONLY and never proposed \
179- through Raft, so the label was lost on failover (silent write-loss under RF>1); \
180- got {rows:?}",
181- node. node_id
228+ "survivor node {} still had no 'Person' label {:?} after the data-group leader \
229+ was killed and a full post-failover catch-up window elapsed — the labeled path \
230+ never reached this survivor, so the SetNodeLabels write did not replicate durably \
231+ via Raft (silent write-loss under RF>1); got {rows:?}",
232+ node. node_id,
233+ expected,
182234 ) ;
183235 }
184236
0 commit comments