SRD-019 — Instance control & engine lifecycle: Cancel, Shutdown, waiter drain¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-18 |
| Owner | Ruslan Gabitov |
| Implements | ADR-013 v.1 Instance Observability & Control |
This SRD lands the control + engine-lifecycle slice of ADR-013 v.1
(§2.3 coarse control, §2.5 engine lifecycle) and realizes the still-open part of
ADR-006 v.1 §2.5 — the
WaitGroup-synchronized EventHub waiter shutdown that Thresher.Shutdown
consumes. It is the sibling of the already-merged observe slice
(SRD-018 v.1, the pull handle + observer
event stream). Coarse, explicit, engine-mediated control: InstanceHandle.Cancel
(+ reserved Suspend/Resume), Thresher.Shutdown, Thresher.Forget, and the
already-present UnregisterProcess documented + lightly hardened.
1. Background & motivation¶
1.1 Current state (verified against the code)¶
- No way to cancel a running instance.
InstanceHandle(pkg/thresher/handle.go:16) is read-only (SRD-018:ID/State/Tokens/History/Data/WaitCompletion/Observe); it has noCancel. The instance has no own cancel and noCancel()/Terminate()method — termination is driven only by ctx-cancel:Instance.Run(ctx)storesinst.ctx = ctx(internal/instance/instance.go:558-585); the loop observesctx.Done()→stopAll()→setState(Terminating)→ tracks stop →setState(Terminated)+close(loopDone)(instance.go:603-688). The only holder of an instance's cancel is the Thresher:launchInstancedoesctx, cancel := context.WithCancel(t.ctx)and storesinstanceReg{stop: cancel, …}(thresher.go:679,696;instanceRegatthresher.go:97-102) — never invoked today. - No
Thresher.Shutdown. TheStateenum isInvalid / NotStarted / Started / Paused(thresher.go:58-95) — noStopped.Run(ctx)setsStarted, callseventHub.Start(ctx)synchronously then spawnsgo eventHub.Run(ctx)(thresher.go:245-299);StartProcessguardsst != Started(thresher.go:623-647). There is no graceful stop: no ThresherShutdown/Stop. - EventHub has no shutdown and no
WaitGroup(the open ADR-006 §2.5 item).EventHub(internal/eventproc/eventhub/eventhub.go:31-38) holdswaiters map[string]EventWaiter+m sync.RWMutex+started; nosync.WaitGroup, noShutdown/Close.Run(ctx)just blocks on<-ctx.Done()(eventhub.go:86-96). Each waiter'sService(ctx)spawns a background goroutine (timer:waiters/timer.go:245-276) andStop()signals it (timer.go:369-385). Nothing waits for those goroutines to exit on stop → a stop leaves live goroutines. - Sole-hub waiter ownership is already in place (FIX-003): a waiter reports a
terminal fire via
hub.WaiterFired(eDefID)and does not remove itself; the hub owns removal (eventhub.go:343WaiterFired,:318RemoveWaiter,:214UnregisterEvent). The double-close / TOCTOU / double-removal defects (audit 1.3/1.5/2.5-ownership) are fixed; what remains of ADR-006 §2.5 is the synchronized drain. UnregisterProcessexists but doesn't consider live instances (thresher.go:450-491): it removes thesnapshots/startersentries and unregisters starters from the hub, with no check for running instances of that process. Thesnapshotsmap is freed only here (audit 2.2). Theinstancesmap (thresher.go:651Instance(id)lookup) is never pruned — completed instances accumulate.
1.2 Problem¶
A host can observe a running instance (SRD-018) but cannot act: no cancel, no graceful engine shutdown, no way to release a finished instance, and the EventHub leaks waiter goroutines on stop (ADR-006 §2.5 open). This SRD adds the coarse, explicit, engine-mediated control + lifecycle ADR-013 §2.3/§2.5 decided, and the waiter drain ADR-006 §2.5 needs.
2. Decision¶
InstanceHandle.Cancel(ctx)requests termination and blocks (ctx-bounded) until the instance is terminal. The instance gains its own cancel:Runderivesinst.ctx, inst.cancel = context.WithCancel(ctx)and exposesCancel(); the handle calls it, then waits on the existingDone()/loopDone. Termination still runs the proven ADR-001 cascade (Active→Terminating→Terminated).Suspend/Resumeare declared but reserved (return a sentinel error) — they need the deferredPausedsubsystem (ADR-013 §2.3).Thresher.Shutdown(ctx)is graceful, engine-mediated: flip to a new terminalStoppedstate (soStartProcess/RegisterProcess/Runreject), cancel every running instance and wait (ctx-bounded) for them to settle, then drain the EventHub.EventHub.Shutdown(ctx)(realizes ADR-006 §2.5): stop every waiter and wait for their goroutines to exit via a hubsync.WaitGroup, bounded by ctx; remove every waiter from the registry even if itsStop()errors (no leak); unblock the hubRun.- Instance discovery + release. A single
Instances(filter)(InstancesAll/InstancesRunning/InstancesCompleted) lists tracked instance ids by liveness (the host reads each one's state viaInstance(id));Thresher.Forget(ids ...string)releases terminal instances in bulk — all-or-nothing (a live or unknown id is an error; none are removed on error) — the explicit release for the kept-for-observability instances. Because event-start registrations have no instance to look up yet, they get their own listing —Starters() []StarterInfo— a read-only projection of the registered starters (process + trigger event + start mode).UnregisterProcesskeeps its current behaviour (remove definition + starters; live instances keep running against their built snapshot) — now documented, with a note that finished instances are released viaForget/Shutdown.
Control is coarse and visible (operator action through the engine's state machine), never a hidden per-node listener (ADR-013 §4, ADR-011).
3. Functional requirements¶
- FR-1 — instance cancel.
InstanceHandle.Cancel(ctx context.Context) (InstanceState, error)requests termination and blocks until the instance reaches a terminal state orctxis done; returns the terminal state (+ctx.Err()on timeout). Idempotent (a secondCancel, orCancelof an already-terminal instance, is a no-op returning the terminal state). - FR-2 — instance self-cancel hook.
internal/instance:Runderives an internal cancellable context (inst.cancel);Instance.Cancel()cancels it (idempotent), driving the loop's existingctx.Done()cascade toTerminating→Terminated. The Thresher's parent ctx (instanceReg.stop, engine ctx) still cascades. - FR-3 — reserved suspend/resume.
InstanceHandle.Suspend(ctx)/Resume(ctx)exist and return a stableErrNotImplementedsentinel (reserved for thePausedsubsystem; the contract is fixed now, ADR-013 §2.3). - FR-4 — engine graceful shutdown.
Thresher.Shutdown(ctx context.Context) error: (a) transition toStopped(so furtherStartProcess/RegisterProcess/Runare rejected); (b) cancel every running instance and wait (ctx-bounded) for them to settle; (c) callEventHub.Shutdown(ctx); return the first error /ctx.Err()on deadline. Idempotent. - FR-5 —
Stoppedstate. AddStoppedto the ThresherStateenum;StartProcess(thresher.go:623),RegisterProcess, andRunreject whenStopped. - FR-6 — EventHub synchronized shutdown.
EventHub.Shutdown(ctx context.Context) errorstops every registered waiter, waits for theirServicegoroutines to exit via a hubsync.WaitGroupbounded byctx, and removes every waiter from the registry even when aStop()returns an error (logged, never leaked). It unblocks the hubRunand rejects further registration. The waiterServicegoroutine signals exit to the hub (it already holds ahubreference and callsWaiterFired). - FR-7 — forget finished instances (batch).
Thresher.Forget(ids ...string) errorremoves the listed terminal instances from theinstancesmap, all-or-nothing: it first validates every id (known and terminal) and removes none if any is unknown or still-live, returning an error naming the offending id.Forget("x")(single) andForget(Instances(InstancesCompleted)...)(sweep) both work. - FR-7a — instance discovery. A single
Thresher.Instances(filter InstanceFilter) []stringlists tracked instance ids by anInstanceFilterenum:InstancesAll(every tracked),InstancesRunning(non-terminal — Created/Active/ Terminating),InstancesCompleted(terminal — Completed/Terminated; the list that feeds batchForget). Read-only, snapshot-consistent under the engine mutex. - FR-7b — starter discovery.
Thresher.Starters() []StarterInfolists the registered event-start registrations (thestartersmap) — each a process awaiting an event, with no instance yet, so they cannot appear underInstances.StarterInfois a read-only projection of the internalinstanceStarter: the process it would instantiate, the start node fired on a match, and the message it waits on. A manual-start process registers no starter, so every listed starter is auto-start (noManualfield needed). - FR-8 — unregister with live instances.
UnregisterProcess(thresher.go:450) removes the definition + starters and leaves any live instances running (current behaviour, now documented); the per-processsnapshotsentry is freed.
4. Non-functional requirements¶
- NFR-1 — coarse, engine-mediated control. Cancel/Shutdown act through the
instance/engine state machines (ctx-cancel cascade,
setState), never a back door; no mutating per-node listener (ADR-013 §4). - NFR-2 — no goroutine leak on shutdown. After
EventHub.Shutdownreturns (withinctx), no waiterServicegoroutine outlives the hub; a failedStop()still removes the waiter (NFR realizes ADR-006 §2.5). - NFR-3 — bounded + idempotent.
Cancel/Shutdownhonourctxdeadlines and are idempotent (safe to call twice / after terminal); report stragglers rather than hang. - NFR-4 — concurrency-safe. Map mutations (
instances/snapshots/waiters) stay under their existing mutexes; the cancel cascade stays the single-owner loop model (ADR-001); no new lock on the observation/execution hot path. - NFR-5 — coverage. Touched files finish ≥80% (target 100%) diff-coverage;
make cigreen (-raceespecially — shutdown is concurrency-heavy).
5. Path analysis (alternatives)¶
- Cancel via instance self-cancel (chosen) vs the handle holding
instanceReg.stop. Chosen: the instance owns its cancel (inst.cancelderived inRun),Cancel()triggers it. The handle (which holds*instance.Instance) callsinst.Cancel()— no thresher round-trip, termination is handle-local, and the engine's parent-ctx cascade is untouched. Rejected handle-holds-thresher-cancel: couples the handle to thresher internals and needs a lookup. Cancel(ctx)request-and-wait (chosen) vs fire-and-forget. Chosen: request + ctx-bounded wait for terminal, returning the state — symmetric withWaitCompletion, gives the caller confirmation. Fire-and-forget would force a separateWaitCompletion.- EventHub drain via hub
WaitGroup+ waiter goroutine-exit signal (chosen) vs synchronousStop()(block until the goroutine exits). Chosen: the hubAdds when it starts a waiter and the waiter's goroutineDones on exit (mirrors the existing waiter→hubWaiterFiredcallback);ShutdownStops all thenWaits bounded by ctx. Rejected synchronous-Stop: changes every waiter'sStop()semantics and serialises the drain. - Instance retention: keep +
Forget(chosen) vs auto-evict on completion vs keep forever. Chosen (product decision): completed instances stay looked-up-able (Instance(id)/Observe/Statework post-completion — ADR-013's live in-memory model), with an explicitForget(id)+Shutdownto release. Rejected auto-evict: loses post-completion observation; rejected keep-forever: unbounded with no release. Stoppedas a new terminal state (chosen) vs reusingNotStarted. Chosen: a distinct terminalStoppedso a shut-down engine is not mistaken for a re-runnable fresh one.- Discovery: one
Instances(filter)(chosen) vs three methods (Instances/RunningInstances/CompletedInstances). Chosen: a single function with a namedInstanceFilterenum (InstancesAll/InstancesRunning/InstancesCompleted) — a 3-way filter, not a boolean, so one function reads cleaner than three and extends if liveness categories grow. Starters are listed separately viaStarters() []StarterInforather than as a fourthInstanceFiltervalue, because a starter is not an instance (no instance id, no lifecycle state) — folding it intoInstances()would mean returning ids that don't resolve viaInstance(id). Forget(ids ...string)batch, all-or-nothing (chosen) vs single-id / partial. Chosen: variadic batch (single-id still works) with validate-all-then-remove, so a bad id in a sweep leaves the map untouched and the error is actionable.UnregisterProcessallows live instances (chosen) vs reject / cancel-them. Chosen (product decision): remove definition + starters, leave live instances running against their built snapshot; simplest, no coupling of unregister to termination. The host usesShutdown/Cancel/Forgetfor instance lifecycle.
6. API (public surface, pkg/thresher)¶
// On the existing read-only handle (SRD-018), control is added:
func (h *InstanceHandle) Cancel(ctx context.Context) (InstanceState, error)
func (h *InstanceHandle) Suspend(ctx context.Context) error // reserved -> ErrNotImplemented
func (h *InstanceHandle) Resume(ctx context.Context) error // reserved -> ErrNotImplemented
// Engine lifecycle:
func (t *Thresher) Shutdown(ctx context.Context) error
// Instance discovery + release:
type InstanceFilter uint8
const (
InstancesAll InstanceFilter = iota // every tracked instance
InstancesRunning // non-terminal (Created/Active/Terminating)
InstancesCompleted // terminal (Completed/Terminated)
)
func (t *Thresher) Instances(filter InstanceFilter) []string
func (t *Thresher) Forget(ids ...string) error // batch, terminal-only, all-or-nothing
// Event-start registrations (no instance yet). A manual-start process registers
// no starter, so a listed starter is always auto-start (hence no Manual field).
type StarterInfo struct {
ProcessID string // the process a matching event instantiates
StartNode string // the start node fired on a match
Trigger string // the message the starter waits on
}
func (t *Thresher) Starters() []StarterInfo
// New thresher state (terminal):
const Stopped State = /* iota after Paused */
// Reserved-feature sentinel:
var ErrNotImplemented = errs.New(/* "feature reserved, not yet implemented" */)
Internal support: internal/instance — Run derives inst.cancel; Instance.Cancel()
(idempotent). internal/eventproc/eventhub — EventHub.Shutdown(ctx) error + a hub
sync.WaitGroup; waiters signal Service-goroutine exit to the hub (e.g. a
hub.waiterDone() callback paired with wg.Add(1) at registerWaiter's
w.Service(eh.ctx)).
7. Test plan¶
TestInstanceHandleCancel—Cancel(ctx)on a parked/long-running instance drives it toTerminated; a secondCanceland aCancelof a completed instance are no-ops returning the terminal state (FR-1, FR-2).TestCancelCtxBounded—Cancelwith a short ctx against an instance that won't settle returnsctx.Err()and a non-terminal state (FR-1).TestSuspendResumeReserved— both returnErrNotImplemented(FR-3).TestThresherShutdown—Shutdown(ctx)cancels running instances, flips toStopped, and closes the hub;StartProcess/RegisterProcess/Runthen reject; idempotent on a second call (FR-4, FR-5).TestShutdownDrainsWaiters(-race) — with a registered timer waiter,EventHub.Shutdownreturns only after the waiter goroutine exits (no leak); a waiter whoseStop()errors is still removed (FR-6, NFR-2).TestForget—Forget(ids...)removes completed instances in bulk (subsequentInstance(id)→ false); is all-or-nothing — a batch containing a live or unknown id removes none and errors naming it (FR-7).TestInstancesFilter—Instances(InstancesAll/InstancesRunning/InstancesCompleted)returns the right id sets across a run (a parked instance under Running, a finished one under Completed, both under All);Forget(Instances(InstancesCompleted)...)sweeps the finished ones (FR-7a).TestStarters— a process registered with a message-start event appears inStarters()with its process id + trigger; manual-start mode is reflected; a process with no event-start has no starter entry (FR-7b).TestUnregisterProcessWithLiveInstance—UnregisterProcesssucceeds with a live instance still running; the definition is gone but the instance completes (FR-8).- Internal
internal/eventproc/eventhub+internal/instanceunits forEventHub.Shutdown/Instance.Cancel(cross-package coverage attribution).
8. Cross-document consistency¶
- Implements ADR-013 v.1 §2.3
(coarse control), §2.5 (engine lifecycle:
Shutdown/UnregisterProcess). - Realizes ADR-006 v.1 §2.5 — the
WaitGroup-synchronized waiter shutdown + no-leak-on-Stop(the sole-ownership half already landed via FIX-003). - ADR-001 v.5 — the generic ctx-cancellation
cascade
Canceltriggers (Active→Terminating→Terminated). - ADR-002 v.2 — the §4.7 public-API surface these methods join.
- SRD-018 v.1 — the observe slice this builds the control surface onto (sibling).
- References up/sideways, version-pinned; no downward refs (ADR-013/ADR-006 do not cite SRD-019).
9. Definition of Done¶
- FR-1…FR-8 wired and exercised by the §7 tests (incl. a
-racewaiter-drain test). InstanceHandle.Cancel/Suspend/Resume,Thresher.Shutdown/Forget, theStoppedstate + guards, andEventHub.Shutdown+ the hubWaitGroupall present.- No waiter goroutine outlives
EventHub.Shutdown(NFR-2) — proven under-race. make cigreen (tidy, lint incl. fieldalignment, build,-race, diff-coverage ≥95, govulncheck); touched files ≥80% (target 100%).- All 9 examples smoke-run exit 0 (control/shutdown didn't regress the happy path).
- §10 filled; status → Accepted; RU twin added; linked docs synced.
10. Implementation summary¶
Landed on feat/instance-control-lifecycle (off master): four milestones,
each a single commit, plus a tangential example enhancement.
10.1 Milestones (commits)¶
| M | Commit | Scope | Tests |
|---|---|---|---|
| M1 | a35012a |
Instance cancel: inst.cancel derived in Run, Instance.Cancel(); InstanceHandle.Cancel(ctx) (request + ctx-bounded wait, idempotent); reserved Suspend/Resume → ErrNotImplemented |
TestInstanceHandleCancel, TestCancelCtxBounded, TestSuspendResumeReserved, TestInstanceCancel (internal) |
| M2 | 0c1ffab |
EventHub.Shutdown(ctx) waiter drain: EventWaiter.Done() + hub Shutdown; timer/message waiters close a done channel on goroutine exit; started bool replaced by a hubState enum |
TestEventHubShutdownDrainsWaiters (-race), TestShutdownRemovesOnStopError, TestShutdownCtxBounded; waiter Done() checks in timer/message tests |
| M3 | 75dcde6 |
Thresher.Shutdown(ctx) + terminal Stopped state; Run derives t.engineCancel (the cascade master switch); guards in Run/StartProcess/RegisterProcess |
TestThresherShutdown, TestThresherShutdownCtxBounded |
| M4 | f3ee4a3 |
Discovery + release: Instances(filter)+InstanceFilter, Forget(ids...) (batch all-or-nothing), Starters()+StarterInfo; UnregisterProcess doc note |
TestInstancesFilter, TestForget, TestStarters, TestUnregisterProcessWithLiveInstance |
Plus ba32f4e — the basic example reads a property + RUNTIME/STARTED_AT
through the gofunc's DataReader (split per the >80-line example rule);
tangential to this SRD, bundled in this branch's example phase.
10.2 Key files¶
pkg/thresher/handle.go—Cancel/Suspend/Resume+ErrNotImplemented.pkg/thresher/thresher.go—Stoppedstate,engineCancel,Shutdown, guards.pkg/thresher/discovery.go(new) —InstanceFilter,Instances,Forget,StarterInfo,Starters.internal/instance/instance.go—inst.cancel+Instance.Cancel().internal/eventproc/eventproc.go—EventWaiter.Done()+EventHub.Shutdownon the interfaces.internal/eventproc/eventhub/eventhub.go—hubStateenum,EventHub.Shutdown, registration guard.internal/eventproc/eventhub/waiters/{timer,message}.go—donechannel +Done().
10.3 Verification¶
make cigreen: tidy, lint (incl. fieldalignment), build,-race, diff-coverage 96.8% of 189 changed lines (≥95), govulncheck.- Touched-function coverage all ≥80% (most 100%;
Shutdown96%,Run87%,RegisterProcess96%). - All 9 examples smoke-run exit 0.
- NFR-2 (no waiter goroutine outlives the hub) proven under
-race.
10.4 Deltas vs the draft¶
StarterInfofields. The draft first sketched{ProcessID, Trigger, Manual}; shipped as{ProcessID, StartNode, Trigger}— a manual-start process registers no starter, so a listed starter is always auto-start (noManualfield). §6/FR-7b reconciled inf3ee4a3.- Waiter-drain test names. §7's single
TestShutdownDrainsWaiterslanded asTestEventHubShutdownDrainsWaiters(real-timer-racedrain) +TestShutdownRemovesOnStopError(the Stop-error-still-removed half) +TestShutdownCtxBounded; same coverage, split for clarity. - EventHub lifecycle. M2 replaced the
started boolwith ahubStateenum (notStarted/started/stopped) so the shutdown flag can't form an invalid started-and-stopped combination — a small refinement beyond the draft.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-06-18 | Ruslan Gabitov | Accepted. Lands the control + engine-lifecycle slice of ADR-013 v.1 (§2.3/§2.5) and realizes the open part of ADR-006 v.1 §2.5: InstanceHandle.Cancel(ctx) (instance self-cancel via a Run-derived inst.cancel, request + ctx-bounded wait, idempotent) + reserved Suspend/Resume; Thresher.Shutdown(ctx) (new terminal Stopped state, cancel + settle running instances, drain the hub); EventHub.Shutdown(ctx) (hub sync.WaitGroup over waiter Service goroutines, ctx-bounded wait, remove-even-on-Stop-error); Thresher.Forget(ids ...string) (batch, all-or-nothing release of terminal instances) + Instances(filter) discovery (one function, InstancesAll/InstancesRunning/InstancesCompleted) + Starters() []StarterInfo (event-start registrations, which have no instance yet) + UnregisterProcess documented (live instances keep running). Code-grounded against pkg/thresher, internal/instance, internal/eventproc/eventhub. Sibling of SRD-018 v.1 (observe). Implements ADR-013 v.1; realizes ADR-006 v.1 §2.5; refs ADR-001 v.5, ADR-002 v.2. |