Skip to content

Commit 385dca8

Browse files
committed
fix(worker): make node-status write fence scope-aware for nested loops
The write fence compared only the root generation (2nd ::-token of the stamp), so a stale late write from an older child-loop generation could overwrite a newer generation's status/result/status_details. Replace the single-scope compare with stamp_lineage + a level-by-level lineage compare in incoming_stamp_is_superseded, mirroring node_status read-time inference but over two stamps directly (the activity sees only one existing row). Handles nested loops and never fences independent sibling branch scopes.
1 parent a497e14 commit 385dca8

1 file changed

Lines changed: 253 additions & 63 deletions

File tree

src/activities/update_node_status.rs

Lines changed: 253 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,120 @@ async fn status_details_present(pool: &PgPool) -> bool {
3636
present
3737
}
3838

39+
fn execution_id_from_details(status_details: Option<&serde_json::Value>) -> Option<&str> {
40+
status_details?.get("execution_id")?.as_str()
41+
}
42+
43+
/// Decompose a node stamp into its generation lineage and the branch node ids
44+
/// spawned at each level.
45+
///
46+
/// A stamp is `{root_instance}::{g0}::{node1}::{g1}::...::{nodeN}::{gN}`: the
47+
/// root df instance id, then the root orchestration generation `g0`, then for
48+
/// every spawned sub-orchestration the child root node id and that child's own
49+
/// `continue_as_new` generation. `subtree_instance_id` composes exactly this
50+
/// shape, so the returned `gens` reads root→innermost (`g0..gN`) and `nodes`
51+
/// holds the branch node id taken to descend from each level to the next.
52+
///
53+
/// Returns `None` for a malformed stamp (no generation, or an odd trailing
54+
/// token count that is not `gen (node gen)*`), so a legacy/unparseable stamp
55+
/// degrades to an unfenced write rather than silently dropping it.
56+
fn stamp_lineage(execution_id: &str) -> Option<(Vec<i64>, Vec<&str>)> {
57+
let tokens: Vec<&str> = execution_id.split("::").collect();
58+
// token[0] is the root df instance id; the remainder must be
59+
// `gen (node gen)*`, i.e. an even total token count (>= 2).
60+
if tokens.len() < 2 || !tokens.len().is_multiple_of(2) {
61+
return None;
62+
}
63+
let mut gens = Vec::new();
64+
let mut nodes = Vec::new();
65+
let mut i = 1;
66+
while i < tokens.len() {
67+
gens.push(tokens[i].parse::<i64>().ok()?);
68+
i += 1;
69+
if i < tokens.len() {
70+
nodes.push(tokens[i]);
71+
i += 1;
72+
}
73+
}
74+
Some((gens, nodes))
75+
}
76+
77+
/// Whether the `incoming` write belongs to a superseded generation relative to
78+
/// the `existing` stamp already on the row.
79+
///
80+
/// Both stamps transition the SAME node, so they share a node lineage but may
81+
/// carry different generations at each nesting level. Walk both lineages from
82+
/// the root: the first level whose incoming generation is OLDER means the write
83+
/// comes from a superseded ancestor (or its own older) generation and must be
84+
/// fenced. If the generations tie at a level, the branch node id spawned next
85+
/// decides — a different id is an independent sibling scope (one loop iteration's
86+
/// parallel branch must never fence another's), an equal id descends, and a
87+
/// lineage that ends is the same-or-deeper scope in the same generation and is
88+
/// accepted. A newer incoming generation at any level is accepted. This is the
89+
/// write-side mirror of the read-time inference in `node_status::is_superseded`,
90+
/// but compares two stamps directly because the activity sees only one row.
91+
fn incoming_stamp_is_superseded(incoming: &str, existing: Option<&str>) -> bool {
92+
let Some(existing) = existing else {
93+
return false;
94+
};
95+
let (Some((inc_gens, inc_nodes)), Some((ex_gens, ex_nodes))) =
96+
(stamp_lineage(incoming), stamp_lineage(existing))
97+
else {
98+
return false;
99+
};
100+
101+
let depth = inc_gens.len().min(ex_gens.len());
102+
for level in 0..depth {
103+
if inc_gens[level] < ex_gens[level] {
104+
return true;
105+
}
106+
if inc_gens[level] > ex_gens[level] {
107+
return false;
108+
}
109+
// Generations tie at this level: an independent sibling scope (a
110+
// different branch node id spawned next) is never fenced.
111+
if let (Some(a), Some(b)) = (inc_nodes.get(level), ex_nodes.get(level)) {
112+
if a != b {
113+
return false;
114+
}
115+
}
116+
}
117+
false
118+
}
119+
fn push_status_update<'a>(
120+
update: &mut QueryBuilder<'a, Postgres>,
121+
status: &'a str,
122+
result: Option<&'a str>,
123+
execution_id: Option<&'a str>,
124+
write_details: bool,
125+
) {
126+
update
127+
.push("UPDATE df.nodes SET status = ")
128+
.push_bind(status);
129+
130+
if let Some(res) = result {
131+
let json_result = serde_json::from_str::<serde_json::Value>(res)
132+
.unwrap_or_else(|_| serde_json::Value::String(res.to_string()));
133+
update
134+
.push(", result = ")
135+
.push_bind(json_result)
136+
.push("::jsonb");
137+
} else if status == "running" {
138+
// When marking as running, clear any stale result from a previous loop
139+
// iteration to satisfy nodes_result_status_chk
140+
// (result IS NULL OR status IN ('completed', 'failed')).
141+
update.push(", result = NULL");
142+
}
143+
144+
if write_details {
145+
let details = serde_json::json!({ "execution_id": execution_id });
146+
update
147+
.push(", status_details = ")
148+
.push_bind(details)
149+
.push("::jsonb");
150+
}
151+
}
152+
39153
/// Update the status and optionally the result of a node in df.nodes.
40154
pub async fn execute(
41155
ctx: ActivityContext,
@@ -76,86 +190,81 @@ pub async fn execute(
76190
// pre-0.2.4 schema lacking it -- degrade to the plain status/result write).
77191
let write_details = execution_id.is_some() && status_details_present(pool.as_ref()).await;
78192

79-
// Fence value: the incoming root generation (second "::"-token of the stamp).
80-
// When the stamp can't be parsed we pass i64::MAX so the fence always
81-
// accepts (i.e. behaves as no fence) rather than silently dropping writes.
82-
let incoming_gen: i64 = execution_id
83-
.and_then(|s| s.split("::").nth(1))
84-
.and_then(|tok| tok.parse::<i64>().ok())
85-
.unwrap_or(i64::MAX);
193+
let mut update = QueryBuilder::<Postgres>::new("");
194+
push_status_update(&mut update, status, result, execution_id, write_details);
86195

87-
let mut update = QueryBuilder::<Postgres>::new("UPDATE df.nodes SET status = ");
88-
update.push_bind(status);
196+
// Monotonic write fence: when status_details exists, first lock the row and compare the
197+
// incoming stamp against the existing stamp using the same scope-lineage semantics as
198+
// read-time status inference. A stale write is one whose own scope generation is older,
199+
// or whose parent scope was spawned by an older ancestor generation. Equal-or-newer
200+
// generations (including running -> terminal within the same generation) are accepted.
201+
if write_details {
202+
let mut tx = pool
203+
.begin()
204+
.await
205+
.map_err(|e| format!("Failed to begin node status update transaction: {e}"))?;
89206

90-
if let Some(res) = result {
91-
let json_result = serde_json::from_str::<serde_json::Value>(res)
92-
.unwrap_or_else(|_| serde_json::Value::String(res.to_string()));
93-
update
94-
.push(", result = ")
95-
.push_bind(json_result)
96-
.push("::jsonb");
97-
} else if status == "running" {
98-
// When marking as running, clear any stale result from a previous loop
99-
// iteration to satisfy nodes_result_status_chk
100-
// (result IS NULL OR status IN ('completed', 'failed')).
101-
update.push(", result = NULL");
102-
}
207+
let existing_details = sqlx::query_scalar::<_, Option<serde_json::Value>>(
208+
"SELECT status_details FROM df.nodes WHERE id = $1 AND instance_id = $2 FOR UPDATE",
209+
)
210+
.bind(node_id)
211+
.bind(instance_id)
212+
.fetch_optional(&mut *tx)
213+
.await
214+
.map_err(|e| format!("Failed to lock node status row: {e}"))?;
215+
216+
let Some(existing_details) = existing_details else {
217+
let err_msg = format!(
218+
"update_node_status affected 0 rows: node {node_id} \
219+
not found in instance {instance_id}"
220+
);
221+
ctx.trace_info(&err_msg);
222+
return Err(err_msg);
223+
};
224+
225+
let existing_execution_id = execution_id_from_details(existing_details.as_ref());
226+
if incoming_stamp_is_superseded(execution_id.unwrap_or_default(), existing_execution_id) {
227+
return Ok("Node status write fenced (superseded by newer generation)".to_string());
228+
}
103229

104-
if write_details {
105-
let details = serde_json::json!({ "execution_id": execution_id });
106230
update
107-
.push(", status_details = ")
108-
.push_bind(details)
109-
.push("::jsonb");
231+
.push(", updated_at = now() WHERE id = ")
232+
.push_bind(node_id)
233+
.push(" AND instance_id = ")
234+
.push_bind(instance_id);
235+
236+
let done = update
237+
.build()
238+
.execute(&mut *tx)
239+
.await
240+
.map_err(|e| format!("Failed to update node status: {e}"))?;
241+
let rows = done.rows_affected();
242+
if rows != 1 {
243+
let err_msg = format!(
244+
"update_node_status affected {rows} row(s) for node {node_id} \
245+
in instance {instance_id} (expected exactly 1)"
246+
);
247+
ctx.trace_info(&err_msg);
248+
return Err(err_msg);
249+
}
250+
251+
tx.commit()
252+
.await
253+
.map_err(|e| format!("Failed to commit node status update transaction: {e}"))?;
254+
return Ok("Node status updated".to_string());
110255
}
111256

112-
// Monotonic write fence: reject a write carrying an OLDER root generation than
113-
// the one already stamped on the row, so a stale loser/iteration drain can't
114-
// clobber a newer generation's status. Equal-or-newer generations (including
115-
// running -> terminal within the same generation) are accepted.
116257
update
117258
.push(", updated_at = now() WHERE id = ")
118259
.push_bind(node_id)
119260
.push(" AND instance_id = ")
120261
.push_bind(instance_id);
121-
if write_details {
122-
update
123-
.push(
124-
" AND (status_details IS NULL OR \
125-
COALESCE(NULLIF(split_part(status_details->>'execution_id', '::', 2), '')::bigint, 0) <= ",
126-
)
127-
.push_bind(incoming_gen)
128-
.push(")");
129-
}
130262

131263
match update.build().execute(pool.as_ref()).await {
132264
Ok(done) => {
133265
let rows = done.rows_affected();
134266
if rows == 1 {
135267
Ok("Node status updated".to_string())
136-
} else if write_details {
137-
// Zero rows under an active fence is ambiguous: the row may exist
138-
// but carry a NEWER generation (a legitimate fenced-out stale
139-
// write), or the node may genuinely be missing. Distinguish the
140-
// two so a fence rejection is not surfaced as a correctness error.
141-
let exists = sqlx::query_scalar::<_, bool>(
142-
"SELECT EXISTS (SELECT 1 FROM df.nodes WHERE id = $1 AND instance_id = $2)",
143-
)
144-
.bind(node_id)
145-
.bind(instance_id)
146-
.fetch_one(pool.as_ref())
147-
.await
148-
.unwrap_or(false);
149-
if exists {
150-
Ok("Node status write fenced (superseded by newer generation)".to_string())
151-
} else {
152-
let err_msg = format!(
153-
"update_node_status affected 0 rows: node {node_id} \
154-
not found in instance {instance_id}"
155-
);
156-
ctx.trace_info(&err_msg);
157-
Err(err_msg)
158-
}
159268
} else {
160269
// No fence in play: exactly one row must match (instance_id, id).
161270
// Anything else (typically zero rows: a missing node or a
@@ -175,3 +284,84 @@ pub async fn execute(
175284
}
176285
}
177286
}
287+
288+
#[cfg(test)]
289+
mod tests {
290+
use super::*;
291+
292+
#[test]
293+
fn accepts_terminal_write_in_same_generation() {
294+
assert!(!incoming_stamp_is_superseded(
295+
"root::1::loop::2",
296+
Some("root::1::loop::2")
297+
));
298+
}
299+
300+
#[test]
301+
fn rejects_older_child_loop_generation() {
302+
assert!(incoming_stamp_is_superseded(
303+
"root::1::loop::1",
304+
Some("root::1::loop::2")
305+
));
306+
}
307+
308+
#[test]
309+
fn rejects_child_spawned_by_older_root_generation() {
310+
assert!(incoming_stamp_is_superseded(
311+
"root::1::loop::5",
312+
Some("root::2")
313+
));
314+
}
315+
316+
#[test]
317+
fn accepts_sibling_scope_in_same_parent_generation() {
318+
assert!(!incoming_stamp_is_superseded(
319+
"root::1::left::1",
320+
Some("root::1::right::2")
321+
));
322+
}
323+
324+
#[test]
325+
fn accepts_unparseable_stamp_as_unfenced_for_backward_compatibility() {
326+
assert!(!incoming_stamp_is_superseded(
327+
"legacy",
328+
Some("root::1::loop::2")
329+
));
330+
}
331+
332+
#[test]
333+
fn rejects_inner_loop_write_from_older_outer_generation() {
334+
// Nested loops: an inner (non-root) loop whose outer loop advanced from
335+
// generation 1 -> 2 is re-spawned under scope root::2::inner. A stale late
336+
// write from the previous outer generation (root::1::inner::5) must be fenced
337+
// out even though its own child generation (5) is numerically higher than the
338+
// current row's child generation (1) -- the deciding factor is that its parent
339+
// scope was spawned by the older outer generation.
340+
assert!(incoming_stamp_is_superseded(
341+
"root::1::inner::5",
342+
Some("root::2::inner::1")
343+
));
344+
}
345+
346+
#[test]
347+
fn accepts_newer_inner_loop_write_from_same_outer_generation() {
348+
// Same nested shape, but the outer generation matches and the inner
349+
// generation advanced: this is the current in-flight write and must be
350+
// accepted, not fenced.
351+
assert!(!incoming_stamp_is_superseded(
352+
"root::2::inner::5",
353+
Some("root::2::inner::1")
354+
));
355+
}
356+
357+
#[test]
358+
fn accepts_sibling_branch_nested_under_same_generations() {
359+
// Two parallel branches (left/right) spawned inside the same inner-loop
360+
// iteration are independent scopes: neither may fence the other even
361+
// though their trailing generations differ.
362+
assert!(!incoming_stamp_is_superseded(
363+
"root::1::inner::2::left::1",
364+
Some("root::1::inner::2::right::9")
365+
));
366+
}
367+
}

0 commit comments

Comments
 (0)