SRD-038 — Service Task dispatch reshape & retry policy (M6, M7)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-08 |
| Owner | Ruslan Gabitov |
| Implements | ADR-021 v.1 Service Task Execution Model §2.6–§2.8 |
Accepted — fourth of five SRDs landing ADR-021 v.1. Builds on classification (SRD-037, Accepted). Two coupled changes: (1) Dispatch reshape — move a worker fault's classification off the parked track and into the dispatcher, so classification + retry happen before the instance loop and the track only applies a pre-decided terminal outcome. This restores ADR-021 §2.7 ("only the terminal outcome reaches the loop"); SRD-037 (M4) drifted classification to track-resume. (2) Retry policy — an extendable, batteries-included, two-level
RetryPolicy(default 3× exponential-backoff+jitter). Retry is a dispatcher-side delayed re-enqueue (the dispatcher re-arms the job with a clock-gated backoff, no sleeping goroutine, the track stays parked), and retries-exhausted → an incident-like terminalFailedthat reaches the instance (incident-ready). This is the ADR-021EngineAuthoritativemode: the engine (here, its default in-process dispatcher) owns the policy and the worker returns raw{code, body}. Out of scope, → SRD-039: theWithWorkerTrustknob + theWorkerTrustedprotocol (the worker runs the shipped policy internally, retries in-process, reports a verdict) + the worked example. Sibling: SRD-035 (M1), SRD-036 (M2/M3), SRD-037 (M4/M5), all Accepted.
1. Background (verified against the code)¶
1.1 SRD-037 (M4) left classification on the track — a drift from the ADR (verified)¶
Today a worker fault rides all the way to the parked track before it is classified. The dispatcher's Fail method
delivers a raw tasks.WorkerOutcome (OutcomeFault) to the engine sink; Instance.handleJobCompletion
(internal/instance/jobs.go:59-90) resolves jobID → track and delivers straight to the track's evtCh:
// jobs.go:85-89 (current)
flipNotParked(tr, waiting, msgIdx)
delete(jobs, jobID)
tr.evtCh <- req.outcome // ← wakes the track for it to classify + apply
req.reply <- nil
The woken track runs ServiceTask.execWorkerOutcome (service_task.go:337-363), whose OutcomeFault case calls
classifyFault (:478-519) — running the two-level ErrorMapper at resume, on the track. But ADR-021 §2.7 is
explicit that under EngineAuthoritative "only the terminal outcome reaches the loop", and §2.6 that "the
engine runs the ErrorMapper (sole authority)". So classification is meant to happen before the instance
loop; M4 drifted it onto the track (as its own §4.1 acknowledged, deferring the locus). SRD-038 moves it to where
the ADR puts it — the dispatcher — and that move is exactly what retry needs.
1.2 Why classification must precede the loop (the design driver)¶
Retry applies to technical faults only (ADR-021 §2.7): a technical fault re-runs the worker with backoff, and
the ServiceTask must stay parked across attempts. A raw fault is only known to be technical (vs a business
BpmnError/Status the ErrorMapper extracts from {code, body}) after classification — so the retry
decision requires classification first. If classification stayed on the track, every intermediate technical
fault would have to wake the track and send it back to sleep per attempt ("re-park"), and the track would have to
carry the retry-cycle count it should not own.
The clean structure (and the ADR's): the worker returns a raw fault; the dispatcher classifies it and decides — re-publish (retry) or escalate a terminal outcome to the instance; the instance loop receives only terminals; the track applies one terminal. The engine holds no live call between attempts — a re-armed job plus a parked track are both persistable (§2.7).
flowchart LR
WK[local worker: fn err] -->|raw Fault| DISP[dispatcher]
DISP -->|classify + retry loop| DISP
DISP -->|terminal only| SINK[engine sink]
SINK --> LOOP[instance loop\nhandleJobCompletion]
LOOP -->|deliver terminal| TR[track: apply]
1.3 Incident-readiness — the terminal reaches the instance, not the dispatcher's edge (design intent)¶
Retries-exhausted terminates the task Failed (§2.8), but the exhausted terminal is delivered to the instance
loop (like every other terminal), not swallowed at the dispatcher's edge. This is deliberate: it keeps the
instance the single owner of every ServiceTask terminal, which is what a future first-class Incident
construct needs — on exhaustion the instance can hold the track parked (an incident awaiting operator
resolution) instead of faulting it, and a parked-during-incident track is the same shape that dehydrates
cleanly, unified with parked UserTasks (ADR-020) and long timer events. SRD-038 lands the terminal Failed; the
incident-hold + dehydration is a future ADR (§2.8) the seam is built for.
1.4 The reshape rails (verified)¶
- Classification needs only the fault + an expression engine — no process scope.
ErrorMapper.Classify(ctx, ee expression.Engine, f Fault)(pkg/tasks/errormapper.go:67) takes the expression engine directly and builds its transient source from theFault's{code, body}— it reads no frame. So the dispatcher can classify a raw fault on the reporting goroutine given a bound expression engine, no frame, no instance involvement. - The dispatcher already has a per-job store to re-arm.
localdispatcher.jobEntry(localdispatcher.go:46-51) holds each job's lock state keyed inbyID/byTopic;lockNext(:181-214) hands out an entry whene.workerID == "" || now.After(e.deadline), over the injectedclock.Clock. A retry re-arms the existing entry (unlock + anotBeforegate + attempt++) — no re-enqueue round-trip through the instance. - The clock already supports a timed wake.
clock.Clock(pkg/clock/clock.go:10-15) isNow()+After(d time.Duration) <-chan time.Time;syscland theclocktestfake both implementAfter. SoFetchAndLockcan wake at the nearest futurenotBeforewithclk.After(until)— no timer per job, no clock interface change. - The binder seam exists to give the dispatcher the expression engine. The engine already binds its sink and
logger onto the dispatcher at startup via
tasks.SinkBinder/tasks.LoggerBinder(workerdispatcher.go:156-170, called inthresher.go:209-218). SRD-038 mirrors it with atasks.ExpressionEngineBinder. Job.Policyis a ready placeholder.tasks.Policy(workerdispatcher.go:61-65) is an emptystruct{}documented as the SRD-037/038 fill;JobcarriesPolicy *Policy(:69-74, the field at:71), populated nowhere yet (enqueueJob,jobs.go:155-156, sets only{ID, Topic, Input}). SRD-038 fillsPolicywith the resolved{ErrorMapper, RetryPolicy}and populates it at enqueue.
2. Requirements¶
Functional — dispatch reshape (M6)¶
- FR-1 — Classification moves to the dispatcher. A raw worker fault (
Fail) is classified in the dispatcher, on the reporting goroutine, via the resolvedJob.Policy.ErrorMapperand a bound expression engine — not on the track, not on the instance loop. This restores ADR-021 §2.7 ("only the terminal outcome reaches the loop").ServiceTask.classifyFaultis removed from the track. - FR-2 —
Job.Policycarries the resolved outcome policy; the instance resolves it at enqueue. The instance, inenqueueJob(loop goroutine), resolves the two-level policy — per-service (a newtasks.WorkerConfigexposed by the node) over engine-wide defaults — and populatesJob.Policy{ErrorMapper, RetryPolicy}.tasks.WorkerConfig(implemented byServiceTask,ok == falsefor an in-process task) surfaces the node's per-serviceErrorMapper/RetryPolicy.tasks.Policygrows from the empty placeholder to{ErrorMapper, RetryPolicy}. - FR-3 — The dispatcher receives the expression engine via a binder seam. A new
tasks.ExpressionEngineBinder(BindExpressionEngine(expression.Engine)), bound at engine startup exactly likeBindLogger/BindSink, gives the dispatcher the engine it needs to run theErrorMapper. A dispatcher that reaches an engine another way (a remote adapter) need not implement it. - FR-4 — The track's apply shrinks to terminals only.
execWorkerOutcomereceives only terminal outcomes:OutcomeComplete→bindOutput(+WithOutputMapping),OutcomeBpmnError→raiseBpmnError,OutcomeStatus→writeStatus,OutcomeFault→technicalFault(the terminal, retries-exhausted case — not re-classified). The track parks once and wakes once.
Functional — retry policy (M7)¶
- FR-5 —
RetryPolicyabstraction + batteries.pkg/tasks.RetryPolicydecides, given the attempt count and the technical cause, whether to retry and the backoff before the next attempt. Batteries-included:NoRetry,FixedDelay(maxAttempts, delay),ExponentialBackoff(maxAttempts, base, max, jitter). Default — 3 attempts, exponential backoff with jitter (count 3 = CamundadefaultNumberOfRetries; backoff improves on Camunda's zero-wait default, ADR-021 §2.7). - FR-6 — Two-level config. Engine-wide
WithWorkerRetryPolicy(p)(on the thresher/enginert config +renv.EngineRuntime.WorkerRetryPolicy()accessor) and per-serviceactivities.WithRetryPolicy(p); the per-service policy overrides the engine-wide default; absent both,DefaultRetryPolicyapplies. The instance resolves the effective policy at enqueue intoJob.Policy(soJob.Policy.RetryPolicyis always non-nil). MirrorsWorkerErrorMapper(SRD-037 FR-3) exactly. - FR-7 — Retry = dispatcher-side delayed re-enqueue (no sleeping goroutine). On a Technical classification
the dispatcher consults
Job.Policy.RetryPolicyagainst the entry's attempt count: if it retries, the dispatcher re-arms the existing store entry — unlock it, setnotBefore = clk.Now() + backoff, incrementattempt— without delivering (the track stays parked).lockNextskips a still-gated entry;FetchAndLockwakes at the nearestnotBeforeviaclk.After; a worker re-fetches after the backoff and the loop repeats. The engine holds no live call (a re-armed job + a parked track are both persistable, §2.7). - FR-8 — Retries-exhausted → incident-like
Failed, delivered to the instance. When the policy is exhausted, the dispatcher delivers a terminal technicalOutcomeFaultto the sink; the instance loop routes it to the track, which faultsFailing → Failedwith a diagnostic identifying the exhausted job (topic, attempts, last error). It is not an auto-raised catchable BPMN Error (§2.8) — infrastructure exhaustion is operational, not a model fault. The terminal reaching the instance (not the dispatcher's edge) is the incident-ready seam (§1.3); the first-class Incident construct (parked-hold + operator surface + dehydration) is a future ADR.
Non-functional¶
- NFR-1 (park-once). The track parks exactly once (
onJobWaiting) and wakes exactly once (on the terminal outcome). Intermediate retried faults never reach the instance loop orevtCh— no re-park. - NFR-2 (only-terminal-to-loop).
handleJobCompletionreceives only terminal outcomes; classification + the retry loop run in the dispatcher, before the instance loop (ADR-021 §2.7). The instance's role in a retried job is one enqueue (with the resolved policy) and one terminal. - NFR-3 (dehydration-friendly / incident-ready). Retry is a clock-gated re-arm — no per-job timer goroutine
(the fetch loop's single
clk.Afterwait), no sleeping goroutine; a re-armed (waiting-for-notBefore) job plus a parked track are both persistable (§2.7). The exhausted terminal reaches the instance (§1.3). - NFR-4 (technical-only). Only a Technical classification is retried; Success / Business Error / Business Status are terminal on first report — never retried (§2.7).
- NFR-5 (single-writer preserved). The instance loop stays the sole owner of the
jobsregistry and the sole sender toevtCh. The dispatcher's classify+retry runs on the worker's reporting goroutine over the dispatcher's own mutex-guarded store — it shares no state with the loop, and delivers terminals through the existing sink. - NFR-6 (gate). diff-coverage ≥95% on touched files;
make cigreen; the dispatcher classify/retry (clock-driven re-arm + timed wake) is-raceclean.
3. Models¶
3.1 RetryPolicy — pkg/tasks/retrypolicy.go (NEW)¶
// RetryPolicy decides whether a technical fault is retried and the backoff before
// the next attempt (ADR-021 §2.7). attempt is the 1-based number of the attempt
// that just failed; cause is its technical error.
type RetryPolicy interface {
Retry(attempt int, cause error) (backoff time.Duration, retry bool)
}
// Batteries: NoRetry (never), FixedDelay(maxAttempts, delay), and
// ExponentialBackoff(maxAttempts, base, max, jitter). DefaultRetryPolicy is
// ExponentialBackoff(3, base, max, jitter) — Camunda's count-of-3 with a
// non-zero backoff.
func NoRetry() RetryPolicy
func FixedDelay(maxAttempts int, delay time.Duration) RetryPolicy
func ExponentialBackoff(maxAttempts int, base, max time.Duration, jitter bool) RetryPolicy
func DefaultRetryPolicy() RetryPolicy
3.2 Policy filled — pkg/tasks/workerdispatcher.go (EXTEND)¶
The empty placeholder becomes the resolved outcome bundle the dispatcher consumes (and, under SRD-039
WorkerTrusted, ships to the worker):
// Policy is a worker-dispatched ServiceTask's resolved outcome policy. Under
// EngineAuthoritative (SRD-038) the dispatcher uses it to classify a raw fault
// (ErrorMapper) and drive retries (RetryPolicy); under WorkerTrusted (SRD-039) it
// is shipped to the worker. The instance resolves it (two-level) at enqueue.
type Policy struct {
ErrorMapper ErrorMapper // nil = raw faults fall through to the default Technical outcome
RetryPolicy RetryPolicy // always non-nil after resolution (DefaultRetryPolicy if unset)
}
OutputMapping/StatusVar are not in Policy — the track applies them and it holds the ServiceTask
directly (execWorkerOutcome is a *ServiceTask method), so they need no shipping. (SRD-039 revisits this if a
WorkerTrusted worker maps its own output.)
3.3 WorkerConfig — pkg/tasks (NEW), feeds enqueue¶
// WorkerConfig is implemented by a node whose worker outcome the engine
// classifies + retries. WorkerConfig returns the node's per-service policy
// (either field nil = fall back to the engine-wide default at resolution).
// ok == false for an in-process ServiceTask.
type WorkerConfig interface {
WorkerConfig() (errorMapper ErrorMapper, retryPolicy RetryPolicy, ok bool)
}
ServiceTask gains a retryPolicy tasks.RetryPolicy field (from WithRetryPolicy) and implements WorkerConfig
(returning st.errorMapper, st.retryPolicy, ok == workerTopic != "").
3.4 Dispatcher classify + retry — pkg/tasks/localdispatcher (RESHAPE)¶
jobEntry gains attempt int and notBefore time.Time; Dispatcher gains an exprEngine expression.Engine
(bound). Fail becomes the classify+retry locus (the other report methods stay terminal-only):
flowchart TD
F[Fail: raw Fault on the reporting goroutine] --> V{lock held & unexpired?}
V -->|no| E[return lock error]
V -->|yes| CLS[Classify: Job.Policy.ErrorMapper + bound exprEngine\nnil mapper → Technical]
CLS -->|BpmnError| MB[terminal NewWorkerBpmnError]
CLS -->|Status| MS[terminal NewWorkerStatus]
CLS -->|Technical| RP{Job.Policy.RetryPolicy.Retry attempt+1}
RP -->|retry, backoff| RE[re-arm entry: workerID=; notBefore=now+backoff; attempt++; broadcast]
RP -->|exhausted| MF[terminal NewWorkerFault]
MB --> DEL[remove entry; sink.ReportJobCompletion terminal]
MS --> DEL
MF --> DEL
RE -. worker re-fetches after notBefore .-> F
Classification runs outside d.mu while the entry stays locked by the reporting worker (its lock is still
valid, so no concurrent fetch); the decision (re-arm or remove) re-acquires d.mu. On re-acquire, Fail
re-validates the entry via the heldEntry invariant (still present, still held by the reporting worker) before
re-arm/remove — a lock that expired during a long classification is dropped rather than stomping a new holder.
That expiry is only reachable with multiple workers per topic; the shipped localdispatcher registers exactly
one worker per topic (RegisterWorker rejects duplicates) and runWorker calls Fail synchronously before its
next fetch, so no concurrent fetch can occur during the classify window in 0.1.x — the re-validation guards the
general Dispatcher seam (its crash-recovery reclaim path). Because the retry path must keep the entry to
re-arm it, Fail gets its own classify+retry path and stops delegating to report() (which removes the entry
before delivery); Complete/ReportBpmnError/ReportStatus keep using report(). Only the Fault kind is
classified — Complete is terminal as today; ReportBpmnError/ReportStatus (worker self-classified verdicts)
stay terminal pass-throughs (the local pool emits only Complete/Fail, so they matter for SRD-039).
3.5 lockNext gate + FetchAndLock timed wake — pkg/tasks/localdispatcher (EXTEND)¶
lockNext grows its return from (LockedJob, bool) to (LockedJob, time.Time, bool) — the middle value is the
earliest future gate across the scanned entries, captured under the same d.mu — so it can skip a re-armed entry
while gated and hand the fetch loop a wake time:
// in lockNext's per-entry scan, after the lock check:
if !e.notBefore.IsZero() && now.Before(e.notBefore) {
// track the earliest future gate for the caller's timed wake
if next.IsZero() || e.notBefore.Before(next) {
next = e.notBefore
}
continue
}
// FetchAndLock: when nothing is immediately available, also wake at the nearest gate.
var timer <-chan time.Time
if !next.IsZero() {
timer = d.clk.After(next.Sub(d.clk.Now()))
}
select {
case <-wake: continue // a job was enqueued
case <-timer: continue // a backoff gate elapsed (nil channel blocks forever)
case <-ctx.Done(): return nil, ctx.Err()
}
3.6 Instance enqueue resolves the policy — internal/instance/jobs.go (EXTEND)¶
enqueueJob resolves the two-level policy and populates Job.Policy; handleJobCompletion is unchanged (it
already delivers the — now always terminal — outcome to the track). The jobs registry stays map[JobID]*track.
// enqueueJob, after binding input:
em, rp := inst.resolveWorkerPolicy(ev.node) // per-service (WorkerConfig) over engine-wide over default
return inst.WorkerDispatcher().Enqueue(ctx, tasks.Job{
ID: jobID, Topic: topic, Input: input,
Policy: &tasks.Policy{ErrorMapper: em, RetryPolicy: rp},
})
// resolveWorkerPolicy: em may stay nil (→ default Technical); rp is never nil.
// em: node.WorkerConfig().errorMapper ?? inst.WorkerErrorMapper()
// rp: node.WorkerConfig().retryPolicy ?? inst.WorkerRetryPolicy() ?? tasks.DefaultRetryPolicy()
3.7 Track apply — activities.execWorkerOutcome (SHRINK)¶
The OutcomeFault case changes from classifyFault(...) to st.technicalFault(fault) (the terminal
retries-exhausted case); classifyFault is deleted from the ServiceTask. technicalFault's doc-comment
(service_task.go:521-522, currently "retry arrives in SRD-038") is refreshed — it is now the terminal
retries-exhausted apply, not a placeholder. bindOutput / raiseBpmnError / writeStatus / ProcessEvent are
unchanged.
4. Analysis¶
4.1 Classify in the dispatcher, before the loop (FR-1, NFR-2)¶
ErrorMapper.Classify reads the fault's {code, body} and takes the expression engine as a parameter, so it runs
on the reporting goroutine with the dispatcher's bound engine — no frame, no instance involvement. Placing it in
the dispatcher (not the instance loop) is the ADR's own wording ("only the terminal outcome reaches the loop",
"the engine runs the ErrorMapper") and is what lets the retry loop stay entirely off the track. Rejected:
classify on the instance loop (an earlier SRD-038 sketch) — it makes the instance run the dispatcher's
retry-orchestration (re-enqueue, attempt counting) per attempt; the user's design correction is that retry is
dispatcher work and the instance should see only terminals. Rejected: keep classify on the track (SRD-037 status
quo) — forces re-park per attempt and track-held retry state, the exact coupling this SRD removes.
4.2 The dispatcher owns classify+retry; ADR-004 remote is a later locus call (FR-1, scope)¶
For the in-process localdispatcher — the default and only transport in 0.1.x — the dispatcher owns classify+retry
directly: it already has the per-job store to re-arm, and gets the expression engine + resolved policy via the
binder seam + Job.Policy. A remote dispatcher (ADR-004) under EngineAuthoritative would either implement
the same behavior in its adapter or the engine grows a shared pre-loop classify locus — an ADR-004 transport
decision, not this SRD's. SRD-038 scopes to the local transport, matching the user's model ("the worker sends raw
data to the dispatcher, and the dispatcher decides re-publish vs escalate").
4.3 Retry = re-arm the existing entry, attempt count in the store (FR-7, NFR-3)¶
The backoff is a notBefore gate on the existing entry (unlock + gate + attempt++), not a re-enqueue round
trip and not a timer — so no goroutine sleeps and a waiting-to-retry job is persistable (§2.7). The attempt count
is retry state owned by the dispatcher's store (where the job already lives), not shipped to the worker and not held
by the instance. The fetch loop wakes at the nearest gate via clk.After — one bounded wait shared by all gated
jobs on a topic, not one timer per job. Rejected: a per-retry timer/goroutine — un-persistable, one goroutine per
retried job. Rejected: re-enqueue via Enqueue (remove + re-add) — needless index churn and a duplicate-id
window; re-arming the entry in place is simpler and keeps the job's identity and input.
4.4 Exhaustion is Failed and reaches the instance (FR-8, §1.3)¶
Exhausted retries are an operational condition (a stuck job), not a business fault, so they terminate the task
Failed with a diagnostic rather than raising a catchable errorCode — Camunda's incident distinction (§2.8).
Delivering that terminal to the instance (not resolving it at the dispatcher's edge) keeps the instance the
single owner of every terminal, which is the seam a future Incident construct needs to hold the track parked
(instead of faulting) and dehydrate it — unified with parked UserTasks and long timers. What lands here is the
terminal, observable Failed.
4.5 EngineAuthoritative; WorkerTrusted → SRD-039 (scope)¶
With the engine (its dispatcher) owning classification + retry and the worker returning raw {code, body}, this
is ADR-021 EngineAuthoritative. SRD-039 adds the WithWorkerTrust knob and the WorkerTrusted protocol (the
worker runs the shipped Job.Policy internally — maps its output, self-classifies, retries in-process holding its
lock — and reports a verdict the dispatcher forwards without re-classify/retry), plus the worked example. No ADR-004
dependency: ADR-004 is the remote transport for the same two protocols. Until SRD-039 lands the knob,
EngineAuthoritative is the implicit (only) mode.
5. API / contract surface¶
- New (
pkg/tasks):RetryPolicy+NoRetry/FixedDelay/ExponentialBackoff/DefaultRetryPolicy;WorkerConfiginterface;ExpressionEngineBinderinterface. Filled:Policy{ErrorMapper, RetryPolicy}. - New (
activities):WithRetryPolicy(p)SrvTaskOption;ServiceTask.WorkerConfig();retryPolicyfield. Removed from the track:ServiceTask.classifyFault. - New (engine config):
WithWorkerRetryPolicy(thresher + enginert) + aWorkerRetryPolicy()method added to therenv.EngineRuntimeinterface — forcing accessors onenginert.Runtimeandthresher.thresherConfig(theWorkerErrorMapper()mirror) and amockrenvregeneration (make gen_mock_files). - Reshaped (
localdispatcher):Dispatcher.exprEngine+BindExpressionEngine;Failclassify+retry;jobEntry.{attempt, notBefore};lockNextgate +FetchAndLocktimed wake. - Reshaped (
instance):enqueueJobresolves + populatesJob.Policy(handleJobCompletionunchanged). - New startup wiring (
thresher):BindExpressionEngine(cfg.ExpressionEngine())alongside the existingBindSink/BindLogger.
6. Test scenarios¶
Proposed (to be implemented in M6/M7; §10 records the actual landed names).
| Test | FR/NFR | Scenario |
|---|---|---|
TestDispatcherClassifiesFaultToBpmnError |
FR-1, FR-4 | a raw Fail classified by the dispatcher → the sink/loop receives a terminal BpmnError → the track raises it → boundary |
TestDispatcherClassifiesFaultToStatus |
FR-1 | a raw Fail mapped to Status → terminal Status → the track writes the status var and completes |
TestEnqueueResolvesTwoLevelPolicy |
FR-2, FR-6 | enqueueJob populates Job.Policy from per-service WorkerConfig over engine-wide over DefaultRetryPolicy; an in-process task enqueues nothing |
TestBindExpressionEngineSeam |
FR-3 | the engine binds its expression engine onto the dispatcher at startup; a fault classifies with it |
TestTrackAppliesTerminalOnly |
FR-4, NFR-1 | the track parks once / wakes once; a raw fault reaches the track only as a terminal (never re-classified there) |
TestRetryPolicyBatteries |
FR-5 | NoRetry never retries; FixedDelay/ExponentialBackoff retry to maxAttempts with the expected backoff; DefaultRetryPolicy = 3 attempts |
TestTechnicalFaultRetriesViaReArm |
FR-7, NFR-1/3 | a technical fault re-arms the entry with backoff; advancing the clock lets a worker re-fetch; the track stays parked across attempts; no delivery until terminal |
TestLocalDispatcherNotBeforeGate |
FR-7 | a re-armed entry with a future notBefore is skipped by FetchAndLock until clk.After fires at the gate |
TestRetryExhaustedFaultsWithDiagnostic |
FR-8 | after maxAttempts technical faults, the dispatcher delivers a terminal Fault; the task Failed with a topic/attempts/last-error diagnostic; not a catchable BpmnError |
TestBusinessOutcomeNotRetried |
NFR-4 | a fault classified BpmnError / Status is terminal on first report (RetryPolicy not consulted) |
7. Milestones¶
- M6 — dispatch reshape. Move classification into the dispatcher:
ExpressionEngineBinderseam +Dispatcher.exprEngine+ startup wiring; fillPolicy{ErrorMapper, RetryPolicy};WorkerConfig+ instanceresolveWorkerPolicy/Job.Policypopulation; classify inDispatcher.Fail(Technical stays terminal for now); shrinkexecWorkerOutcome(removeclassifyFault;OutcomeFault→technicalFault). One commit. - M7 — retry policy.
RetryPolicy+ batteries + default + two-level config (WithRetryPolicy/WithWorkerRetryPolicy+renvaccessor); the dispatcher re-arm loop (jobEntry.{attempt, notBefore},lockNextgate,FetchAndLocktimed wake); retries-exhausted → terminalFailedwith diagnostic. One commit.
8. Cross-doc¶
- Implements: ADR-021 v.1 §2.6 (
EngineAuthoritative: the engine classifies, the worker returns raw), §2.7 (retry by re-enqueue; "only the terminal reaches the loop"), §2.8 (retries-exhausted → incident-like Failed). The §2.10 sequence diagram places the retry loop inJQ(job queue / dispatcher) —W→JQ: Fail→JQ→JQ: re-enqueue with backoff until exhausted→JQ→IL: exhausted— with only the terminal crossing toIL(instance loop); SRD-038 realizes exactly that locus. - References (up / sideways): ADR-018 v.1 (Business Error boundary — the unchanged apply path), ADR-017 v.1 §2 (loop delivery / single-writer — the instance side stays within it), ADR-011 v.5 (data / expression), ADR-001 v.6 (execution model), SAD-001 v.1 §11, §13.2.
- Sibling SRDs: SRD-035/036/037 (Accepted); SRD-039 (
WithWorkerTrust+WorkerTrustedprotocol + worked example) forthcoming — ADR-021 flips Accepted after SRD-039. SRD→SRD sideways; pins by number. - Direction: SRD → ADR / SAD (up), SRD → SRD (sideways); no downward reference.
9. Definition of Done¶
- FR-1…FR-8 implemented and wired; NFR-1…NFR-6 upheld.
- Every FR covered by ≥1 named §6 test, all green under
-race(per-package coverage for thetasks/activities/localdispatcher/instancetouched code — the SRD-036 per-package-gate lesson). NFR-1/3/4 have dedicated rows; NFR-2 (only-terminal-to-loop) is asserted byTestTrackAppliesTerminalOnly; NFR-5 (single-writer) is a structural invariant that rides the-racegate (NFR-6). ServiceTask.classifyFaultremoved from the track with no stale callers; the dispatcher classifies every raw fault before delivery;handleJobCompletionreceives only terminal outcomes.make cigreen (tidy · lint · build ·-race· diff-coverage ≥95% on touched files · govulncheck).- SRD-038 flips to Accepted. ADR-021 stays Draft until SRD-039.
10. Implementation summary (stage-by-stage actual landings + deltas vs draft)¶
10.1 Stages by commit (branch feat/service-task-retry)¶
| Stage | Commit | Scope | Tests |
|---|---|---|---|
| Doc | 4534255 |
SRD-038 (this document) | — |
| M6 | d79208c |
Dispatch reshape: classify in localdispatcher.Fail; ExpressionEngineBinder + BindExpressionEngine + startup wiring; Policy{ErrorMapper, RetryPolicy}; WorkerConfig + resolveWorkerPolicy (ErrorMapper level) → Job.Policy; classifyFault deleted, execWorkerOutcome OutcomeFault → technicalFault |
TestDispatcherClassifiesFaultTo{BpmnError,Status}, TestDispatcher{UnmatchedFaultIsTechnical,FaultWithoutEngineDefaultsTechnical,FaultMapperErrorFallsBackTechnical}, TestBindExpressionEngineSeam, TestThresherBindsExpressionEngineToDispatcher, TestServiceTaskWorkerConfig, TestEnqueueResolves{PerServiceErrorMapper,FallsBackToEngineErrorMapper}, TestServiceTaskWorkerExecFaultsOnCause (terminal-fault apply) |
| M7 | 1367eee |
Retry policy: RetryPolicy batteries + DefaultRetryPolicy; two-level WithRetryPolicy / WithWorkerRetryPolicy (+ renv.WorkerRetryPolicy()); dispatcher re-arm loop (retryOrExhaust, jobEntry.{attempt, notBefore}, lockNext gate, FetchAndLock clk.After wake); exhaustion → terminal Failed |
TestRetryPolicy{NoRetry,FixedDelay,ExponentialBackoff,ExponentialCap,ExponentialJitter}, TestDefaultRetryPolicy, TestTechnicalFaultRetriesViaReArm, TestLocalDispatcherNotBeforeGate, TestRetryExhaustedFaultsWithDiagnostic, TestBusinessOutcomeNotRetried, TestRetryReValidatesExpiredLock, TestEnqueueResolves{PerServiceRetryPolicy,FallsBackToEngineRetryPolicy}, TestWith{RetryPolicy,WorkerRetryPolicy}RejectsNil |
make ci green at each milestone (tidy · lint 0 issues · build · -race · diff-coverage ≥95% on touched files · govulncheck); every new/changed function measured at 100% coverage.
10.2 Empirical findings — deltas vs the §3 draft¶
- Classification locus landed exactly as designed — in
localdispatcher.Fail(the dispatcher), not the instance loop.handleJobCompletionis unchanged; the only instance-side addition isresolveWorkerPolicyat enqueue (populatingJob.Policy). Confirms §1.1/§4.1. - Interfaces reached final shape in M6, wiring in M7 —
RetryPolicy,WorkerConfig,ExpressionEngineBinder, and bothPolicyfields were defined in M6 with the retry field inert, so M7 added no signature churn. Failstopped delegating toreport()for the retry branch — the technical-retry path must keep the store entry to re-arm it, so it re-acquiresd.muand re-validates (heldEntry); the business-verdict and exhausted branches still deliver viareport().- Re-validation guard is covered deterministically —
TestRetryReValidatesExpiredLockuses a clock-advancingErrorMapper(clockPokeMapper) to expire the lock mid-classify, exercising the drop-the-re-arm path the shipped single-worker-per-topic pool never hits. - SRD-037 track-side classification tests were relocated — the M4 tests that drove
classifyFaultat the track were removed frompkg/model/activities(the logic moved) and re-established at the dispatcher (classify_test.go) and instance (resolveWorkerPolicy) layers. - §6 test-name drift (all covered, no gaps): the proposed
TestEnqueueResolvesTwoLevelPolicylanded split per level (…PerServiceErrorMapper/…PerServiceRetryPolicy/…FallsBackToEngine{ErrorMapper,RetryPolicy});TestRetryPolicyBatterieslanded split per battery;TestTrackAppliesTerminalOnlyis realized byTestServiceTaskWorkerExecFaultsOnCauseplus the dispatcher classify tests.