SRD-032 — Snapshot start-events precompute and instance-scope encapsulation¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-29 |
| Owner | Ruslan Gabitov |
| Implements | ADR-012 v.1 §2.5 Execution layering |
Two behavior-preserving internal refactors in the instance/registry area, landed together because both shrink the same overgrown surfaces:
- Snapshot start-events precompute (M1). Compute the instantiating start
triggers once, during
snapshot.New's single node pass, into an immutableSnapshotsection — soscanInstantiatingStartsstops re-walking every node on a second O(nodes) pass. instanceScopeencapsulation (M2). Pull the data-plane wiring out of the 1646-lineinternal/instance/instance.gointo a smallinstanceScopevalue, closing the audit's literal §2.3 "Scope role" line and honoring one-entity-per-file.
No public API change; no behavior change.
1. Background & §2.3 reconciliation (verified against the code)¶
The 2026-06-11 architecture audit (§2.3) flagged internal/instance/instance.go
as an 852-line god-object and recommended "extract InstanceScope to fix the
addData race." That first step is already done:
scope.Scope(internal/scope/scope.go:27) is a standalone type owning the scope map under its own mutexm sync.Mutex(:33); the class comment (scope.go:15-19) states no compound operation spans lock acquisitions.- Instance no longer implements
scope.Scope— it holdsdataPlane *scope.Scopeand builds it viascope.New(inst.rootScope, inst)(instance.go:527), passing itself as the narrow 2-methodscope.RuntimeVarsSupplier(RuntimeVarinstance.go:1459,RuntimeVarNames:1507). The oldaddData/getDataInstance methods were removed in SRD-007 (data-plane/frames). TheaddDatarace is structurally gone — Instance cannot touch the scope map except throughscope.Scope's atomic methods.
So §2.3's bug-half is closed. What remains is the god-object size:
instance.go has since grown to 1646 lines (≈2× the audited size) as
SRD-022/025/027/028/029 piled the event loop + token/track tracking onto it.
That decomposition (the event-loop extraction) is out of scope here — ADR-012
v.1 §2.5 (:138) already names "Splitting the Instance god-object (audit
2.3)" as a deferred sibling refactor, and it relocates ADR-001's
single-goroutine ownership model, so it needs its own ADR + SRD + heavy -race
validation (see §7 Non-goals). This SRD does the two safe, behavior-preserving
pieces: the snapshot precompute and the scope-wiring encapsulation.
1.1 The snapshot two-pass redundancy¶
Instantiating start triggers are found by two O(nodes) passes today:
- Pass 1 —
snapshot.New(snapshot/snapshot.go:71-105): loops everyp.Nodes(), clones each intos.Nodes, thenwireClonedGraph(:125) populates each clone'sIncoming(). - Pass 2 —
scanInstantiatingStarts(thresher/instance_starter.go:109): re-walkss.Nodesand, per node withlen(n.Incoming()) == 0andisInstantiatingStartNode(:212), inspects eacheDef— keeping a message (correlationKeyOf,:197) or signal definition (otherscontinue), resolving the Event-Based-gateway arm (ArmFor/ParallelStartprobes,:129-158) — and builds an*instanceStarter(:160).
The expensive part (the scan + predicate + arm/corr-key resolution) is
engine-agnostic and produces, per qualifying (node, eDef), exactly the
triple (startNode, eDef, corrKey). Only instanceStarter.thr (:24) and its
fresh id are engine-bound — so the starter itself cannot live on the
engine-agnostic Snapshot, but the triple can.
2. Requirements¶
Functional¶
- FR-1 — Snapshot carries its instantiating starts.
Snapshotgains an immutable sectionInstantiatingStarts []InstantiatingStart, whereInstantiatingStartis a plain descriptor{ StartNode flow.Node; EventDef flow.EventDefinition; CorrelationKey *bpmncommon.CorrelationKey }. It is populated bysnapshot.NewafterwireClonedGraph(incoming-flow info only exists post-wiring), applying the same predicatescanInstantiatingStartsuses today. It holds raw model descriptors only — never a*Thresheror*instanceStarter. - FR-2 —
scanInstantiatingStartsbecomes a thin adapter. It iteratess.InstantiatingStartsand wraps each descriptor into an*instanceStarter(bindingthr+foundation.GenerateID()). The node re-scan, theisInstantiatingStartNode/correlationKeyOfprobes, and the arm-resolution move into the snapshot package; the thresher keeps only the engine binding. The produced starters are identical (same start nodes, eDefs, corr-keys; ids stay fresh per build). - FR-3 —
Cloneshares the section by reference.InstantiatingStartsis a definition-level concern read once by the Thresher at registration against the template snapshot; instances never re-scan starts.Clone(snapshot.go:165) shares it by reference alongsideProperties/CorrelationKeys(the immutable header), or omits it — instances do not read it. - FR-4 —
instanceScopeowns the data-plane wiring. A newinternal/instance/scope.godefines an unexportedinstanceScopevalue owningdataPlane *scope.Scope,rootScope scope.DataPath, andreader service.DataReader, plus the data-plane mechanics (loadPropertiesandbindEventPayload, which callplane.Commitinternally).Instanceholdssc instanceScopeand delegates. Thescope.RuntimeVarsSuppliermethods (RuntimeVar/RuntimeVarNames) stay onInstance— they read instance lifecycle state (STATE,TRACKS_CNT,STARTED_AT) — andInstanceis still the supplier passed when the plane is built. NoinstanceScope → Instanceback-reference is introduced. - FR-5 — Behavior preserved. Registration, auto/manual start, supersession, message/signal instantiation, Event-Based start (exclusive + parallel), correlation, and the data plane behave exactly as before. This is an internal refactor.
Non-functional¶
- NFR-1 — One fewer O(nodes) pass. Registration computes the instantiating
starts once (in
New) instead of twice; the thresher does O(starts) wrapping, not O(nodes) scanning. - NFR-2 — No race regression.
go test -race ./...stays green; the data-plane concurrency (the audit'saddDataclass) stays fixed — a-racetest pins two forked tracks committing concurrently. - NFR-3 — Coverage. Diff-coverage ≥ project standard (95%, aim 100%) on every
touched file;
make cigreen; the 18 examples still run. - NFR-4 — No new package cycles.
internal/instance/snapshotmay importpkg/model/events/msgflowfor the moved probes (no cycle —eventsdoes not importsnapshot);pkg/model ↛ internaldepguard stays clean.
3. Models¶
3.1 The Snapshot section (internal/instance/snapshot/snapshot.go)¶
// InstantiatingStart is one resolved instantiating start trigger of a process,
// precomputed at snapshot build time: the node a born instance runs from, the
// event definition that fires it, and the correlation key (nil = name-match
// only). Raw model descriptors — engine-agnostic, no *Thresher.
type InstantiatingStart struct {
StartNode flow.Node
EventDef flow.EventDefinition
CorrelationKey *bpmncommon.CorrelationKey
}
type Snapshot struct {
foundation.ID
ProcessID string
ProcessName string
Nodes map[string]flow.Node
Flows map[string]*flow.SequenceFlow
Properties []*data.Property
CorrelationKeys []*bpmncommon.CorrelationKey
// InstantiatingStarts is the precomputed set of instantiating start triggers
// (FR-1), populated in New after wiring; shared by Clone (definition-level).
InstantiatingStarts []InstantiatingStart
}
The predicate/arm/corr-key logic relocates from instance_starter.go:113-158 into
a snapshot-package helper called by New after wireClonedGraph; the structural
probes isInstantiatingStartNode and correlationKeyOf move with it (pure
flow/bpmncommon interface probes, no thresher dependency).
3.2 The thin thresher adapter (pkg/thresher/instance_starter.go)¶
func scanInstantiatingStarts(s *snapshot.Snapshot, thr *Thresher) []*instanceStarter {
starters := make([]*instanceStarter, 0, len(s.InstantiatingStarts))
for _, is := range s.InstantiatingStarts {
starters = append(starters, &instanceStarter{
thr: thr,
snapshot: s,
startNode: is.StartNode,
eDef: is.EventDef,
corrKey: is.CorrelationKey,
id: foundation.GenerateID(),
})
}
return starters
}
3.3 instanceScope (internal/instance/scope.go, new)¶
// instanceScope owns the instance's data-plane wiring (the scope.Scope plane,
// its root path, and the read view), extracted from Instance to keep the role
// in one file. The scope map's lock lives in scope.Scope (the addData race is
// already fixed there); this type adds no lock of its own.
type instanceScope struct {
plane *scope.Scope
root scope.DataPath
reader service.DataReader
}
Instance gains sc instanceScope; loadProperties and bindEventPayload (both
of which commit into plane) move onto it; Instance retains
RuntimeVar/RuntimeVarNames (the supplier) and passes itself when
instanceScope builds the plane.
4. Analysis¶
4.1 Precompute in New, after wiring (decided)¶
The predicate needs Incoming(), which is populated only by wireClonedGraph
(snapshot.go:125). So the precompute runs as a short post-wire pass over
s.Nodes (or folds into one). It cannot run inside the existing clone loop
(:71) because incoming flows aren't wired yet there. It is still one build,
not a separate registration pass.
4.2 Raw descriptors, not starters (decided)¶
instanceStarter binds *Thresher (:24) and calls s.thr.resolveAndLaunch
(:63); the snapshot is engine-agnostic (ADR-019 §2.3) and must not import
pkg/thresher. So the section stores the engine-agnostic triple; the thresher
adds the binding. This keeps the dependency direction intact.
4.3 Clone shares by reference (decided)¶
The instantiating-starts list is read once at RegisterProcess against the
template snapshot; an instance is already launched (it consumes a start node by
id, it does not discover starts). So the section is definition-level — it lives on
the immutable header and Clone shares it by reference (like Properties/
CorrelationKeys, snapshot.go:171-172). The descriptors point into the
template's s.Nodes, exactly as the starters do today, so the pointers stay
template-bound and valid (no aliasing into per-instance clones).
4.4 The instanceScope extraction is a tidy-up, not a fix (decided)¶
The audit framed §2.3's scope step as a race-fix; that race was already closed by
SRD-007. So M2 is encapsulation only — pulling the data-plane wiring out of
the 1646-line file. The RuntimeVarsSupplier methods stay on Instance because
they read lifecycle state, which avoids an instanceScope → Instance back-ref;
the cleaner split is "data-plane mechanics in instanceScope, instance-state
glue on Instance."
4.5 What stays the same (decided)¶
No public API changes; no new locks; the event loop, token tracking,
EventProducer delegation, message correlation, and observation all stay on
Instance (their decomposition is the deferred event-loop ADR). The snapshot's
existing seExists/eeExists/instStartExists validation booleans (:62-64)
are unaffected.
5. API / contract surface¶
No public API change. Snapshot gains an exported field
InstantiatingStarts and the exported descriptor type InstantiatingStart (the
snapshot package is internal/, so this is not part of the library's public
surface). scanInstantiatingStarts, instanceScope, and the moved probes are
unexported. Thresher's registration/start API is untouched; the engine behaves
identically.
6. Test scenarios¶
| # | Scenario | FR | Where |
|---|---|---|---|
| T-1 | New precomputes InstantiatingStarts matching the old scan for: plain message StartEvent; signal StartEvent; instantiate ReceiveTask; non-instantiating node excluded; node-with-incoming excluded |
FR-1 | snapshot/*_test.go |
| T-2 | Event-Based start: exclusive-start resolves each arm as the start node + arm corr-key; parallel-start keeps the gate as start node + gate corr-key | FR-1 | snapshot/*_test.go |
| T-3 | scanInstantiatingStarts output is equivalent (same start nodes / eDefs / corr-keys, ids fresh) to the pre-refactor scan — characterization test |
FR-2 | thresher/*_internal_test.go |
| T-4 | Clone shares InstantiatingStarts; a registered process still launches event-born instances after an instance is cloned (no pointer aliasing) |
FR-3 | thresher/*_test.go |
| T-5 | RuntimeVar still serves STARTED_AT/STATE/TRACKS_CNT and RuntimeVarNames lists them, through the instanceScope wiring |
FR-4 | instance/*_internal_test.go |
| T-6 | Data-plane -race: two forked tracks Commit concurrently — detector clean (the addData class stays fixed) |
NFR-2 | instance/*_test.go |
| T-7 | Existing thresher start suites (StartProcess/StartLatest/StartVersion, message/signal instantiation, event-based start) stay green |
FR-5 | existing |
7. Milestones & non-goals¶
- M1 — Snapshot start-events precompute. FR-1/FR-2/FR-3: the
Snapshotsection + descriptor, the post-wire precompute inNew, the moved probes, the thinscanInstantiatingStarts,Clonesharing. T-1..T-4, T-7. - M2 —
instanceScopeencapsulation. FR-4: the newscope.go, the data-plane wiring moved offInstance, delegation kept behavior-identical. T-5, T-6.
Non-goals (deferred to a future ADR + SRD): the event-loop / token-tracking
extraction (the real god-object mass — ADR-012 §2.5) and the track-package split.
This SRD shrinks instance.go only modestly (the scope wiring); the 1646-line
file is not decomposed here.
8. Cross-doc¶
- Implements ADR-012 v.1 §2.5 — advances the deferred Instance-decomposition item (the scope-wiring slice; the event-loop split stays deferred there).
- ADR-010 v.2 — the data plane /
scope.ScopetheinstanceScopewraps. - ADR-009 v.1 — per-instance
Clone; grounds the §4.3 share-by-reference decision. - ADR-019 v.1 §2.3 — the snapshot is the engine-agnostic isolation boundary (why the section holds raw descriptors).
- SRD-007 — closed §2.3's
addDatarace (data plane / frames); number-only ref. - SRD-024/025/026 — the Event-Based-start / signal-start semantics the precompute preserves; number-only refs.
9. Definition of Done¶
- [x] FR-1..FR-5 wired; covered by T-1..T-7.
- [x]
Snapshot.InstantiatingStartspopulated inNewpost-wiring; probes moved to the snapshot package;scanInstantiatingStartsis the thin adapter;Cloneshares the section. - [x]
instanceScopeowns the data-plane wiring;Instancedelegates; no back-reference;RuntimeVar/RuntimeVarNamesbehavior unchanged. - [x]
/check-styleclean;/check-srdPASS. - [x]
make cigreen incl. diff-coverage ≥95% on touched files (NFR-3) andgo test -race ./...(NFR-2); 18 examples run. - [x] §8 cross-doc pins consistent (up/sideways, versioned); no downward ref.
- [x] §10 filled; status flipped Draft → Accepted.
10. Implementation summary¶
Landed on feat/srd-032-snapshot-starts-scope over master c1319cc in two
milestones plus the doc commit.
Commits
| Commit | Scope |
|---|---|
c721c41 |
doc — SRD-032 |
d55162a |
M1 — snapshot start-events precompute (FR-1/2/3) |
d34d858 |
M2 — instanceScope encapsulation (FR-4) |
M1 — files
internal/instance/snapshot/instantiating_starts.go(new) —InstantiatingStartdescriptor;discoverInstantiatingStarts(the predicate / Event-Based arm / correlation-key loop lifted from the thresher); the movedisInstantiatingStartNodeandcorrelationKeyOfprobes.internal/instance/snapshot/snapshot.go—Snapshot.InstantiatingStartsfield; populated inNewafterwireClonedGraph; shared by reference inClone.pkg/thresher/instance_starter.go—scanInstantiatingStartsreduced to a thin adapter wrapping each descriptor into an*instanceStarter; the two probes deleted (moved to the snapshot package).internal/instance/snapshot/instantiating_starts_test.go(new) — T-1 (each start kind / exclusions), T-2 (Event-Based arm: exclusive→arm, parallel→gate), T-4 (Cloneshare + no-alias).
M2 — files
internal/instance/scope.go(new) —instanceScope{plane, reader, root}withload(build plane + commit properties, supplier passed in — noInstanceback-ref),openFrame(consolidates the threescope.NewFramesites),bindEventPayload.internal/instance/instance.go— three data-plane fields collapsed to onesc instanceScope;loadProperties/bindEventPayloadremoved; call sites +DataReaderdelegate toinst.sc.internal/instance/{activation.go,track.go}— the guard frame and per-node execution frame now go throughinst.sc.openFrame.
Verification (V-results)
make ciexit 0 (tidy-check · lint 0 issues · build-all ·-racetests · diff-coverage · govulncheck) across all modules.- Combined diff-coverage 96.0% of 124 changed lines — PASS (min 95%):
snapshot.go100%,track.go100%,instance_starter.go100%,instantiating_starts.go98.2%,scope.go90.0%. The 5 uncovered lines are unreachable defensive error-returns (scope.New/openFramecannot fail on a valid-by-construction path; the non-EventNodeguard) — the branch ceiling. - Behaviour preserved: 156 thresher + 180 instance tests pass unchanged (characterization); all 18 examples run end-to-end (exit 0).
/check-srdPASS (HEADd34d858).
Deferred (named in §7 Non-goals): the real Instance god-object decomposition
(event-loop / token-tracking extraction, ADR-012 §2.5) and the track-package
split — each its own future ADR + SRD.
Open questions¶
None.