@@ -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.
40154pub 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