SRD-013 — SendTask & ReceiveTask: broker-backed message handling (tasks)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-15 |
| Owner | Ruslan Gabitov |
| Implements | ADR-014 v.1 Message Handling |
This SRD lands the task half of ADR-014 v.1: the SendTask and ReceiveTask executors. A SendTask binds its Message from scope and publishes it to the MessageBroker; a ReceiveTask registers a new MessageWaiter that subscribes to the broker and, on arrival, binds the payload into scope and completes. Phase-1 correlation is match-by-message-name. The throw/catch message events that share ADR-014's producer/consumer seam are a separate follow-up SRD; so are correlation-key derivation and message-triggered instantiation (ADR-014 §2.6–§2.8).
1. Background & motivation¶
1.1 Current state (verified against the code)¶
SendTask/ReceiveTaskare field-only stubs —pkg/model/activities/send_task.go:8,receive_task.go:8. They hold aMessage, a vestigialservice.Operation, anImplementationstring (andReceiveTask.Instantiate), embedtask, and implement no executor (grepforfunc (st *SendTask)/(rt *ReceiveTask)→ none). TheOperationfield has zero readers (grep .Operation→ onlyerrs.OperationFailedand the unrelatedMessageEventDefinition.Operation()); it is safe to remove (ADR-014 §2.8).- The executor pattern is public (SRD-012).
pkg/exec.NodeExecutor.Exec(ctx, re renv.RuntimeEnvironment) ([]*flow.SequenceFlow, error); the reference isServiceTask.Exec(service_task.go:75) which binds viaservice.BindInput,re.Puts the result, returnsst.Outgoing(). The embeddedtaskalready implementsexec.NodeDataConsumer.LoadData/NodeDataProducer.UploadData(task.go:84/219) and the output-association push (updateOutputs,task.go:268) — so a task inherits full data binding once it gainsExec. - The broker exists, name-match is already its behaviour.
pkg/messaging.MessageBroker—Publish(ctx, Envelope) error,Subscribe(ctx, name, correlationKey string) (<-chan Envelope, error);Envelope{Payload any; Name string; CorrelationKey string}.membrokerbuffers undelivered envelopes (subscribe-before-publish per ADR-006 §2.4) and matches name + (empty-or-equal key). An executor reaches it viare.MessageBroker()(pkg/renv.EngineRuntime.MessageBroker()). service.BindInput(pkg/model/service/operation.go:252) reads a*bpmncommon.Message's item from scope by id (Ready-checked) and returns the bound item — the exact send-side bind-from-scope, reusable.- The MessageWaiter is the missing keystone.
internal/eventproc/eventhub/waiters/waiters.go:47CreateWaiterswitches oneDef.Type()with only aflow.TriggerTimercase;TriggerMessagefalls through toObjectNotFound. The reference isTimerWaiter(timer.go): constructed with(hub, ep, eDef, id, rt renv.EngineRuntime);Service(ctx)spawns a goroutine; on fire it snapshots processors under lock, releases, callsep.ProcessEvent(ctx, eDef)unlocked, thenhub.RemoveWaiter. Waiters are registered byEventHub.RegisterEvent(eventhub.go:101, which callsw.Service). - The track's event wait/resume loop is in place.
track.checkNodeType(track.go:279) registers aflow.EventNode's definitions viainstance.RegisterEvent(track, def)and parks the track inTrackWaitForEvent;track.ProcessEvent(track.go:674) resumes it on fire — it casts the current node toeventproc.EventProcessor(track.go:693) and callsnode.ProcessEvent, then unregisters and returns the track toTrackReady, after whichExecruns. No real model node implementseventproc.EventProcessortoday (the cast is latent, exercised only by mocked timer tests) —ReceiveTaskwill be the first. - Output binding reuse. A node that
re.Puts a Ready datum has it pushed to scope by the inheritedtask.UploadDatathrough output associations (oa.UpdateSource,association.go:123) — the same wayServiceTaskcommits its result. No new association API is needed.
1.2 Why¶
ADR-014 decided message handling: messages travel the broker, the EventHub stays the internal wait machine, and a MessageWaiter bridges them. The pieces exist (broker, executor pattern, wait/resume loop, data binding) but SendTask/ReceiveTask are unrunnable and no MessageWaiter exists — so a gobpm process cannot send or receive a message. This SRD lands the task executors and the waiter, giving the engine its first cross-participant capability.
2. Goals & scope¶
2.1 Goals (in scope)¶
- G1. Drop the vestigial
service.Operationfield fromSendTask/ReceiveTask(ADR-014 §2.8). - G2. A
SendTaskis anexec.NodeExecutorthat binds itsMessagefrom scope and publishes anEnvelopetore.MessageBroker(), then completes (synchronous to its lifecycle, no reply-wait). - G3. A
MessageWaiter(peer ofTimerWaiter) subscribes the broker for a message name and fires the event on arrival; theTriggerMessagecase is wired into the waiter registry. It cleans up its goroutine + subscription onStop/ctx (no leak). - G4. A
ReceiveTaskis aflow.EventNode+eventproc.EventProcessor+exec.NodeExecutor: it registers aMessageWaiter, parks the track, captures the arrived payload on fire, and on resume binds it into scope (reusingtask.UploadData) and completes. - G5. Phase-1 correlation = match-by-message-name (the broker's default); the
Envelopecarries the message item's value, and the consumer reconstructs a typed datum for the message'sItemDefinition. - G6. The shared producer choreography lands as a helper in a new public
pkg/model/msgflowpackage; a runnable send→receive example demonstrates it.
2.2 Non-goals (deferred, each with a named home)¶
- Throw/catch message events + the
MessageProducer/MessageConsumerinterface declarations — the next SRD (the message-events landing). The interfaces gain their second implementor there; SRD-013 ships the shared choreography helper(s) the events will also call, but not the (single-implementor, non-polymorphic) interfaces. This also closescatch_upload_test.go's WS-C3 TODO there. - Correlation-key derivation (CorrelationSubscription → key) — a follow-up Correlation SRD (ADR-014 §2.6/§2.8); the
Envelope.CorrelationKeyfield already carries a key. - Message-triggered instantiation (
ReceiveTask.Instantiate/ start message event spawning an instance) — deferred with the thresher message-routing work (ADR-014 §2.7); theInstantiatefield stays but is not acted on. - EventHub
WaitGroupsole-ownership shutdown (ADR-006 §2.5) — ADR-006's own implementing SRD; SRD-013 only guarantees theMessageWaitercleans up itself onStop/ctx. - Service-operation-backed send — the dropped
Operationfield; re-introduced only when needed (ADR-014 §2.8).
3. Requirements¶
3.1 Functional¶
| # | Requirement |
|---|---|
| FR-1 | Remove the service.Operation field from SendTask and ReceiveTask (and the now-unused service import). No code references it (§1.1). |
| FR-2 | New public package pkg/model/msgflow: a Send(ctx, re renv.RuntimeEnvironment, msg *bpmncommon.Message) error helper — bind msg from scope (service.BindInput), build a messaging.Envelope{Name: msg.Name(), Payload: <item value>}, re.MessageBroker().Publish. It imports only public packages (pkg/messaging, pkg/renv, pkg/model/{bpmncommon,service,data}); no cycle. |
| FR-3 | SendTask gains NewSendTask(name, msg, opts…), Exec(ctx, re) ([]*flow.SequenceFlow, error) (calls msgflow.Send, returns Outgoing()), Clone(), TaskType()→flow.SendTask, and var _ exec.NodeExecutor = (*SendTask)(nil). |
| FR-4 | New MessageWaiter (internal/eventproc/eventhub/waiters/message.go) mirroring TimerWaiter: constructed (hub, ep, eDef, id, rt renv.EngineRuntime); Service(ctx) subscribes rt.MessageBroker().Subscribe(ctx, name, "") and runs a goroutine; on the first matching Envelope it reconstructs a typed data.Data for the message's ItemDefinition, clones the event definition carrying it, and fires ep.ProcessEvent (TimerWaiter's lock discipline), then hub.RemoveWaiter; Stop()/ctx terminate the goroutine and release the subscription. waiters.go's CreateWaiter gains case flow.TriggerMessage. |
| FR-5 | ReceiveTask implements flow.EventNode (synthesizing a MessageEventDefinition from its Message so checkNodeType registers it and parks the track), eventproc.EventProcessor (ProcessEvent captures the arrived payload into a per-execution field), and exec.NodeExecutor (Exec re.Puts the captured payload as a Ready datum for the message item, returns Outgoing(); the inherited task.UploadData pushes it to scope). Constructor NewReceiveTask, Clone, TaskType()→flow.ReceiveTask, interface assertions. |
| FR-6 | Phase-1 correlation: the broker matches on message name (empty correlation key). The producer sets Envelope.Name = msg.Name() and Payload = the bound item's value; the MessageWaiter reconstructs a typed datum from Payload using the message's ItemDefinition. |
| FR-7 | A runnable example (examples/message-send-receive or equivalent) starts a process with a SendTask and a ReceiveTask (and the broker) and shows the message flowing end to end, exit 0. |
3.2 Non-functional¶
| # | Requirement |
|---|---|
| NFR-1 | The MessageWaiter's goroutine and broker subscription are released on Stop() and ctx-cancel — no goroutine/subscription leak (verified by a waiter test). Existing internal/instance / eventhub / model / thresher suites pass. |
| NFR-2 | No payload values in logs — log message name, key, item ids, states only (ADR-010/011/014 masking). |
| NFR-3 | make ci green per milestone; diff-coverage ≥95 % (target 100 %) on touched files. |
| NFR-4 | pkg/model/msgflow imports no internal/* (depguard); every new exported symbol carries a doc comment; new constructors validate inputs with self-identifying errors. |
4. Design & implementation plan¶
4.1 Send: bind → publish¶
flowchart LR
A["SendTask.Exec"] --> B["msgflow.Send: BindInput(msg) from scope"]
B --> C["Envelope{Name, Payload}"]
C --> D["re.MessageBroker().Publish"]
D --> E["return Outgoing()"]
SendTask mirrors ServiceTask: it never waits (a request/reply is a send node then a receive node — the diagram shows the wait). msgflow.Send is the shared producer choreography the throw message event will also call (next SRD).
4.2 Receive: wait (via MessageWaiter) → capture → bind¶
flowchart TD
R["ReceiveTask: flow.EventNode (synthetic MessageEventDefinition)"]
R --> CN["track.checkNodeType -> instance.RegisterEvent(track, def)"]
CN --> W["MessageWaiter.Service: broker.Subscribe(name)"]
W -->|"Envelope arrives"| F["reconstruct typed data; clone def; ep.ProcessEvent"]
F --> TP["track.ProcessEvent -> node.(EventProcessor).ProcessEvent captures payload"]
TP --> RES["track -> TrackReady -> ReceiveTask.Exec"]
RES --> P["re.Put(payload) -> task.UploadData pushes to scope"]
P --> O["return Outgoing()"]
ReceiveTask plugs into the existing wait/resume loop by being a flow.EventNode (so checkNodeType registers its synthetic MessageEventDefinition and parks the track) and an eventproc.EventProcessor (so track.ProcessEvent's node cast at track.go:693 resolves — ReceiveTask is the first real node to implement it). No new track path. The payload travels broker → MessageWaiter (reconstructs typed data) → cloned event definition → ProcessEvent (captures) → Exec (re.Put) → inherited task.UploadData → scope.
4.3 The Envelope payload contract (phase-1)¶
The broker's Payload any meets the typed data plane at the waiter: the producer puts the value (item.Structure().Get(ctx)); the MessageWaiter reconstructs a typed data.Data for the message's ItemDefinition (it has the def via the MessageEventDefinition), so the consumer side sees properly-typed data. The any opacity is confined to the broker hop.
4.4 Milestones (each = one commit, make ci green)¶
- M1 — drop
Operation; addmsgflow.Send. Remove the vestigial field from both tasks; addpkg/model/msgflowwith theSendhelper. Pure additions/removals; nothing references the field. - M2 —
SendTaskpublishes.NewSendTask,Exec,Clone,TaskType, assertion; unit test against an in-memory broker (publish observed). - M3 —
MessageWaiter.waiters/message.go+ theTriggerMessagecase; waiter unit test mirroringtimer_test.go(mockEventProcessor+ in-mem broker; assert fire + cleanup, no leak). - M4 —
ReceiveTaskwaits + binds.flow.EventNode+eventproc.EventProcessor+Exec; implement the node-levelProcessEvent(first real one — verify the timer path still works). Integration test: publish then receive, payload reaches scope. - M5 — example + DoD. A send→receive example; smoke it (exit 0); coverage gate.
4.5 Tests¶
pkg/model/msgflow (Send against a fake/in-mem broker), SendTask.Exec (publishes the bound payload), MessageWaiter (fires on envelope, cleans up — mirrors timer_test.go), ReceiveTask end-to-end (RegisterEvent → waiter → ProcessEvent → Exec → scope-bound), and the example as smoke. Cover the node-level ProcessEvent path (latent until now).
5. Verification (Definition of Done)¶
| # | Check | Expectation |
|---|---|---|
| V1 | SendTask/ReceiveTask have no Operation field; no dangling refs; service import dropped where unused (FR-1). |
green |
| V2 | SendTask.Exec binds its message from scope and publishes an Envelope to the broker; returns its outgoing flows (FR-2/3). |
green |
| V3 | MessageWaiter subscribes the broker, fires ProcessEvent on a matching envelope, and releases its goroutine + subscription on Stop/ctx (FR-4, NFR-1); TriggerMessage is wired. |
green |
| V4 | ReceiveTask registers a waiter, parks the track, captures the payload on fire, and binds it into scope on resume (FR-5); the node-level ProcessEvent cast resolves. |
green |
| V5 | Phase-1 name-match: a published message with the receiver's name is delivered; the payload is typed per the message item (FR-6). | green |
| V6 | A send→receive example runs to exit 0; existing suites pass (FR-7, NFR-1). | green |
| V7 | make ci green; diff-coverage ≥95 % on touched files; msgflow imports no internal (NFR-3/4). |
pass |
6. Risks & regressions¶
- First real node-level
ProcessEvent. Thetrack.go:693cast was latent (mock-only).ReceiveTaskis the first real implementor — M4 verifies the timer path is unaffected and the cast resolves. If a non-EventProcessor catch node ever reaches that path it errors loudly (existing behaviour). - Waiter subscription/goroutine leak. A
MessageWaiterthat doesn't release its broker subscription + goroutine onStop/ctx leaks (the §2.5 concern, scoped to per-waiter here). NFR-1's test guards it; the full hubWaitGroupownership is ADR-006's SRD. - Payload typing across the broker
anyhop. A malformed/mistyped payload surfaces when the waiter reconstructs the typed datum — handled with a classified error, not a silent mis-bind (§4.3). MessageEventDefinitioncloner. The payload-carrying fire needs the def to satisfyflow.EventDefCloner(CloneEventDefinition); verified at M3/M4 (the def currently hasCloneEvent— confirm the contract or adapt).
7. Implementation summary¶
Landed on feat/srd-013-message-handling in five milestones (each one commit,
make ci green). /check-srd audit: PASS; all V1–V7 met.
7.1 Milestones¶
| M | Commit | Scope | Tests |
|---|---|---|---|
| doc | 66c2a79 |
SRD-013 (this doc) | — |
| M1 | a0c1454 |
drop vestigial Operation from both tasks; new pkg/model/msgflow with Send (bind → Envelope → re.MessageBroker().Publish) |
msgflow Send 100% |
| M2 | 99294d6 |
SendTask executor (NewSendTask/Exec/Clone/TaskType/accessors + exec.NodeExecutor) |
send_task.go 100% |
| M3 | 81b39a8 |
MessageWaiter (peer of TimerWaiter) + TriggerMessage wired; payload reconstructed and conveyed via CloneEvent; per-waiter cleanup; updates the obsolete eventhub limitation test |
message.go avg 98.6% (2 unreachable defensive branches) |
| M4 | 106bb05 |
ReceiveTask = flow.EventNode + eventproc.EventProcessor + exec.NodeExecutor (first real node ProcessEvent); binds payload via inherited task.UploadData |
receive_task.go 97.6% |
| M5 | f3a3be0 |
runnable examples/message-send-receive (own module) + instance integration test; mid-flow event registration fix in internal/instance/track.go (smoke-surfaced) |
track.go 100% of changed lines; message_flow_test.go (V4 end-to-end + negative) |
7.2 Key files¶
pkg/model/msgflow/{msgflow,send}.go— public producer choreography (Send).pkg/model/activities/{send_task,receive_task}.go— the two executors.internal/eventproc/eventhub/waiters/message.go+waiters.go—MessageWaiterand its registry wiring.internal/instance/track.go—checkNodeTypereorder (wait-before-register) andcheckFlowsmid-flow registration.examples/message-send-receive/— runnable demo.
7.3 V-results¶
V1–V7 all green: Operation removed (V1); SendTask.Exec publishes (V2); MessageWaiter fires + cleans up, TriggerMessage wired (V3); ReceiveTask parks/captures/binds, node ProcessEvent resolves (V4); name-match delivery with typed payload (V5); example exits 0 and the suite is green (V6); make ci green, diff-coverage 97.0 % (min 95), msgflow imports no internal (V7).
7.4 Notable delta vs the draft¶
The §6 risk "intermediate event nodes" surfaced concretely: a mid-flow ReceiveTask reached by the token never registered its event, because checkNodeType ran only for a track's initial node (newTrack). M5 adds the registration at token-advance (checkFlows) and reorders checkNodeType to declare TrackWaitForEvent before registering (so a broker-buffered message delivered synchronously on subscribe is accepted). Start (no-incoming) nodes are unchanged — they still pre-register via createTracks. The MessageEventDefinition cloner concern (§6) resolved by using its concrete CloneEvent method (the EventDefCloner interface is unused in the codebase).
8. References¶
- ADR-014 v.1 Message Handling — the decision this lands (broker for send, MessageWaiter for receive, the producer/consumer seam, phase-1 name-match); §2.8 deferrals (Operation field, instantiation, correlation derivation); throw/catch message events share the seam (next SRD).
- ADR-006 v.1 Events & Subscriptions — §2.4 delivery (subscribe-before-publish, broker-buffered) and §2.5 waiter lifecycle the
MessageWaiterobeys; the hubWaitGroupownership is ADR-006's own SRD. - ADR-012 v.1 Execution Layering — the public
pkg/exec/pkg/renvcontracts the executors implement. - SRD-012 v.1 Execution layering — published those contracts;
msgflowand the executors build on them (sideways). - SRD-011 v.1 Go-operation service reader —
service.BindInput, reused by the send-side bind (sideways).
9. Open questions¶
- None. The task scope (send=publish / receive=MessageWaiter-subscribe),
ReceiveTaskas aflow.EventNode+EventProcessorreusing the wait/resume loop (first real nodeProcessEvent), per-waiter cleanup (hubWaitGroupdeferred to ADR-006's SRD), the phase-1 name-match + value-payload contract, and landing the shared producer choreography as amsgflowhelper while deferring theMessageProducer/MessageConsumerinterfaces to the message-events SRD (second implementor) are decided above. Throw/catch message events, correlation-key derivation, and instantiation are deferred (§2.2).
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-06-15 | Ruslan Gabitov | Draft. Lands the task half of ADR-014 v.1: drop the vestigial service.Operation from SendTask/ReceiveTask; SendTask binds its message from scope (service.BindInput) and publishes an Envelope to re.MessageBroker() via a new pkg/model/msgflow.Send helper, then completes; a new MessageWaiter (peer of TimerWaiter, TriggerMessage wired) subscribes the broker and fires ProcessEvent on arrival, cleaning up its goroutine+subscription on Stop/ctx; ReceiveTask becomes a flow.EventNode + eventproc.EventProcessor + exec.NodeExecutor that parks the track, captures the arrived payload (reconstructed typed per the message item), and binds it to scope on resume via the inherited task.UploadData — the first real node-level ProcessEvent. Phase-1 correlation = match-by-message-name; Envelope carries the item value. Five milestones + a send→receive example. Deferred to follow-up SRDs: throw/catch message events + the MessageProducer/MessageConsumer interface declarations (second implementor; closes the WS-C3 catch-binding TODO), correlation-key derivation, message-triggered instantiation, and the EventHub WaitGroup shutdown (ADR-006's SRD). Implements ADR-014 v.1 (task half). |