SRD-056.A — Multi-Instance (parallel)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-07-21 |
| Owner | Ruslan Gabitov |
| Implements | ADR-025 v.2 §2.5 (parallel execution), §2.6 (data mediator), §2.7 (completion-condition cancellation), §2.9 (runtime attributes), §2.12 (composite iteration as an off-loop decorator) — the parallel slice; §2.8 behavior → SRD-056.B; epic #88 |
| Upstream | ADR-017 v.1 (the single-writer loop the decorator requests scope operations from), ADR-023 v.2 (the execution-scope re-entry + cancelScope), ADR-018 v.1 (scoped interruption / boundary cancel this reuses per instance), ADR-011 v.7 (the data.Collection split/assemble mediator), ADR-010 v.2 (name-based data resolution), ADR-013 v.2 (iteration facts), ADR-001 v.6 |
| Refines | — |
| Related | SRD-054 (the composite-iteration decorator engine — the scopeRequest/scopeRoundtrip protocol this extends), SRD-055 (the sequential Multi-Instance slice this parallels), SRD-053 (the unique per-instance scope segment + N-concurrent-scope precedent) |
§1 Background¶
BPMN 2.0 §13.3.7 lets a parallel Multi-Instance (isSequential = false, the
model default) run its inner activity N times concurrently — all N instances
start at activation, run in distinct per-instance scopes, split a collection
element in per instance, assemble one out, and the activity completes when the
last drains; a completionCondition cancels the still-running remainder.
This is the third slice of the ADR-025 v.2 decorator re-landing (§2.12, after
SRD-054's composite Standard Loop and SRD-055's sequential MI). The prior landing
drove the parallel fan-out and the N-of-N barrier entirely on the loop
goroutine: fanOutParallelMI opened N scopes at the host's onScopeOpen, the
host parked once, and parallelInstanceDrained (loop-side, from
completeScope) counted each drain, evaluated the completionCondition, cancelled
the remainder, published the output, and resumed the host at exhaustion. §2.12
moves that control onto the activity's own off-loop runner — the iteration
decorator — so §2.8's behavior throw (SRD-056.B) becomes an ordinary off-loop throw
caught on the MI boundary, instead of a loop-goroutine throw that self-deadlocks on
the loop's own inbound channel.
This SRD deletes and reuses the SRD-056.A slot: its prior (loop-goroutine-driven) execution content is replaced with the decorator design. The model, the per-instance data mediator, positional assembly, the completion semantics, and the runtime attributes are unchanged — only who drives the fan-out and the barrier moves off the loop.
§1.1 The constraint that shapes the parallel barrier¶
A composite host's off-loop runner parks on a single, cap-1, waiting-gated
channel (track.evtCh, eventBufferDepth == 1; dispatchToParked flips the host
out of ls.waiting and blocks on the send). Sequential MI (SRD-055) fits this
perfectly — one instance scope is open at a time, so one scopeDone
arrives per pass and awaitScopeDrained reads exactly one. Parallel has N
concurrent instance scopes draining in arbitrary order at that
single-goroutine, single-slot runner, so the loop cannot fire N scopeDones at
it — each delivery needs the runner re-armed first.
The decorator therefore drives the barrier with a per-drain handshake (ADR-025
v.2 §2.12 "fan-out-then-await-all"): the loop delivers instance drains to the runner
one at a time through the cap-1 re-arm, queueing any that arrive while the runner
is busy; the runner processes each off-loop (count, bind the §2.9 attributes,
evaluate the completionCondition, and — in SRD-056.B — throw the behavior event)
and re-arms for the next. All completion policy runs on the runner; the loop
performs only the single-writer mutations it requests (open N, capture each
output before its scope closes, cancel the remainder, publish).
In scope: the off-loop parallel driver (runMIParallel), the fan-out +
N-of-N handshake barrier, the per-instance data mediator (reused), positional
assembly + the visibility barrier (reused), completionCondition cancellation,
the runtime attributes, and MI-aware cancelHostScope (the interrupting-boundary
teardown of all N). Deferred: behavior/ComplexBehaviorDefinition →
SRD-056.B; MI compensation → future Transaction work (ADR-025 §2.10). Multi-Instance
stays composite-host-only (as SRD-055 — no leaf-Task MI path).
§2 Requirements¶
Functional — the model (landed, unchanged)¶
- FR-1 — no model change.
MultiInstanceLoopCharacteristicsalready expresses parallel (isSequential = false, the default), constructed under the SRD-055 FR-2 guards. This SRD is runtime-only.
Functional — the decorator drives parallel MI off the loop (reworked)¶
- FR-2 — the host drives its own fan-out via
runMIParallel. A parallel-MI composite iterates on its own runner goroutine (a newrunMIParallel, routed byexecuteStepbefore the park path, likerunMISequential). It resolves N once off the loop, requests the loop to fan out N instance scopes, then runs the per-drain barrier and, on exit, follows the composite's single outgoing flow once. The host no longer parks for control —drivesOwnIteration(node)now covers a parallel-MI composite too (Standard-Loop or any Multi-Instance composite), socheckNodeType/enterCompositedo not park it and the oldonScopeOpenfan-out dispatch is removed. - FR-3 — fan out N distinct instance scopes (loop-side, on request). A
scopeRequest{op: scopeFanOut, n, col}roundtrip asks the loop to open N child scopes — instance i at segmentscopeSegment(node) + "-" + i(0-based, deterministic, ADR-025 §2.2/§2.5) — binding each instance'sloopCounter+ input item at its own scope and seeding + arming its body, all before the reply. Because the handler runs to completion on the loop goroutine, every instance is registered in the loop-owned group and the host is marked waiting before any drain is processed. N ≤ 0 opens no scope, creates no group, and follows the outgoing once. - FR-4 — the loop owns a lean N-of-N group; the runner owns the policy. A
loop-owned
miGroup(the host, node, the still-open instance-path→ordinal set, the pre-sized staging collection, the output names, N, and the undelivered-drain count) is the barrier substrate the per-scopescopeEntrymodel cannot express. The counting, thecompletionCondition, the cancellation decision, and the §2.9 attribute binds run on the runner (off the loop); the loop only mutates the group on request.
Functional — the per-drain handshake barrier¶
- FR-5 — one drain, one delivery. Each instance scope drains through the
existing
decScope→completeScope, which (on the loop) captures the instance's output before the scope closes (FR-8), removes it from the group's open set, and then delivers onescopeDoneto the runner if it is waiting, else queues it (increments the undelivered count). The runner receives exactly onescopeDoneper completed instance, in the loop's completion order. - FR-6 — re-arm delivers the next. After processing a drain the runner issues a
scopeRequest{op: scopeReArm}roundtrip; the loop re-marks the host waiting and, if a drain is queued, immediately delivers one (dequeues). The runner thenawaitScopeDraineds the next. The handshake makes the loop deliver concurrent drains strictly one at a time — the cap-1 channel is never sent to while the runner is busy, so the single-writer loop never blocks on it. - FR-7 — completion order is nondeterministic; the engine is ordered. Instances
run concurrently, but each scope's
completeScoperuns on the loop goroutine, one at a time — so output capture and the group mutations are single-writer and race-free; and the runner processes one delivered drain at a time, so the counters and thecompletionConditionevaluation are single-reader without new locks.
Functional — the data mediator (per-instance scope; unchanged)¶
- FR-8 — split in / assemble out, at each instance's own scope. Instance i's
inputDataItem= element i of theloopDataInputRefcollection is bound into instance i's child scope at fan-out (loop-side, reusedopenParallelInstance) — not the host scope, which SRD-055 could use only because it runs one at a time; concurrent instances would clobber a sharedloopCounter/item. When instance i drains, itsoutputDataItemis read from its child scope into slot i of the private staging collection, keyed by the ordinal the scope carries (frozen at fan-out), not the host's mutableloopCounter— the one loop-side capture,captureParallelOutputincompleteScopebeforeCloseScope(reused). Positional assembly (slot = input ordinal) is deterministic despite nondeterministic completion order (ADR-025 §2.6). - FR-9 — visibility barrier. The staging collection is never scope-visible
during the run (pre-sized to N;
SetAt(ordinal)replaces; a cancelled slot keeps its pre-run nil). It publishes underloopDataOutputRefat the host scope once, at activity completion — the loop performs thebindValueAtin the terminalscopeCompletehandler (the staging is loop-owned; publish is a plane write, not a throw, so it stays loop-side, unlike sequential's off-loop publish).
Functional — completion condition & cancellation¶
- FR-10 —
completionConditioncancels the remainder. Evaluated on the runner, off the loop, after each delivered drain (evalCompletionreused, reading the §2.9 attributes the runner bound at the host scope):true→ the activity is done now — the runner issuesscopeRequest{op: scopeComplete, cancel: true}; the loop cancels every still-open instance scope as a unit (cancelScope(path, PhaseCanceled), ADR-018 mechanism, idempotent + per-path), counts them innumberOfTerminatedInstances, publishes the assembled output over the completed slots, drops the group, and replies with the terminated count. Cancelled instances contribute nooutputDataItem(their slot stays nil).false→ the instance is counted and the runner re-arms for the next. - FR-11 — natural completion. When the runner has received N drains with no
completionConditionfiring, it issuesscopeRequest{op: scopeComplete, cancel: false}; the loop publishes the staging and drops the group.
Functional — runtime attributes & host teardown¶
- FR-12 — runtime attributes (§2.9). After each delivered drain the runner binds
the §2.9 attributes at the host scope (off the loop,
bindDataItemAt, mutex-safe), where thecompletionCondition(and the body) resolve them by name:numberOfInstances(frozen N),numberOfActiveInstances(still-running, genuinely> 1for parallel),numberOfCompletedInstances(the delivered count — monotonic, one per evaluation, matching sequential), andnumberOfTerminatedInstances(§2.7). Each instance additionally sees its own 0-basedloopCounterin its own scope (bound at fan-out). - FR-13 — MI-aware host teardown. An interrupting boundary firing on the MI
activity (or a scoped Terminate reaching it) tears down all N instance scopes:
cancelHostScopeis MI-aware — for a parallel-MI host it cancels the group's open paths as a unit and drops the group (reusedcancelParallelGroup). The parked runner unblocks throughawaitScopeDrained'sevtCh-closed path (as SL / sequential MI), andrunMIParallelreturns. - FR-14 — observability. Each instance scope's Opened/Completed/Canceled facts
carry that instance's own 0-based ordinal —
entry.ordinal, frozen on the instance scope at fan-out — not the sharedhost.loopCounter, so concurrent passes are individually observable. The ordinal is applied by thecompleteScopegrouped-entry override (if entry.group != nil { ordinal = entry.ordinal }) andopenParallelInstance's directi— not byscopeLoopCounter, which returns the meaninglesshost.loopCounterfor a self-driving node. Because this SRD widensdrivesOwnIterationto include parallel MI,scopeLoopCounter(parallel)flips from-1tohost.loopCounter; M1 therefore extends the sameentry.groupordinal override to the cancel-path fact (cancelScope'sreportScope) so a cancelled instance reports its ordinal, not the shared counter.
Functional — front door¶
- FR-15 — front door.
examples/multi-instance-parallel/(the voting worked example — a parallel MI over a candidate list with acompletionConditionquorum), the iteration guide,CHANGELOG.md, the conformance tracker, and the READMEs (EN + RU) already describe parallel MI (behavior unchanged); the rework needs only a CHANGELOG note.
Non-functional¶
- NFR-1 — reuse the decorator + the concurrent-scope substrate. No new scope
open/seed/drain/cancel primitives —
runMIParallelreuses the SRD-054scopeRoundtrip/awaitScopeDrainedprotocol and the SRD-056.A-landedopenParallelInstance/captureParallelOutput/cancelOpenInstances/cancelParallelGroup. The genuinely new code isrunMIParallel, the three new scope-protocol ops (scopeFanOut/scopeReArm/scopeComplete) and their loop-side handlers, and thecompleteScopegroup-branch rewrite (capture + dispatch-or-queue instead of the loop-side driving). - NFR-2 — single-writer preserved (ADR-017 v.1). The loop stays the sole writer
of scope lifecycle (
OpenScope/CloseScope,ls.scopes,ls.miGroups, the group fields) and performs the pre-close capture and the final publish. The runner does only mutex-safe plane writes (bindDataItemAtfor the §2.9 attributes) off the loop, and never reads loop-owned group state mid-flight — it counts delivered drains locally and learns the terminated count from thescopeCompletereply. - NFR-3 — no new locks; the handshake is the fence. All group state is
loop-goroutine-owned; concurrency lives only in the instance bodies, which scope
isolation already separates. The cap-1 re-arm handshake serializes drain delivery,
so the loop never blocks on the runner's channel.
-raceon the e2e guards it. - NFR-4 — deferred surfaces stay out. No
behavior/ComplexBehaviorDefinition(SRD-056.B), no compensation, no leaf-Task MI. - NFR-5 — the existing suites are the safety net. The landed
TestParallelMultiInstance*(internal/instance/mi_parallel_test.go) +pkg/threshere2e + the SRD-054/SRD-055 suites stay green throughout — behavior is unchanged. - NFR-6 — coverage. Every touched file finishes ≥95% diff-coverage (aim 100%);
make cigreen,-raceclean.
§3 Models¶
§3.1 Runtime deltas (internal/instance/)¶
-
mi_parallel.go—runMIParallel(new, host runner) + the group relocation.runMIParallel(ctx, step, mi): n, col := resolveActivation(...) // off-loop; count once if n <= 0 { return t.executeNode(ctx, step) } // zero-instance scopeRoundtrip{op:scopeFanOut, host:t, node, n, col} // loop opens N + group + marks waiting for completed := 0; ; { awaitScopeDrained(ctx) // one delivered drain completed++ bindParallelCounters(t, n, completed, terminated=0) // off-loop (FR-12) if completed < n && mi.CompletionCondition() != nil && evalCompletion(...) { term := scopeRoundtrip{op:scopeComplete, cancel:true} // loop cancels+publishes+drops bindParallelCounters(t, n, completed, term) // final off-loop break // (SRD-056.B: throw before this) } if completed == n { scopeRoundtrip{op:scopeComplete, cancel:false} // loop publishes+drops break } scopeRoundtrip{op:scopeReArm} // loop re-marks waiting + delivers next queued } return t.executeNode(ctx, step) // follow the composite's outgoing oncebindParallelCountersmoves off the loop (the runner callsbindDataItemAt);resolveActivation/evalCompletionreused (mi.go). -
scope_decorator.go— the protocol gains an op.scopeRequestcarries anop scopeOp(scopeOpen— the existing sequential/SL open;scopeFanOut;scopeReArm;scopeComplete) plusn int/col data.Collection(fanOut) /cancel bool(complete);scopeReplygainsterminated int.handleScopeRequestdispatches onop: scopeFanOut→ build themiGroup,openParallelInstance×N (reused), mark the host waiting, reply.scopeReArm→ls.waiting[host] = {}; if the group has a queued drain, dispatch onescopeDone; reply.-
scopeComplete→ ifcancel,cancelOpenInstances(reused) countingterminated; publishgrp.stagingat the host scope (bindValueAt); dropls.miGroups[host]; reply(terminated). -
mi_parallel.go—miGroupkeepshost/node/open/staging/outputRef/outputItem/n, addspending int(undelivered drains). The loop-side driving fields (completed/terminated/mi/collection/inputItem) that only the oldparallelInstanceDrainedused are dropped — the runner owns those counts; the fan-out reads names/collection from the request +multiInstanceOf(node). -
scope_runtime.go—completeScopegroup-branch rewrite. Keep thecaptureParallelOutputstep (forced loop-side, FR-8). Replace theparallelInstanceDrainedcall with: remove the path fromgrp.open; ifls.waiting[host]—dispatchToParked(scopeDone); elsegrp.pending++. Remove theonScopeOpenparallel-MI fan-out dispatch (a parallel MI never parks now).resumeScopeHost'sdrivesOwnIterationguard is untouched (the group branch returns before it). -
track.go/std_loop.go— routing.drivesOwnIteration(node)becomes Standard-Loop composite or any Multi-Instance composite (drop theIsSequential()narrowing);executeSteproutes ascopeHostparallel MI torunMIParallel. -
Seam removal (M2).
fanOutParallelMI,parallelInstanceDrained, the loop-sidebindParallelCounters, and theonScopeOpenparallel branch are deleted (the old loop-driven fan-out + barrier).openParallelInstance,captureParallelOutput,cancelOpenInstances,cancelParallelGroup,cancelHostScope's MI branch are kept (reused by the new handlers / boundary teardown).
No model change — behavior fields land in SRD-056.B.
§4 Analysis¶
§4.1 Why the barrier moves off the loop (FR-2, ADR-025 v.2 §2.12)¶
The v.1 parallel barrier lived in parallelInstanceDrained on the loop goroutine —
it counted drains, evaluated the completionCondition, cancelled the remainder, and
resumed the host. SRD-056.B must throw a behavior event at those exact points
(per instance / on completion, §2.8). A throw hands the event to the loop's ordered
inbound channel; issued from the loop goroutine (the channel's only reader, busy
inside the throw) it self-deadlocks, and made fire-and-forget it drops the boundary
catch nondeterministically (§2.12). Because the behavior decision depends on the
aggregate group state (None/One/All/Complex over the completed count), it must
run wherever the barrier is — so the barrier itself must move off the loop. This SRD
does that now, so SRD-056.B adds only the throw at an already-off-loop decision
point, with no barrier rework.
§4.2 The per-drain handshake is forced by the cap-1 runner park (FR-5, FR-6)¶
The naive "the runner awaits N drains" is impossible: the runner's evtCh is cap-1
and waiting-gated, so the loop can deliver a scopeDone only when the host is
waiting, and delivering flips it not-waiting. With N concurrent drains and a single
busy runner, the loop would block on the second send (§1.1). The handshake resolves
it: completeScope delivers one drain if the host is waiting, else queues it
(grp.pending), and scopeReArm re-marks the host waiting and dequeues the next.
Every loop-side step — capture, close, group mutation, dispatch/queue, re-arm — runs
on the loop goroutine and is serialized, so there is no race between a concurrent
drain's completeScope and a scopeReArm handler. The runner sees drains strictly
one at a time; the concurrency is only in the instance bodies (unchanged).
§4.3 The runner never reads loop-owned group state mid-flight (NFR-2)¶
Delivery is 1:1 with completion, in order, so the runner's local completed
count (number of scopeDones received) equals the group's authoritative completed
count for every instance it has been told about — it never reads grp.completed.
numberOfCompletedInstances as the completionCondition sees it is the delivered
count (monotonic, one increment per evaluation) — the same "the condition sees each
completion once, in order" contract as sequential MI, and a cleaner definition than
the v.1 loop-side grp.completed (which could jump ahead of the evaluated drain when
siblings drained into the queue). The terminated count comes back in the
scopeComplete reply; grp.staging is read only at publish, which the loop
performs (FR-9), after the last capture — no cross-goroutine read of loop state. The
existing TestParallelMultiInstanceRuntimeAttributes asserts only completion (a
never-true numberOfInstances >= 100 condition, all N run), not mid-run active
counts, so the delivered-count definition is behavior-preserving against the suite.
§4.4 Publish and capture stay loop-side; only policy is off-loop (FR-8, FR-9)¶
grp.staging is loop-owned: captureParallelOutput writes it on each drain (before
CloseScope — the one window only the loop observes, as for sequential MI), and the
final bindValueAt publishes it. Both are plane operations, not event throws, so
keeping them on the loop introduces no deadlock and avoids sharing the group pointer
with the runner. What genuinely must be off-loop is the policy — count, attribute
binds, completionCondition, cancellation decision, and (SRD-056.B) the throw — and
that is exactly what runMIParallel owns. This is the parallel counterpart of
sequential's captureSequentialOutput staying loop-side (SRD-055 §4.2); parallel
additionally keeps the publish loop-side because its staging is group-owned, not
host-owned. This sequential/parallel asymmetry is deliberate and both conform to
§2.12 ("the loop performs the mutation on request"): sequential publishes off-loop
(publishOutput) because its staging is host-owned; parallel routes publish through
the scopeComplete request because its staging is group-owned.
§4.5 Cancellation is a single ordered step (FR-10, FR-13)¶
completionCondition = true → scopeComplete{cancel:true} runs one loop step:
cancelOpenInstances tears down every still-open instance path (cancelScope,
idempotent + per-path), counts them terminated, then publishes over the completed
slots and drops the group — no window where the host resumes before the output is
published or a sibling's slot is captured. The same open-path set drives
cancelHostScope's MI-awareness (FR-13): an interrupting boundary / scoped Terminate
on the MI activity tears down all N via the loop-owned group, closing the gap the
default-segment resolution (sp-<id>) leaves for a fanned-out activity. A queued
(undelivered) drain at cancellation time is already out of grp.open (removed at its
completeScope), so it counts as completed and keeps its captured slot — cancellation
touches only the truly still-open instances.
§4.6 Zero-instance and the fan-out race¶
n ≤ 0 opens no scope, creates no group, and follows the composite's outgoing once
(executeNode) — no publish (no staging). For n > 0, scopeFanOut runs to
completion on the loop goroutine (opening all N, populating grp.open, marking the
host waiting) before the loop processes any instance drain, so a fast instance
cannot resume the host before the group is fully populated — the same "single loop
goroutine removes the fan-out race" property the v.1 landing relied on, now reached
through the request handler instead of onScopeOpen.
§6 Test scenarios¶
The landed internal/instance/mi_parallel_test.go + pkg/thresher e2e are the
behavior-preserving safety net (names reconciled at Accepted). New white-box tests
cover the decorator's off-loop error paths (runMIParallel's roundtrip / drain /
cancel faults), mirroring SRD-055's mi_decorator_test.go.
| Test | Level | Covers |
|---|---|---|
TestParallelMultiInstanceRunsAll |
instance | FR-2/FR-3/FR-5 — N instances all run, complete when the last drains (existing) |
TestParallelMultiInstanceDistinctScopes |
instance | FR-3/FR-14 — N distinct -i segments, each fact carries its ordinal (existing) |
TestParallelMultiInstanceZeroCardinality |
instance | FR-3 — N ≤ 0, no scope, follow outgoing (existing) |
TestParallelMultiInstanceInputItemPerScope |
instance | FR-8 — each instance sees its own element (existing) |
TestParallelMultiInstanceAssemblesOutput / …OutputItemMissing |
instance | FR-8/FR-9 — positional output, published once (+ missing-item fault) (existing) |
TestParallelMultiInstanceCompletionCancelsRemainder |
instance | FR-10 — true cancels still-open instances, numberOfTerminatedInstances (existing) |
TestParallelMultiInstanceRuntimeAttributes / …NonBoolCompletion |
instance | FR-12 — numberOf* readable by the condition (+ non-bool fault) (existing) |
TestParallelMultiInstanceBoundaryInterruptsAll |
instance | FR-13 — interrupting boundary tears down all N (existing) |
TestParallelMultiInstance{CardinalityError,InputGetAtError,OpenScopeError,PublishError} |
instance | error paths — white-box (existing, re-pointed at the new loci) |
TestRunMIParallel{RequestError,DrainError} |
instance | FR-2/FR-6 — fan-out roundtrip on a stopped instance faults; a mid-barrier evtCh close unblocks the runner (new white-box) |
TestDrivesOwnIteration |
instance | FR-2 — parallel MI now self-drives (update: parallel → true) |
TestParallelMultiInstanceE2E |
thresher | FR-2–FR-12 end-to-end, the voting quorum (existing) |
§7 Milestones¶
| # | Scope | Files |
|---|---|---|
| M1 | The off-loop parallel driver, switch and seam removal in one commit (the driver cannot half-switch — the moment drivesOwnIteration covers parallel and executeStep routes it, the old onScopeOpen fan-out + parallelInstanceDrained become unreachable and the unused linter fails on them, so their deletion folds into the switch, as SRD-054's M1+M2 folded): runMIParallel + parallelBarrierStep; the scopeFanOut/scopeReArm/scopeComplete protocol ops + handlers (scopeExchange full-reply); the completeScope group-branch rewrite (capture + dispatch-or-queue); drivesOwnIteration covers parallel; executeStep routes it; remove the onScopeOpen fan-out dispatch; cancelScope fact ordinal (FR-14); bindParallelCounters → off-loop bindMICounters. Delete fanOutParallelMI / parallelInstanceDrained / the loop-side bindParallelCounters. Reuses openParallelInstance/captureParallelOutput/cancelOpenInstances/cancelParallelGroup. Existing parallel suite + e2e green under -race; new white-box tests cover the decorator error/guard branches. |
mi_parallel.go, scope_decorator.go, scope_runtime.go, mi.go, std_loop.go |
| M2 | CHANGELOG note; SRD-056.A §10; doc sync; Accepted flip. | docs |
§8 Cross-doc¶
- Implements ADR-025 v.2 §2.5–§2.7, §2.9, §2.12.
- Upstream ADR-017 v.1, ADR-023 v.2, ADR-018 v.1, ADR-011 v.7, ADR-010 v.2, ADR-013 v.2, ADR-001 v.6.
- Related SRD-054, SRD-055, SRD-053 (sideways, number-only per the one-shot rule). Direction: SRD → ADR / SRD only (up/sideways), version-pinned; no downward reference.
§9 Definition of Done¶
- FR-1…FR-15 wired; FR-2/FR-3 via
runMIParallel+scopeFanOut, FR-5/FR-6 via the handshake, FR-10/FR-11 viascopeComplete, FR-8 via the reused loop-side capture. - §6 tests exist and pass; the landed parallel suite + e2e + the SRD-054/SRD-055
suites stay green (NFR-5);
examples/multi-instance-parallel/runs and exits 0. - The old loop-side fan-out + barrier is removed (M2); sequential MI + Standard Loop are unaffected.
- Single-writer preserved (NFR-2); the handshake fence documented (NFR-3) and
-racegreen on the concurrent e2e. make cigreen (verify the gate's own completion markers, not a wrapper exit); CHANGELOG[Unreleased]notes the internal rework./check-srdPASS before flipping status; ADR-025 v.2 stays Draft until the whole re-landing (SRD-056.B behavior) completes.
§10 Implementation summary¶
§10.1 Stages by commit (branch feat/mi-parallel-decorator)¶
| Stage | Commit | Scope | Tests |
|---|---|---|---|
| doc | e21d7f9 |
SRD-056.A rewritten (delete-and-reuse) for the off-loop decorator | — |
| M1 | 14bdd10 |
off-loop parallel driver — runMIParallel + parallelBarrierStep; scopeOp protocol (scopeFanOut/scopeReArm/scopeComplete) + handlers + scopeExchange; completeScope group-branch (capture + dispatch-or-queue); drivesOwnIteration covers parallel; executeStep routing; cancelScope fact ordinal (FR-14); off-loop bindMICounters. Deleted fanOutParallelMI/parallelInstanceDrained/loop-side bindParallelCounters. Switch + seam removal folded. |
5 new white-box + 2 re-pointed; landed suite + e2e green under -race; make ci diff-cov 100% |
§10.2 Empirical findings vs the draft¶
- The cap-1 handshake held exactly as §4.2 predicted. The per-drain
dispatch-or-queue +
scopeReArmserialized N concurrent drains onto the runner's single-slot park with no loop stall;-raceon the concurrent voting e2e is clean. - The switch could not half-land — M1+M2 folded. The moment
drivesOwnIterationcovered parallel andexecuteSteprouted it,fanOutParallelMI/ theonScopeOpenbranch /parallelInstanceDrainedwent unreachable, and theunusedlinter fails on a dead unexported method — so the seam deletion had to land in the switch commit (the same fold SRD-054 took). §7 M2 collapsed to docs. - The reused primitives were not counted as changed. Although
mi_parallel.gowas rewritten wholesale,git diffrecognizedopenParallelInstance/captureParallelOutput/cancelOpenInstances/cancelParallelGroupas moved-identical, so covercheck scored only the genuinely new driver/handler lines — reaching 100% needed 5 focused white-box tests plus re-pointing the two old white-box tests (PublishError→handleComplete,OpenScopeError→handleFanOut) at the new loci. runMIParallelsplit for cognitive complexity. The inline barrier loop hit gocognit 32 (> 30); extractingparallelBarrierStep(one delivered drain: bind, eval, finalize or re-arm) brought both functions under the bar and reads cleaner.
§10.3 Backlog¶
- SRD-056.B — the Multi-Instance
behaviorthrow (the original trigger for the decorator rework) is now an ordinary off-loop throw atparallelBarrierStep's completion point (and sequential MI's), deterministic and boundary-catchable. - ADR-025 v.2 flips Accepted once SRD-056.B lands — the whole §2.12 re-landing complete.
Open questions¶
None.