concurrent scheduler foundation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user