Skip to content

Commit 568c558

Browse files
committed
test(cluster): poll for convergence in node-label replication test
Replace single-shot labeled MATCH reads with a poll that retries both transient errors and stale-empty results. A survivor can answer the query successfully right after a leader kill while its data group is still catching up, so one-shot reads raced that window and could report loss that was actually just lag. Also require every replica (not just node 0) to observe the label before the failover, which is what makes the post-kill survivor assertion meaningful.
1 parent fe0e16c commit 568c558

1 file changed

Lines changed: 80 additions & 28 deletions

File tree

nodedb-cluster-tests/tests/node_labels_replicate_to_followers.rs

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
80120
async 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

Comments
 (0)