SRD-020 — Signal events: name-matched broadcast catch & throw¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-18 |
| Owner | Ruslan Gabitov |
| Implements | ADR-006 v.1 Events & Subscriptions §2.1, §2.4 |
This SRD lands the first runtime slice of the events workstream opened by ADR-006 v.1: signal events — the publication / broadcast trigger (§2.1) — for intermediate catch and throw (intermediate-throw + signal end event). It also closes the dormant §2.4 no-waiter gap (propagating to no listener becomes a logged no-op, not an error) that has been waiting for signals. Signal start (instantiating) and signal boundary events are deferred to the instantiation and boundary workstreams respectively.
1. Background & motivation¶
1.1 Current state (verified against the code)¶
- Signals are modelled but not executable.
SignalEventDefinition(pkg/model/events/signal.go:59-112) exists withType() → flow.TriggerSignal(:110-112) and aSignal()accessor over a namedSignal(signal.go:14-51,Name()at:49); the intermediate-catch trigger whitelist already admits signals (pkg/model/events/intermediate_catch.go:18-23,flow.TriggerSignalat:21). But there is no signal waiter: the waiter-builder switch (internal/eventproc/eventhub/waiters/waiters.go:53-70) has cases only forTriggerTimerandTriggerMessage; a signal falls through to thedefaulterror ("couldn't find builder for event definition of type …",:64-70). So a process with a signal catch/throw fails at registration today. - The hub is point-to-point, keyed by
eDef.ID(). The registry iswaiters map[string]eventproc.EventWaiter(eventhub.go:48);registerWaiterkeys byeDef.ID()— find-or-build, and if a waiter already exists for the key it adds the processor to it (eventhub.go:194-228). A waiter fans out to all its registeredEventProcessors when it fires (the message waiter loops them —waiters/message.go:307-322).PropagateEventlooks up one waiter byeDef.ID()(eventhub.go:376) and delivers to it. - Propagating to no waiter is an ERROR (the §2.4 gap).
PropagateEventreturnsObjectNotFoundwheneDef.ID()is absent from the registry (eventhub.go:379-385). For a signal broadcast with no live catcher this is wrong — a signal thrown into the void is simply not caught (§10.5.1), a normal condition, not a failure (ADR-006 §2.4). - Delivery runs on the waiter goroutine, track-synchronized. A fired event
reaches the waiting track via
track.ProcessEvent(internal/instance/track.go:723-778), which runs on the waiter goroutine (comment:743), guardsTrackWaitForEvent(:730), delivers to the node, thenunregisterEvent+TrackReady(:769-775). Registration istrack.checkNodeType→RegisterEvent(t, d)per definition when the token reaches the node (track.go:301-340, register at:328). The throw side: a throw event'sExecutepropagates via the instance'sEventProducer.PropagateEvent(internal/instance/instance.go:987-1002) →Thresher.PropagateEvent(thresher.go:400-419) →EventHub.PropagateEvent. This is the proven path message/timer already use; signals reuse it (ADR-006 §2.1's single-loop inbound edge stays conception, as it is for message/timer today).
1.2 Problem¶
The signal trigger — BPMN's broadcast publication strategy (§10.5.1) — has a model but no runtime. A process cannot throw or catch a signal, and the hub's point-to-point, error-on-no-waiter behaviour is the wrong shape for broadcast. This SRD makes signals executable and turns the §2.4 contract into reality.
2. Decision¶
- Broadcast falls out of shared eDef identity (no name index needed). Signal
EventDefinitions have noCloneForInstance, soEvent.clone()(events/event.go:161-167) shares them by reference across per-instance node-graph clones (ADR-009) viacloneDefsForInstance(event.go:175-191, "definitions without the capability are shared by reference"). Every instance catching the same modelled signal node therefore holds the sameeDef.ID(), so the existing find-or-add-processorregisterWaiterlands all catchers on one ID-keyed waiter as distinct processors, and a throw (PropagateEventby that id) fansProcessout to all of them — broadcast via the existing registry, no name index, no keying change. The clone-vs-share asymmetry incloneDefsForInstancealready encodes it: message/timer clone → point-to-point (the FIX-004 no-share rule); signal shares → broadcast (the deliberate inverse). Signal therefore deliberately does not addCloneForInstance. - A passive
signalWaiter. Unlike message (broker subscription) / timer (ticker), a signal has no external source — it is fired only by an in-process throw viaPropagateEvent. So the signal waiter spawns no service goroutine; itsServiceis a no-op that marks it running and itsDone()is already closed. It obeys §2.5 ownership (the hub creates/removes it; it never self-removes) and fansProcessout to all its processors with no correlation filter (signals have no correlation, §10.5.1). - No-waiter ⇒ logged no-op (closes §2.4).
PropagateEventto an absent key is a debug-logged no-op, not an error — correct for a signal broadcast with no live catcher, harmless for any other kind. - Catch is single-shot; the waiter persists until empty. An intermediate catch
consumes the signal once: on fire the track
unregisterEvents (removes its processor); when the last processor leaves, the hub removes the waiter. Because the broadcast fan-out drives each catcher to self-unregister duringProcess, the last unregister removes the now-empty waiter, soPropagateEvent's own post-Processempty-cleanup (eventhub.go:397-398) must tolerate an already-removed waiter (a lock-check-delete, not a hardRemoveWaiterthat errorsObjectNotFound). This path is currently dead for message (itsProcesserrors) and timer (fires viaWaiterFired), so signal is the first to exercise it. - Throw broadcasts. Intermediate-throw and signal end events propagate
their
SignalEventDefinition; the hub finds the shared-id waiter and fans out. Reach is engine-wide (every instance registered in theThresher), including the throwing instance's own catchers — the single-process in-memory conformance target (§2.4).
flowchart LR
TA[instance A: catch signal X] -->|register processor| W[(one waiter: shared eDef id of X)]
TB[instance B: catch signal X] -->|register processor| W
THR[instance C: throw signal X] -->|PropagateEvent| W
W -->|fan out, no correlation| TA
W -->|fan out| TB
W -.->|no processors -> logged no-op| VOID[thrown into the void]
3. Functional requirements¶
- FR-1 — signal catch waiter. A
signalWaiter(eventproc.EventWaiter) is built for aSignalEventDefinition;waiters.CreateWaitergains aflow.TriggerSignalcase. It is passive (no service goroutine):Servicemarks itWSRunnedand leavesDone()closed;Stopmarks it stopped; it never removes itself (§2.5). - FR-2 — shared-eDef-id broadcast (no keying change). Signal
EventDefinitions deliberately have noCloneForInstance, so all instances catching the same modelled signal node share oneeDef.ID()(Event.clone→cloneDefsForInstance, shared by reference). The existingeDef.ID()-keyedregisterWaiterfind-or-add-processor then lands every catcher on one waiter as a distinct processor — broadcast, with nowaiterKeyand no change toregisterWaiter/PropagateEvent/UnregisterEventkeying. - FR-3 — broadcast fan-out.
signalWaiter.Process(eDef)delivers to every registeredEventProcessor(each catching track), with no correlation filter; a per-processor delivery error is logged and does not abort the rest of the broadcast. Each delivered track resumes via the existingtrack.ProcessEventpath. - FR-4 — no-waiter no-op (closes §2.4).
EventHub.PropagateEventwith no registered waiter foreDef.ID()is a debug-logged no-op returningnil, replacing the currentObjectNotFounderror (eventhub.go:379-385). Landed in M1. - FR-5 — single-shot catch + cleanup tolerance. An intermediate signal catch is
consumed once: on fire the track unregisters its processor (existing
track.ProcessEventunregisterEvent); the waiter is removed when its last processor leaves. Since the broadcast fan-out makes catchers self-unregister duringProcess,PropagateEvent's post-Processempty-cleanup must tolerate the waiter being already removed (lock-check-delete, not a hardRemoveWaiter). - FR-6 — signal throw. Intermediate-throw and signal end events propagate a
SignalEventDefinitionthrough the existing throw path (instance.PropagateEvent→Thresher.PropagateEvent→EventHub); no new throw plumbing — the shared-id waiter fans out. - FR-7 — deferrals (documented, not built). Signal start events (instantiating — extends ADR-015) and signal boundary events (boundary workstream) are out of scope; the catch path here is the intermediate in-flow waiter only (ADR-006 §2.3 row 1).
4. Non-functional requirements¶
- NFR-1 — BPMN broadcast semantics. One throw of name
Xreaches all current catchers ofXin reach (§10.5.1); zero catchers is a no-op, never an error or a buffered late-delivery (§2.4 — the hub is not a store). - NFR-2 — §2.5 ownership. The signal waiter is hub-owned: created on first
catch, removed when empty or on
Shutdown; it never self-removes; it drains cleanly (itsDone()is closed, soEventHub.Shutdown's wait is immediate). - NFR-3 — no new locks / single-mutator preserved. Registration/propagation
stay under the hub's existing
m sync.RWMutex; delivery reuses the track's existingt.m/state synchronization (ADR-001). No change to the instance loop. - NFR-4 — coverage. Touched files finish ≥80% (target 100%) diff-coverage;
make cigreen (incl.-race— broadcast crosses instances/goroutines).
5. Path analysis (alternatives)¶
- Shared-eDef-id via the existing registry (chosen) vs name-keying vs a parallel
map[name][]subscriberindex. Chosen: rely on signal eDefs being shared by reference across instance clones (noCloneForInstance), so all catchers share oneeDef.ID()and the existingeDef.ID()-keyed find-or-add-processor + fan-out + §2.5 ownership + empty-cleanup machinery gives broadcast with no keying change at all. Rejected name-keying (waiterKey): it would forceregisterWaiter/PropagateEvent/UnregisterEvent(which takeseDef.ID()) onto a name-derived key — extra threading for an asymmetrycloneDefsForInstancealready encodes. Rejected a parallel index: duplicates ownership/shutdown and splits propagation into two paths. - Passive waiter (chosen) vs a service goroutine that waits on a channel.
Chosen: a signal has no external source (it is fired by an in-process throw), so a
goroutine would block on nothing and only complicate the §2.5 drain. A passive
waiter (closed
Done()) is the honest shape. Rejected the goroutine: needless concurrency + a drain edge with no benefit. - No
CloneForInstancefor signals (chosen) vs per-instance clone like message/timer. Chosen: message/timer clone to a fresh per-instance ID so concurrent instances do not share a waiter (point-to-point — sharing was the FIX-004 broadcast bug). For signals the opposite is correct: catchers across instances should all fire on one throw, so they should share one waiter as distinct processors — achieved precisely by not addingCloneForInstance(cloneDefsForInstancethen shares the eDef by reference). Rejected cloning: it would fragment the broadcast into per-instance waiters a single throw can't reach. - No-waiter no-op (chosen) vs keep the error / buffer the signal. Chosen: logged no-op (ADR-006 §2.4) — a signal with no catcher is normal BPMN. Rejected the error (wrong for broadcast) and buffering (the hub is not a store; signals are not replayed — §2.4).
- Reach = engine-wide incl. self-instance (chosen) vs per-instance only. Chosen: a thrown signal reaches every catcher in the engine, including the thrower's own instance (§10.5.1 "within and across Processes"). The single-process in-memory model is the conformance target (§2.4). Cross-engine reach is the persistence/distribution ADR's, not here.
6. API & key shapes¶
// internal/eventproc/eventhub/waiters — new passive waiter:
// NewSignalWaiter(eh, ep, eDef, rt) (eventproc.EventWaiter, error)
// Process fans out to all EventProcessors (no correlation); Service is a
// no-op (no goroutine); Done() is closed; never self-removes (§2.5).
// waiters.CreateWaiter gains:
// case flow.TriggerSignal: w, err = NewSignalWaiter(eh, ep, eDef, rt)
// internal/eventproc/eventhub/eventhub.go:
// PropagateEvent post-Process empty-cleanup tolerates an already-removed
// waiter (the broadcast fan-out self-unregisters its catchers). The §2.4
// no-waiter no-op landed in M1.
// No keying change: signal eDefs share one eDef.ID() across instances
// (cloneDefsForInstance shares by reference), so the existing eDef.ID()-keyed
// registry broadcasts via find-or-add-processor.
No new public pkg/ surface: signals are authored with the existing
events.NewSignalEventDefinition + intermediate catch/throw + end-event builders;
this SRD wires their runtime.
7. Test plan¶
TestSignalCatchThrow— one instance: a track waits on an intermediate signal catch; another track (or a second flow) throws the same signal name; the catch fires and the process completes (FR-1, FR-3, FR-6).TestSignalBroadcast(-race) — two concurrent instances each catch signalX; a throw ofX(from a third) fires both — both reach their downstream node (FR-2, FR-3, NFR-1). This is the broadcast canary (the inverse of FIX-004's no-cross-instance rule, which holds for message/timer).TestSignalThrownIntoVoid— throwing a signal with no registered catcher is a no-op (no error), and a later catch of the same name is not retro-delivered (FR-4, NFR-1; the §2.4 no-buffer contract).TestSignalSingleShotConsume— after a catch fires, its processor is removed; a second throw does not re-fire the consumed catch; the waiter is gone once empty (FR-5).TestPropagateNoWaiterIsNoop(internaleventhub) —PropagateEventwith an absent key returnsniland logs at debug, for both a signal and a non-signal eDef (FR-4) — the direct §2.4 regression test againsteventhub.go:379-385.- Internal
internal/eventproc/eventhub/waitersunit forsignalWaiter(passiveService/closedDone, fan-outProcess,Stop) for cross-package coverage attribution.
8. Cross-document consistency¶
- Implements ADR-006 v.1 §2.1 (publication/broadcast reach, per-instance subscription identity), §2.4 (no-waiter no-op, non-durable, no buffering — signals are not the broker's job).
- ADR-001 v.5 — the track/loop model the
delivery path runs against (single-mutator; signal reuses
track.ProcessEvent). - ADR-009 v.1 — per-instance clones; each instance's catch is a distinct processor on the shared waiter.
- ADR-013 v.1 §2.5 / SRD-019 v.1
— the
EventHub.Shutdownwaiter drain the passive signal waiter obeys (closedDone()). - ADR-014 v.1 — the message waiter this mirrors (and deliberately diverges from on correlation/broadcast).
- References up/sideways, version-pinned; no downward refs (ADR-006 does not cite SRD-020).
9. Definition of Done¶
- FR-1…FR-7 wired and exercised by the §7 tests (incl. the
-racebroadcast canary). signalWaiter+CreateWaitersignal case +broadcastSignalname-scan + §2.4 no-op present; signal catch/throw/end run end-to-end.- §2.4 closed:
PropagateEventno-waiter is a logged no-op (the oldObjectNotFoundpath is gone), proven byTestPropagateNoWaiterIsNoop. make cigreen (tidy, lint incl. fieldalignment, build,-race, diff-coverage ≥95, govulncheck); touched files ≥80% (target 100%).- A runnable
examples/signal-broadcast(or extension of an existing example) smoke-runs exit 0, plus the existing 9 examples still exit 0. - §10 filled; status → Accepted; RU twin added; linked docs synced; GitHub sub-issue under epic #90 closed by the PR.
10. Implementation summary¶
Landed on feat/signal-events (off master): two code milestones + a
prerequisite fix + a runnable example.
10.1 Commits¶
| # | Commit | Scope | Tests |
|---|---|---|---|
| doc | 24a451c |
SRD-020 draft | — |
| fix | 30c8437 |
Pre-existing SRD-019 race: EventHub.state made atomic.Uint32 (Run/PropagateEvent read it lock-free while Start/Shutdown write it). Surfaced by M1's timing shift. |
TestThresherShutdown -race |
| M1 | d4460a7 |
§2.4 no-waiter no-op: PropagateEvent to an absent key → debug-logged nil (was ObjectNotFound). |
TestPropagateNoWaiterIsNoop |
| M2 | ed43854 |
Signal runtime: passive signalWaiter, CreateWaiter signal case, broadcastSignal name-scan in PropagateEvent. |
TestSignalWaiter*, TestBroadcastSignalFanOut, TestSignalCatchThrow, TestSignalBroadcast -race, TestSignalThrownIntoVoid, TestSignalSingleShotConsume |
| M3 | ce0bc96 |
examples/signal-broadcast (one throw → two watcher instances). |
smoke |
10.2 Key files¶
internal/eventproc/eventhub/waiters/signal.go(new) — the passivesignalWaiter.internal/eventproc/eventhub/waiters/waiters.go—CreateWaiterTriggerSignalcase + per-trigger-identity NOTE.internal/eventproc/eventhub/eventhub.go—broadcastSignal/signalName, thePropagateEventsignal branch + §2.4 no-op, and theatomic.Uint32state (race fix).examples/signal-broadcast/(new) —process.go(catcher/thrower builders) +main.go.
10.3 Verification¶
make cigreen: tidy, lint, build,-race, diff-coverage 97.2% of 212 changed lines (≥95), govulncheck. Touched funcs ≥80% (signal.go +broadcastSignal100%).- All 10 examples smoke-run exit 0 (incl.
signal-broadcast).
10.4 Deltas vs the draft¶
- Matching mechanism corrected (FR-2/§2/§5). The draft first sketched a
name-keyed registry (
waiterKey); implementation revealed the simpler truth: catchers across instances already share one waiter (signal eDefs aren't cloned), and throw→catch matches by name-scan inPropagateEvent(broadcastSignal) — no keying change, noUnregisterEventsignature churn. The doc was corrected in M2. A futureSubscriptionKey()generalization (when Link events land) is captured as a follow-up, not built here. - Prerequisite race fix. M1's
-racerun exposed a pre-existing SRD-019EventHub.statedata race; fixed as its own30c8437commit before M1, not folded in silently.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-06-18 | Ruslan Gabitov | Accepted. First runtime slice of the ADR-006 v.1 events workstream: signal events (intermediate catch + intermediate/end throw) via a passive signalWaiter (no broker, no goroutine; Process fans out to all catchers, no correlation; closed Done() for §2.5 drain). Broadcast is matched by name-scan in PropagateEvent (broadcastSignal) — throw and catch are distinct nodes with distinct ids — while catchers across instances share one waiter because signal eDefs aren't cloned (cloneDefsForInstance shares by reference; the deliberate inverse of FIX-004's point-to-point clone). Also closes the §2.4 no-waiter gap (PropagateEvent absent-key → logged no-op, replacing ObjectNotFound; landed in M1) and fixed a pre-existing SRD-019 EventHub.state data race (atomic). Drafted with a name-keyed registry; corrected to shared-id + name-scan during implementation (no waiterKey/UnregisterEvent churn). Signal start (instantiating) and boundary events deferred; a polymorphic SubscriptionKey() matching generalization deferred to the Link-events workstream. Code-grounded against pkg/model/events, internal/eventproc/eventhub, internal/instance. Implements ADR-006 v.1 §2.1/§2.4; refs ADR-001 v.5, ADR-009 v.1, ADR-013 v.1, ADR-014 v.1, SRD-019 v.1. |