concurrent scheduler foundation

This commit is contained in:
lda
2026-05-22 00:57:04 +07:00 Verified
parent fbeb762ce9
commit 617eb90ed9
11 changed files with 1178 additions and 40 deletions
+13 -7
View File
@@ -5,10 +5,10 @@ from typing import Any
from wf_core.models.workflow import Workflow
from wf_core.runtime.ops.flow import finalize_run
from wf_core.runtime.ops.frames import collapse_completed_frames
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_core.runtime.ops.nodes import AsyncNodeHandler, NodeHandler
from wf_core.runtime.ops.runs import create_run_state
from wf_core.runtime.scheduler import resolve_no_ready_frames, select_next_frame
from wf_core.run_state import RunState, RunStatus
from wf_core.tokens import END
@@ -77,9 +77,12 @@ def resume_workflow(
return run
while True:
collapse_completed_frames(run)
if run.current_node_id == END:
break
frame = select_next_frame(run)
if frame is None:
status = resolve_no_ready_frames(run)
if status == RunStatus.COMPLETED:
break
return run
step_workflow(
workflow,
run,
@@ -116,9 +119,12 @@ async def resume_workflow_async(
return run
while True:
collapse_completed_frames(run)
if run.current_node_id == END:
break
frame = select_next_frame(run)
if frame is None:
status = resolve_no_ready_frames(run)
if status == RunStatus.COMPLETED:
break
return run
await step_workflow_async(
workflow,
run,
+6
View File
@@ -13,6 +13,10 @@ from wf_core.run_state import (
)
from wf_core.runtime.ops.schemas import validate_payload_against_schema
from wf_core.runtime.ops.state import project_output
from wf_core.runtime.scheduler import (
mark_frame_pending,
wake_parent_if_children_complete,
)
from wf_core.tokens import END
@@ -77,8 +81,10 @@ def advance_frame(
if next_node_id == END:
frame.status = FrameStatus.COMPLETED
frame.finished_at_node_id = END
wake_parent_if_children_complete(run, frame.id)
else:
frame.finished_at_node_id = None
mark_frame_pending(run, frame.id)
run.sync_from_current_frame()
+23 -14
View File
@@ -8,6 +8,11 @@ from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecuti
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
from wf_core.runtime.ops.frames import frame_context_values
from wf_core.runtime.ops.index import WorkflowIndex
from wf_core.runtime.scheduler import (
ForeachIterationMetadata,
add_frame,
block_frame_on_children,
)
def step_foreach(
@@ -61,20 +66,25 @@ def step_foreach(
item = iterable[loop_index]
progress["index"] = loop_index + 1
child_id = f"{frame.id}:{step.id}:{loop_index}"
child_metadata = {
"foreach_node_id": step.id,
"loop_index": loop_index,
"loop_item": item,
"loop_alias": step.as_,
}
run.frames[child_id] = ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
metadata=child_metadata,
child_metadata = ForeachIterationMetadata(
foreach_node_id=step.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
)
add_frame(
run,
ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
metadata=child_metadata.to_metadata(),
),
ready=True,
)
block_frame_on_children(run, frame.id, (child_id,))
append_step_result_trace(
run,
frame_id=frame.id,
@@ -88,6 +98,5 @@ def step_foreach(
state_changes={},
),
)
run.current_frame_id = child_id
run.sync_from_current_frame()
return run
+11 -8
View File
@@ -5,6 +5,7 @@ from copy import deepcopy
from wf_core.models.workflow import Workflow
from wf_core.paths import set_nested_value
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
from wf_core.runtime.scheduler import add_frame
def create_run_state(workflow: Workflow, workflow_input: dict[str, object]) -> RunState:
@@ -18,16 +19,18 @@ def create_run_state(workflow: Workflow, workflow_input: dict[str, object]) -> R
status=RunStatus.PENDING,
workflow_input=dict(workflow_input),
state=state,
frames={
"root": ExecutionFrame(
id="root",
kind="workflow",
node_id=workflow.start,
status=FrameStatus.PENDING,
)
},
current_frame_id="root",
current_node_id=workflow.start,
)
add_frame(
run,
ExecutionFrame(
id="root",
kind="workflow",
node_id=workflow.start,
status=FrameStatus.PENDING,
),
ready=True,
)
run.sync_from_current_frame()
return run
+5 -7
View File
@@ -5,11 +5,11 @@ from typing import Any
from wf_core.errors import WorkflowExecutionError
from wf_core.models.workflow import Workflow
from wf_core.runtime.ops.frames import collapse_completed_frames
from wf_core.runtime.ops.index import WorkflowIndex, build_workflow_index
from wf_core.runtime.ops.interrupts import resume_interrupt
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_core.runtime.ops.schemas import validate_payload_against_schema
from wf_core.runtime.scheduler import wake_frame
from wf_core.run_state import FrameStatus, RunState, RunStatus
from wf_core.tokens import END
@@ -43,7 +43,6 @@ def prepare_resume(
if run.current_frame_id is None:
raise WorkflowExecutionError("run has no current frame")
collapse_completed_frames(run)
if run.current_node_id is None:
raise WorkflowExecutionError("run has no current node")
@@ -64,13 +63,13 @@ def prepare_resume(
resume_outcome=resume_outcome,
reducers=reducers,
)
collapse_completed_frames(run)
if run.current_node_id == END:
return None
if run.current_frame_id is not None:
frame = run.current_frame()
if frame.status == FrameStatus.INTERRUPTED:
wake_frame(run, frame.id, front=True)
run.status = RunStatus.RUNNING
run.error = None
run.current_frame().status = FrameStatus.RUNNING
return index
@@ -83,7 +82,6 @@ def prepare_step(
if run.current_frame_id is None:
raise WorkflowExecutionError("run has no current frame")
collapse_completed_frames(run)
if run.current_node_id is None or run.current_node_id == END:
return None
if run.status == RunStatus.INTERRUPTED:
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from wf_core.errors import WorkflowExecutionError
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
@dataclass(slots=True, frozen=True)
class BlockedOnChildren:
"""Typed block reason for frames waiting on child frame completion."""
child_frame_ids: tuple[str, ...]
@classmethod
def from_frame(cls, frame: ExecutionFrame) -> "BlockedOnChildren | None":
raw = frame.metadata.get("blocked_on")
if raw is None:
return None
if not isinstance(raw, dict) or raw.get("type") != "child_frames":
raise WorkflowExecutionError(
f"malformed block reason for frame {frame.id!r}"
)
raw_ids = raw.get("frame_ids")
if not isinstance(raw_ids, list) or not all(
isinstance(item, str) for item in raw_ids
):
raise WorkflowExecutionError(
f"malformed child frame ids for frame {frame.id!r}"
)
return cls(tuple(raw_ids))
def to_metadata(self) -> dict[str, object]:
return {"type": "child_frames", "frame_ids": list(self.child_frame_ids)}
@dataclass(slots=True, frozen=True)
class ForeachIterationMetadata:
"""Typed metadata for a foreach iteration frame."""
foreach_node_id: str
loop_index: int
loop_item: Any
loop_alias: str
@classmethod
def from_frame(cls, frame: ExecutionFrame) -> "ForeachIterationMetadata | None":
if frame.kind != "foreach_iteration":
return None
metadata = frame.metadata
foreach_node_id = metadata.get("foreach_node_id")
loop_index = metadata.get("loop_index")
loop_alias = metadata.get("loop_alias")
if not isinstance(foreach_node_id, str) or not foreach_node_id:
raise WorkflowExecutionError(
f"malformed foreach node id for frame {frame.id!r}"
)
if not isinstance(loop_index, int):
raise WorkflowExecutionError(
f"malformed foreach loop index for frame {frame.id!r}"
)
if not isinstance(loop_alias, str) or not loop_alias:
raise WorkflowExecutionError(
f"malformed foreach loop alias for frame {frame.id!r}"
)
if "loop_item" not in metadata:
raise WorkflowExecutionError(
f"missing foreach loop item for frame {frame.id!r}"
)
return cls(
foreach_node_id=foreach_node_id,
loop_index=loop_index,
loop_item=metadata["loop_item"],
loop_alias=loop_alias,
)
def to_metadata(self) -> dict[str, object]:
return {
"foreach_node_id": self.foreach_node_id,
"loop_index": self.loop_index,
"loop_item": self.loop_item,
"loop_alias": self.loop_alias,
}
def add_frame(run: RunState, frame: ExecutionFrame, *, ready: bool = False) -> None:
"""Add a frame once; frame id reuse is always a runtime invariant error."""
if frame.id in run.frames:
raise WorkflowExecutionError(f"duplicate frame id {frame.id!r}")
run.frames[frame.id] = frame
if ready:
enqueue_frame(run, frame.id)
def enqueue_frame(run: RunState, frame_id: str, *, front: bool = False) -> None:
"""Put a pending frame in the ready queue without creating duplicates."""
frame = _frame(run, frame_id)
if frame.status != FrameStatus.PENDING:
raise WorkflowExecutionError(
f"cannot enqueue frame {frame_id!r} with status {frame.status!s}"
)
if frame_id in run.ready_frame_ids:
run.ready_frame_ids.remove(frame_id)
if front:
run.ready_frame_ids.insert(0, frame_id)
else:
run.ready_frame_ids.append(frame_id)
def select_next_frame(run: RunState) -> ExecutionFrame | None:
"""Select the next ready frame and update compatibility cursor fields."""
while run.ready_frame_ids:
frame_id = run.ready_frame_ids.pop(0)
frame = _frame(run, frame_id)
if frame.status != FrameStatus.PENDING:
raise WorkflowExecutionError(
f"ready frame {frame_id!r} has status {frame.status!s}"
)
frame.status = FrameStatus.RUNNING
run.current_frame_id = frame.id
run.sync_from_current_frame()
return frame
return None
def mark_frame_pending(run: RunState, frame_id: str, *, front: bool = False) -> None:
"""Mark a live frame pending and enqueue it for future execution."""
frame = _frame(run, frame_id)
frame.status = FrameStatus.PENDING
enqueue_frame(run, frame_id, front=front)
def block_frame_on_children(
run: RunState, frame_id: str, child_frame_ids: Sequence[str]
) -> None:
"""Mark a frame blocked on child completion and remove it from readiness."""
frame = _frame(run, frame_id)
run.ready_frame_ids = [item for item in run.ready_frame_ids if item != frame_id]
frame.status = FrameStatus.BLOCKED
frame.metadata["blocked_on"] = BlockedOnChildren(
tuple(child_frame_ids)
).to_metadata()
def wake_frame(run: RunState, frame_id: str, *, front: bool = False) -> None:
"""Wake a blocked or interrupted frame and enqueue it as pending."""
frame = _frame(run, frame_id)
if frame.status not in {FrameStatus.BLOCKED, FrameStatus.INTERRUPTED}:
raise WorkflowExecutionError(
f"cannot wake frame {frame_id!r} with status {frame.status!s}"
)
frame.status = FrameStatus.PENDING
frame.metadata.pop("blocked_on", None)
enqueue_frame(run, frame_id, front=front)
def wake_parent_if_children_complete(run: RunState, child_frame_id: str) -> None:
"""Wake a blocked parent once all child frames it waits on are completed."""
child = _frame(run, child_frame_id)
parent_id = child.parent_frame_id
if parent_id is None:
return
parent = _frame(run, parent_id)
block = BlockedOnChildren.from_frame(parent)
if block is None:
return
if all(
_frame(run, item).status == FrameStatus.COMPLETED
for item in block.child_frame_ids
):
wake_frame(run, parent_id)
def resolve_no_ready_frames(run: RunState) -> RunStatus:
"""Classify an empty ready queue into terminal, paused, or deadlocked state."""
if run.status == RunStatus.INTERRUPTED:
return RunStatus.INTERRUPTED
if any(frame.status == FrameStatus.FAILED for frame in run.frames.values()):
return RunStatus.FAILED
if run.frames and all(
frame.status == FrameStatus.COMPLETED for frame in run.frames.values()
):
return RunStatus.COMPLETED
if any(frame.status == FrameStatus.BLOCKED for frame in run.frames.values()):
raise WorkflowExecutionError("run has no ready frames and is deadlocked")
raise WorkflowExecutionError("run has no ready frames")
def _frame(run: RunState, frame_id: str) -> ExecutionFrame:
frame = run.frames.get(frame_id)
if frame is None:
raise WorkflowExecutionError(f"unknown frame id {frame_id!r}")
return frame
+10 -1
View File
@@ -27,7 +27,8 @@ from wf_core.runtime.ops.nodes import (
execute_node_use,
execute_node_use_async,
)
from wf_core.run_state import RunState
from wf_core.runtime.scheduler import select_next_frame
from wf_core.run_state import FrameStatus, RunState
from .preparation import prepare_step
@@ -71,6 +72,10 @@ def step_workflow(
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> RunState:
"""Execute at most one synchronous workflow step."""
frame = run.current_frame() if run.current_frame_id is not None else None
if frame is None or frame.status != FrameStatus.RUNNING:
if select_next_frame(run) is None:
return run
prepared = prepare_step(workflow, run, index)
if prepared is None:
return run
@@ -120,6 +125,10 @@ async def step_workflow_async(
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> RunState:
"""Execute at most one async workflow step."""
frame = run.current_frame() if run.current_frame_id is not None else None
if frame is None or frame.status != FrameStatus.RUNNING:
if select_next_frame(run) is None:
return run
prepared = prepare_step(workflow, run, index)
if prepared is None:
return run