214 lines
11 KiB
Markdown
214 lines
11 KiB
Markdown
---
|
|
status: proposed
|
|
---
|
|
|
|
# Explicit Fork and Topology-Driven Gather
|
|
|
|
General graph concurrency will use explicit fork and gather steps. Outcomes
|
|
continue to select one transition; forks create several branch activations;
|
|
gathers rendezvous compatible activation tokens, merge their lineage-local
|
|
state patches, and create one continuation. This preserves topology-only edges
|
|
without making multiple matching edges silently mean broadcast.
|
|
|
|
## Context
|
|
|
|
The scheduler, ready queue, blocked frames, lineage-local state views, and
|
|
reducer-aware barrier commits already support concurrent foreach and native
|
|
subgraphs. They do not yet define general graph-level fork/gather. A former
|
|
`JoinNode` day-one marker immediately emitted `done`; it was removed after a
|
|
census found no real persisted workflows using it. `GatherNode` therefore does
|
|
not inherit placeholder semantics or a compatibility burden.
|
|
|
|
A cross-system semantics review supported keeping `NodeResult.output` separate
|
|
from its named domain `outcome`, keeping operational failure outside that
|
|
outcome namespace, and representing concurrency with explicit control steps.
|
|
The decisive design pressure came from partial and repeated gathers rather than
|
|
from output typing.
|
|
|
|
Consider a fork producing `a`, `b`, and `c`. One gather may merge `a+b` into
|
|
`d`, while a later gather merges `d+c`. The first gather must not know which
|
|
fork originally produced its inputs, and the second must accept the merged
|
|
`d` continuation like any other arrival. Inside a loop, it must combine
|
|
`a1+b1`, never `a1+b2`.
|
|
|
|
Conditional paths add another requirement. If one branch continues through
|
|
either `d` or `e`, a later gather may require `b AND (d OR e)`. Waiting for
|
|
every incoming edge would deadlock because only one alternative can arrive.
|
|
|
|
Cross-merging raises the same issue at larger scale. If one activated region
|
|
produces `b,c` and another produces `e,f`, independent gathers may consume
|
|
`b+e` and `c+f`. Correctness cannot depend on whether the scheduler happens to
|
|
run `b,e,c,f` or `c,f,b,e` first.
|
|
|
|
## Decision
|
|
|
|
The graph model distinguishes three control meanings:
|
|
|
|
- an ordinary node outcome selects exactly one continuation;
|
|
- a fork creates one child activation for every declared branch;
|
|
- a gather consumes compatible arrivals and creates one continuation.
|
|
|
|
A gather is independent of the construct that produced its inputs. It knows
|
|
its required local input slots from the graph and owns a merge policy. Edges
|
|
targeting a gather identify the slot they can satisfy. A gather requires every
|
|
slot, while several incoming edges may be alternatives for one slot. This gives
|
|
`b AND (d OR e)` without placing executable predicates on edges.
|
|
|
|
Runtime arrivals carry activation identity, lineage identity, and provenance.
|
|
The gather buckets arrivals by compatible activation context, accepts at most
|
|
one token per slot for an activation, waits until all slots are present, then
|
|
merges their lineage-local patches. Its result is a new continuation token that
|
|
preserves combined provenance and can enter another gather.
|
|
|
|
Runtime identity follows one canonical dependency chain. A runtime scope is one
|
|
workflow invocation and its committed state universe. A lineage belongs to
|
|
exactly one scope and represents one isolated state worldview inside it. An
|
|
execution frame is a schedulable graph cursor that executes against one
|
|
lineage, so the frame's scope and parent lineage are derived through that
|
|
lineage rather than independently stored relationships. Frame ancestry remains
|
|
separate: a parent frame expresses scheduling ownership and block/wake behavior,
|
|
not state ancestry.
|
|
|
|
Lineages are not owned by frames. A completed branch frame may leave its
|
|
lineage waiting at a gather, and a later continuation frame may execute against
|
|
the lineage produced by a merge. Activation tokens bind a control-flow arrival
|
|
to its lineage plus the correlation and provenance needed by gathers; they do
|
|
not duplicate the lineage's scope membership.
|
|
|
|
Lineage remains a virtual worldview: committed scope state plus writes visible
|
|
to one branch. Gathering several lineages does not require turning lineage
|
|
ancestry into a multi-parent graph. Every input lineage must belong to the same
|
|
runtime scope. Because lineages have one parent, their ancestry chains have at
|
|
most one deepest shared lineage: the lowest common ancestor is the deterministic
|
|
merge base. Different scopes or no shared ancestor fail before state mutation.
|
|
A partial gather creates its intermediate lineage under that merge base and
|
|
retains multi-input provenance in activation-token metadata. A final gather can
|
|
merge that lineage with remaining siblings and resume the blocked parent
|
|
continuation.
|
|
|
|
The first gather merge policy is fail-closed:
|
|
|
|
```python
|
|
class GatherMergePolicy:
|
|
conflicts: Literal["error"] = "error"
|
|
```
|
|
|
|
State-field reducers remain the source of truth for legitimate concurrent
|
|
merges. The gather policy determines what happens when patches cannot be
|
|
merged; the initial behavior is to fail rather than choose a last writer.
|
|
|
|
Gather slots have declaration order, and that order is the canonical reducer
|
|
replay order. After choosing the merge base, the runtime applies each selected
|
|
lineage's writes after that base in declared-slot order, never arrival,
|
|
scheduler, or frame-id order. A bucket accepts exactly one token for each slot;
|
|
a second token for the same activation and slot fails the activation instead of
|
|
making an alternative-path race decide the result. Order-sensitive reducers
|
|
such as append are therefore deterministic in synchronous and asynchronous
|
|
execution.
|
|
|
|
Branch execution order may be deterministic in the synchronous runtime and
|
|
overlap in the asynchronous runtime. Both modes must produce equivalent graph
|
|
semantics. Scheduler order decides when compatible work progresses, never which
|
|
arrivals belong together.
|
|
|
|
An unhandled branch failure makes its gather activation terminally failed and
|
|
stops further branch admission. The runtime requests cancellation of admitted
|
|
siblings, awaits every sibling's settlement, and accepts no later state or
|
|
trace commits from them; external effects that already occurred cannot be
|
|
rolled back. It then marks the failed activation's uncommitted lineages
|
|
abandoned and permanently non-mergeable, invalidates and removes every pending
|
|
gather token, marks sibling frames cancelled or failed, and persists the failed
|
|
run. Restore may inspect those frames and lineages but cannot schedule them or
|
|
consume a token from the failed activation.
|
|
|
|
`END` and explicit `EndNode` represent workflow/subgraph termination, not a
|
|
generic way to complete any child frame. A foreach item returns through a
|
|
back-edge targeting its owning `ForeachNode`; the runtime completes that item
|
|
frame and wakes its parent barrier without executing the controller inside the
|
|
child. Future fork branches likewise converge through explicit gathers rather
|
|
than independently terminating the containing workflow scope.
|
|
|
|
General break and race behavior are not part of the first fork/gather design.
|
|
The existing foreach `fail`, `skip`, and `collect` policies describe the
|
|
disposition of runtime item failures; they are distinct from completion
|
|
policies such as first-completed, first-error, or all-completed. A future break
|
|
feature would require an operational signal to the owning foreach and an
|
|
explicit policy for already admitted concurrent items. No `BreakNode` is added
|
|
without that use case and policy.
|
|
|
|
The placeholder `JoinNode` was removed rather than silently upgraded. The
|
|
repository has no real persisted artifacts outside tests, so `GatherNode` can
|
|
be introduced with its actual rendezvous and merge contract and no legacy wire
|
|
alias.
|
|
|
|
## Considered Options
|
|
|
|
**Multiple ordinary edges broadcast.** Rejected because edge cardinality would
|
|
silently change an outcome from selection to spawning and make ordinary graph
|
|
convergence ambiguous.
|
|
|
|
**Gather references its originating fork.** Rejected because partial gathers,
|
|
staged gathers, conditional paths, and cross-merges consume tokens based on
|
|
their local rendezvous contract, not one producer.
|
|
|
|
**Gather waits for every incoming edge.** Rejected because alternative paths
|
|
such as `d OR e` would require mutually exclusive arrivals. Named slots provide
|
|
AND across slots and OR within a slot.
|
|
|
|
**Lineage becomes a multi-parent DAG.** Not currently required. Merge provenance
|
|
belongs to activation tokens; an intermediate merged worldview can remain a
|
|
child of the compatible inputs' common lineage parent.
|
|
|
|
**Store scope and parent-lineage identity on every frame.** Rejected because
|
|
those relationships are canonical on the lineage and duplicated frame fields
|
|
can disagree with them. Runtime operations should resolve and validate a
|
|
frame's lineage and scope together.
|
|
|
|
**Model normal foreach completion as a continue node.** Rejected because an
|
|
ordinary back-edge to the owning foreach already expresses item return in the
|
|
canonical graph. Break and race semantics remain separate future policies
|
|
rather than additional meanings assigned to ordinary outcomes.
|
|
|
|
**Reuse or rename the placeholder `JoinNode`.** Rejected and removed because
|
|
the node was a pass-through marker with no barrier contract.
|
|
|
|
## Consequences
|
|
|
|
- Edge identity gains gather-slot significance only when its target is a
|
|
gather; ordinary edge semantics stay unchanged.
|
|
- Workflow validation must require every gather-target edge to name exactly one
|
|
declared slot, reject missing or unknown gather slots, prove that every slot
|
|
has an incoming edge, reject slots on non-gather targets, and preserve one
|
|
successor per ordinary `(node, outcome)` pair.
|
|
- Checkpoints must persist pending gather arrivals and activation provenance so
|
|
interruption/resume cannot mix loop iterations or subgraph invocations.
|
|
- Runtime operations should resolve a frame, its lineage, and its scope through
|
|
one internal interface instead of accepting several independently supplied
|
|
identifiers.
|
|
- Persisted frame state should not duplicate scope or parent-lineage
|
|
relationships once real checkpoint compatibility has been checked and any
|
|
required migration has been defined.
|
|
- Trace output must make fork activation, branch identity, gather waiting, and
|
|
merged continuation inspectable without treating scheduler bookkeeping as
|
|
ordinary node output.
|
|
- Fork/gather should generalize concurrent-foreach lineage and barrier helpers,
|
|
not create a second state-patch system.
|
|
- Runtime branch failures remain execution failures with the terminal cleanup
|
|
above. Skip, collect, race, first-success, configurable cancellation, and
|
|
timeout policies are deferred.
|
|
|
|
## Open Questions
|
|
|
|
- The exact serialized edge field and authoring name for a gather slot.
|
|
- The minimal activation-correlation and provenance representation that
|
|
supports loops, nested forks, subgraphs, partial gathers, and cross-merges.
|
|
- Whether a gather resumes an existing blocked frame or creates a dedicated
|
|
continuation frame in each topology shape.
|
|
- The trace representation for waiting and merging without excessive internal
|
|
scheduler noise.
|
|
|
|
This ADR extends the lineage and barrier direction established by
|
|
[ADR-0002](0002-concurrent-foreach-policy-and-barrier-commits.md). It remains
|
|
proposed until the open runtime-state questions are resolved in the fork/gather
|
|
design specification.
|