audit: fix concurrent subgraph loss, fail-closed ownership, drop barrier compat
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user