`control_loop.py` was a single 2128-line module fusing two unrelated
concerns — the stateful async runtime and the pure reducer — with the
reducer dominated by two functions that read as walls:
`_process_step_result_tick` (~400 lines) and `_process_add_event_tick`
(~230 lines). You couldn't follow either in one pass.
This splits the module into a package along the reducer pattern's
natural seam and breaks the two god-functions into named phases. No
behavior change: the reducer is pure, so the existing transformation and
collection-stream suites pin command ordering and stream accounting.
## Package split
`control_loop.py` becomes `control_loop/`:
- `runner.py` — the async runtime: worker tasks, the scheduled-wakeup
heap, the adapter, turning reducer commands into side effects.
- `reduce.py` — the pure reducer: `_reduce_tick` and every per-tick
processor, plus retry/collect/replay helpers. `State + Tick -> (State,
Commands)`.
- `streams.py` — collection-stream accounting: fan-out stream lifecycle,
open-work-item counting, collect-batch release.
The dependency graph is a DAG: `runner -> reduce -> streams`. The one
back-edge (`streams`' release path needs `reduce`'s
`_add_or_enqueue_event`) is a single inline import at that chokepoint,
commented.
The package facade exports only the cross-package API — `control_loop`,
`rebuild_state_from_ticks`, `rebuild_state_from_ticks_stream`,
`replay_ticks_stream` — the symbols sibling packages (server, dbos,
`external_context`, `step_function`) actually import. White-box tests
import the internals they exercise straight from the owning submodule
(`.reduce` / `.runner` / `.streams`). `import time` stays in the facade
because tests patch `control_loop.time.time`, and the submodule loggers
keep the `workflows.runtime.control_loop` name so caplog filters still
match.
## Decomposing the reducer functions
Both now read top-to-bottom as a sequence of named steps.
`_process_step_result_tick`:
```python
this_execution = _find_in_progress(worker_state, tick.worker_id)
rerun = _rerun_for_stale_collect_buffer(tick, worker_state, this_execution)
if rerun is not None:
return state, rerun
scope = _fan_out_scope(state, step_name, this_execution, fanned_out=fanned_out)
for result in tick.result:
_apply_step_result(result, ..., acc=acc)
acc.commands.extend(_resolve_work_item_in_stream(tick, state, scope, acc, now_seconds))
# finalize: NOT_RUNNING transition, drop from in_progress, drain queue
```
The per-result flags that were locals threaded through 400 lines now
ride on a small `_StepResultAcc` carrier, and the ~120-line
retry/`catch_error` branch is extracted to
`_schedule_retry_or_route_failure`.
`_process_add_event_tick` splits into its three real phases:
`_redeliver_collection_payload` (payload-carrying ticks route straight
to their collect target and stop), `_resolve_waiters`, then
`_route_to_accepting_steps` — with the gnarly collect-member routing
pulled into `_route_member_to_collect_step`.
One rename: `_apply_stream_work_delta` -> `_adjust_open_work_items`,
matching the `open_work_items` field it mutates.
5.2 KiB
Control Loop Architecture
The control loop is the core execution engine for workflows. It follows a reducer pattern — pure state transitions with side effects expressed as commands:
State + Tick --> (NewState, Commands)
control_loop/ — split along the reducer seam into runner.py (async runtime: tasks, the wakeup heap, command execution), reduce.py (the pure reducer: _reduce_tick and the per-tick processors), and streams.py (collection-stream accounting). The package __init__ re-exports the surface so workflows.runtime.control_loop stays a single import target.
Main Loop
flowchart TD
A[Initialize: queue StartEvent, schedule timeout] --> B[Drain tick buffer]
B --> C{Buffer empty?}
C -- No --> D[Reduce tick --> state + commands]
D --> E[Execute commands]
E --> B
C -- Yes --> F[Wait for next completion]
F --> G{What completed?}
G -- Timeout --> H[Pop due scheduled ticks into buffer]
G -- External tick --> I[Add to buffer]
G -- Worker result --> J[Add TickStepResult to buffer]
H --> B
I --> B
J --> B
- Initialize — Queue
StartEvent, schedule workflow timeout, rewind any in-progress work from a prior run. - Drain tick buffer — Process all queued ticks synchronously. Each tick runs through the reducer and its commands execute before the next tick.
- Wait for next completion — Build a task set (worker tasks + one pull task), then wait for the first to complete. Workers have priority over pull tasks.
- Process completed task — Route the result back into the tick buffer and loop.
Ticks and Commands
Ticks are inputs to the reducer. They represent things that happen: events arriving, steps completing, cancellation requests, timeouts, and publish requests from steps. Each tick type dispatches to a dedicated reducer function.
types/ticks.py — all tick types
Commands are outputs from the reducer — the side effects the loop executes. They represent actions to take: spawning step workers, queuing events (with optional delays), completing or failing the run, and publishing events to the external stream.
types/commands.py — all command types
Runtime Integration
The control loop is runtime-agnostic. It talks to the outside world exclusively through InternalRunAdapter (see core-overview.md — Runtime and Adapters). This is the extension point — runtime decorators wrap the adapter to add behavior like tick persistence, idle detection, or event recording.
sequenceDiagram
participant CL as Control Loop
participant A as InternalRunAdapter
participant Ext as External (handler/client)
Note over CL: Main loop iteration
CL->>A: wait_receive() [pull task]
Ext-->>A: send_event() delivers tick
A-->>CL: WaitResultTick
CL->>CL: reduce tick --> (state, commands)
CL->>A: on_tick(tick) [journaling hook]
Note over CL: Execute commands
CL->>A: write_to_event_stream(event)
CL->>CL: spawn worker task
CL->>A: wait_for_next_task(task_set, timeout)
A-->>CL: completed task (worker or pull)
plugin.py — full adapter interface
Key Design Decisions
- Deterministic replay — The reducer is pure. Adapters can record ticks and replay them to reconstruct state, and override time functions for deterministic timestamps.
- Priority ordering — Worker tasks complete before pull tasks, ensuring in-flight work finishes before accepting new external events.
- Optimistic execution with retry — Workers receive a snapshot of collected events. If new events arrive during execution, the worker re-runs with the updated snapshot.
- State rehydration — On resume, in-progress events move back to the queue and worker IDs reset, allowing clean restart from stored ticks.
- Idle detection — When all steps are waiting on external input, the loop publishes
WorkflowIdleEvent. Runtime decorators can use this signal to release idle workflows from memory. - Retry-exhaustion hook —
_schedule_retry_or_route_failure(theStepWorkerFailedpath of_process_step_result_tick) routes aStepFailedEventto a registered@catch_errorhandler. Handlers can be scoped (@catch_error(for_steps=[...])) or wildcard, with a per-handlermax_recoveriesbudget tracked per event lineage inrecovery_counts: dict[str, int]onEventAttempt/TickAddEvent/CommandQueueEvent. Routing consultsBrokerConfig.handler_for_stepandBrokerConfig.catch_error_handlers; when the count exceedsmax_recoveriesor no handler owns the step, the loop publishes aWorkflowFailedEventcarrying the live exception and fails the run. The liveExceptionrides onEventAttempt/TickAddEvent/CommandQueueEventbetween retries — annotated withSerializableExceptionwhere it crosses a pydantic serialization boundary — and is exposed to step bodies viaContext.retry_info().