audit: fix concurrent subgraph loss, fail-closed ownership, drop barrier compat

This commit is contained in:
lda
2026-09-04 10:58:13 +07:00 Verified
parent 79ce0d3eff
commit f155e6651a
11 changed files with 424 additions and 478 deletions
+8 -1
View File
@@ -80,7 +80,10 @@ def advance_frame(
# Foreach back-edge return is an ownership check, not generic cycle
# detection. Only the frame's immediate recorded owner completes the item;
# a root frame targeting the same foreach enters it normally.
from wf_core.runtime.foreach_state import item_frame_owner
from wf_core.runtime.foreach_state import (
item_frame_owner,
register_foreach_item_success,
)
owner = item_frame_owner(frame)
if owner is not None:
@@ -91,6 +94,10 @@ def advance_frame(
)
if next_node_id == owner.foreach_node_id:
source_node_id = frame.node_id
# Register the completed item with its barrier before completing
# the child, so every final operation (node, subgraph, nested
# control) counts. Closed or superseded activations fail closed.
register_foreach_item_success(run, frame, owner)
frame.prior_outcome = outcome
frame.activated_incoming_edge = source_node_id
frame.node_id = owner.foreach_node_id
+81 -72
View File
@@ -96,40 +96,16 @@ def _step_foreach_serial(
loop_start = index.next_node_id(frame.node_id, "loop")
item = iterable[loop_index]
barrier.next_index = loop_index + 1
loop_start, child_id = _admit_item_frame(
run=run,
frame=frame,
step=step,
index=index,
activation=activation,
loop_index=loop_index,
item=item,
)
save_foreach_activation(frame, activation)
child_id = _child_frame_id(activation, loop_index)
child_lineage_id = _child_lineage_id(activation, loop_index)
# Serial items still own a lineage so nested subgraph/boundary commits have
# a parent lineage to buffer into; top-level serial writes commit through
# the parent scope root.
add_lineage(
run,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_id=frame.lineage_id,
)
add_frame(
run,
ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_lineage_id=frame.lineage_id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
activation_id=activation.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
)
block_frame_on_children(run, frame.id, (child_id,))
append_step_result_trace(
run,
@@ -251,6 +227,58 @@ def _item_error_record(child: ExecutionFrame) -> ItemErrorRecord:
)
def _admit_item_frame(
*,
run: RunState,
frame: ExecutionFrame,
step: ForeachNode,
index: WorkflowIndex,
activation: ForeachActivationState,
loop_index: int,
item: object,
) -> tuple[str, str]:
"""Create one activation-qualified child frame and lineage.
Every item owns a lineage so nested subgraph/boundary commits have a
parent lineage to buffer into; top-level serial writes still commit
through the parent scope root. Returns the loop start node and child id;
barrier child bookkeeping stays with the caller. Compare ids by name;
never parse them.
"""
loop_start = index.next_node_id(frame.node_id, "loop")
child_id = _child_frame_id(activation, loop_index)
child_lineage_id = _child_lineage_id(activation, loop_index)
add_lineage(
run,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_id=frame.lineage_id,
)
activation.barrier.next_index = loop_index + 1
add_frame(
run,
ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_lineage_id=frame.lineage_id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
activation_id=activation.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
)
return loop_start, child_id
def _admit_concurrent_children(
*,
run: RunState,
@@ -272,38 +300,17 @@ def _admit_concurrent_children(
):
loop_index = barrier.next_index
item = iterable[loop_index]
child_id = _child_frame_id(activation, loop_index)
child_lineage_id = _child_lineage_id(activation, loop_index)
add_lineage(
run,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_id=frame.lineage_id,
)
active_count = len(barrier.active_frame_ids)
barrier.next_index = loop_index + 1
barrier.start_child(child_id)
add_frame(
run,
ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_lineage_id=frame.lineage_id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
activation_id=activation.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
loop_start, child_id = _admit_item_frame(
run=run,
frame=frame,
step=step,
index=index,
activation=activation,
loop_index=loop_index,
item=item,
)
barrier.start_child(child_id)
append_step_result_trace(
run,
frame_id=frame.id,
@@ -417,14 +424,16 @@ def _patch_for_successful_item(
) -> StatePatch:
"""Return the replayable patch for a completed foreach item.
New concurrent foreach results store writes in `RunState.lineages` and keep
only lineage metadata in the barrier. Old serialized barrier metadata may
still carry `result.patch`, so keep that as the compatibility fallback.
Item writes live in `RunState.lineages`; a success without a known
lineage is corrupt state and fails closed.
"""
if result.lineage_id is not None and result.lineage_id in run.lineages:
return lineage_patch(
run,
scope_id=frame.scope_id,
lineage_id=result.lineage_id,
if result.lineage_id is None or result.lineage_id not in run.lineages:
raise WorkflowExecutionError(
f"foreach item result for index {result.index!r} references "
f"unknown lineage {result.lineage_id!r}"
)
return result.patch
return lineage_patch(
run,
scope_id=frame.scope_id,
lineage_id=result.lineage_id,
)
+10 -14
View File
@@ -18,7 +18,6 @@ from wf_core.run_state import (
from wf_core.runtime.foreach_state import (
item_frame_owner,
require_foreach_activation,
save_foreach_activation,
)
from wf_core.runtime.input_bindings import resolve_step_input_bindings
from wf_core.runtime.lineage import (
@@ -30,7 +29,7 @@ from wf_core.runtime.ops.frames import frame_context_values
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_core.runtime.ops.overlays import state_view_for_frame
from wf_core.runtime.ops.schemas import validate_payload_against_schema
from wf_core.runtime.ops.state import StatePatch, build_output_patch
from wf_core.runtime.ops.state import build_output_patch
NodeHandler = Callable[[dict[str, Any], RuntimeContext], NodeResult | dict[str, Any]]
AsyncNodeHandler = Callable[
@@ -120,29 +119,26 @@ def _finalize_node_execution(
if owner is None:
state_changes = commit_patch_for_frame(run, frame, patch)
else:
parent_frame = run.frames[owner.parent_frame_id]
parent_frame = run.frames.get(owner.parent_frame_id)
if parent_frame is None:
raise WorkflowExecutionError(
"foreach item state references missing parent frame "
f"{owner.parent_frame_id!r} for child frame {frame.id!r}"
)
# Fail closed when the child names a closed or superseded activation:
# its writes must not land in a later visit's barrier.
activation = require_foreach_activation(
parent_frame, owner.foreach_node_id, owner.activation_id
)
barrier = activation.barrier
if barrier.mode == "concurrent":
# New concurrent foreach stores writes in the child lineage; the
# barrier keeps only result metadata plus old patch fallback.
if activation.barrier.mode == "concurrent":
# Concurrent writes stay buffered in the child lineage; the owner
# back-edge registers the completed item with the barrier.
append_lineage_writes(
run,
scope_id=frame.scope_id,
lineage_id=frame.lineage_id,
writes=patch.writes,
)
barrier.add_success_patch(
index=owner.item_index,
frame_id=frame.id,
patch=StatePatch(),
lineage_id=frame.lineage_id,
)
save_foreach_activation(parent_frame, activation)
state_changes = {}
else:
state_changes = commit_patch_for_frame(run, parent_frame, patch)