Durable effects
A queue moves work. It does not move effects. Everything below is a way a system with a correct queue
still charges twice, ships twice, or reports success for something it killed.
The rule: claim the effect atomically before performing it, and make every terminal outcome a state you
can read back.
1. Delivery is not the problem. The effect is
signature verified → still not idempotent
lease reclaimed → still not exactly-once
retry with the same key → still a second charge, unless something refused it
A valid retry carries a valid signature and runs capture, fulfilment or entitlement again. Verification and
idempotency are two separate prior checks, and only one of them protects the effect.
Claim a logical effect key atomically before running the effect: treat the same event as a duplicate,
and reject a different event that reuses the key. In a multi-replica deployment the unique constraint in
the durable store is the boundary — not a check in application code, and not a cache.
2. Reclaiming a lease is a race you can lose quietly
A stale processing claim has to be reclaimable after a crash, or a dead worker blocks the queue forever. But
the previous holder may have completed the external side effect in the moment before it died.
The ledger can stop the duplicate state transition. Only an idempotency key on the external side makes the
external operation safe across that reclaim. If the downstream system has no such key, say so out loud: the
system is at-least-once, and the design has to tolerate it.
3. A lease is owned, and ownership can be lost
| Event |
Correct outcome |
| Handler still running, lease near expiry |
renew with the exact fencing token |
| Renewal fails |
abort the handler, report lease lost |
| Lease lost |
not an acknowledgement and not a failure — a third outcome |
| Job force-killed by an operator |
a durable cancelled state, lease cleared, late acknowledgements rejected |
Marking a killed job done hides the abort and lets reporting claim success. Cancellation is its own
terminal state, scoped to the tenant, and the worker's cooperative abort is a separate step that the queue
mutation must not imply.
Polling for cancellation is not enough on a long job: the visibility timeout can expire while the handler is
still working, and another worker picks up the same job.
4. Stopping is harder than starting
- Clearing a timer does not cancel an in-flight request. A pending receive or renew is still awaiting a
remote response, so a worker that closes its server after one pass gets an unhandled rejection later.
Track an active flag, check it after every await, and catch post-stop queue errors so shutdown
produces a fenced result instead of background noise.
- Killing a shell child is not killing the process tree it spawned.
- Retries need a terminal policy. An unbounded retry is an outage amplifier; a retry that gives up
silently is data loss. Name the maximum, name where an exhausted job goes, and make that queue observable.
5. Durable means the store, not the label
- A durable queue on top of a volatile store is volatile. The guarantee belongs to what persists the
message, not to the interface in front of it.
- A pub/sub notification channel is not a queue. It is useful for low-latency fan-out and it loses
messages across a disconnect, with a payload limit on top. Persist first, then notify, and reconcile.
- Bootstrap needs serialisation. A conditional
CREATE TABLE IF NOT EXISTS makes one statement
repeatable; it does not make a multi-step bootstrap safe when two fresh replicas start at once. Take a
transaction-scoped advisory lock and do the whole schema step inside it.
- Cross-replica retry cannot rely on a filesystem check. Use a database primary key, insert atomically,
and compare the stored digest on conflict.
5b. A batch with per-row outcomes
An import, an export, a bulk recalculation: one job, thousands of rows, each with its own fate. Five rules,
and the first one is where the data actually goes wrong.
- Counters are derived, never incremented.
increment() on a progress counter from parallel chunks is
a lost update on every collision, and the drift is invisible because the number still looks plausible.
Recompute with one aggregate over the rows themselves — they are the source of truth — after each flush,
each chunk, and each manual edit.
- Validation is orchestrated by the base, not overridden by the concrete type. Give it phases — the
declarative rules, then per-field checks found by convention, then the cross-field hook — so a new import
type can only fill in the parts, never replace the sequence and silently skip one.
- The row processor returns an identifier or throws. It does not record its own failure: the caller
owns the outcome, marks the row and moves on. A processor that writes its own error state and returns
normally makes a failed row indistinguishable from a successful one that produced nothing.
- Progress is broadcast at a bounded rate, not per row. A real-time event per row on a large file is a
denial of service you wrote yourself.
- Re-running protects what already completed. Delete and re-parse only the rows that are not in a
terminal state, skip the completed ones while parsing, and let the recount include them. Otherwise a
re-validation silently undoes work somebody already reviewed.
6. Event streams and replay
- An event id is not replay. Persist the event before publishing the notification, subscribe before
replaying, deduplicate across the overlap, and signal a missing or expired cursor explicitly instead of
returning an empty result. Replay lowers recovery cost; it does not replace refetching authoritative
state.
- A server-sent stream is a transport, not a durable event source. Reconnection converges from the
authoritative projection, not from what the stream remembers.
- Signature plus raw body plus clock. A webhook signature is computed over the exact bytes received, so
it must be verified before any parsing middleware rewrites them, and it needs a timestamp tolerance to
stop replays of a genuinely signed request.
7. Worker identity
A worker that can take any job can take any tenant's job. Require an explicit verifier or a deployment
credential before boot, compare it in constant time, keep it out of diagnostics, and enforce the tenant
scope inside dequeue and again on acknowledge — see padosoft-tenant-isolation. A static token is a
visible bootstrap fallback, never the production answer.
Gotchas
- An in-memory queue test proves your control flow and nothing about the engine: migration locks, lease
fencing and serialisation failures only appear against the real store. See
padosoft-evidence-boundaries.
- A timeout is not a negative result. The effect may have happened. Classify it as unknown and
reconcile against authoritative state before mutating anything else.
- Priority is not fairness. A strict priority queue starves the low-priority tenant indefinitely; decide
which one you meant.
- Recovery must not depend on the thing that failed. A cleanup job that only runs inside a healthy
worker never runs on the day it is needed.
- Emergency controls have to cross the worker boundary. A kill switch the workers cannot observe is a
switch for the dashboard.
Checklist
Final report
Path: <producer> → <queue/store> → <worker> → <effect>
Effect key: <what is claimed, where, atomically?> Duplicate handling: <…>
Lease: renew <…> · loss <…> · cancellation <state>
Retries: <max> → <destination> Terminal states: <list>
Store: <durable? what persists the message>
Identity: worker <…> · tenant scope at dequeue/ack: yes | no
Proven against the real engine: yes | no
1---2name: padosoft-durable-effects3description: Use this skill when work leaves the request and happens later — a background job, a queue, a worker, a webhook consumer, a scheduled task, a retry — and whenever the user reports that something ran twice, that a job was processed by two workers, that a retry charged or sent a second time, that a cancelled job still reported success, that a worker hung or exited with a late error, or that a job vanished. It covers lease and fencing semantics, exactly-once effects, terminal states, cancellation, and what a durable queue needs that an in-memory one does not. Do not use it to choose a queue technology, to tune throughput or worker counts, or for request-scoped transactions (padosoft-atomic-invariants covers those).4license: MIT5---67# Durable effects89A queue moves *work*. It does not move **effects**. Everything below is a way a system with a correct queue10still charges twice, ships twice, or reports success for something it killed.1112**The rule: claim the effect atomically before performing it, and make every terminal outcome a state you13can read back.**1415---1617## 1. Delivery is not the problem. The effect is1819```text20signature verified → still not idempotent21lease reclaimed → still not exactly-once22retry with the same key → still a second charge, unless something refused it23```2425A valid retry carries a valid signature and runs capture, fulfilment or entitlement again. Verification and26idempotency are **two separate prior checks**, and only one of them protects the effect.2728**Claim a logical effect key atomically before running the effect**: treat the same event as a duplicate,29and reject a *different* event that reuses the key. In a multi-replica deployment the unique constraint in30the durable store is the boundary — not a check in application code, and not a cache.3132## 2. Reclaiming a lease is a race you can lose quietly3334A stale processing claim has to be reclaimable after a crash, or a dead worker blocks the queue forever. But35the previous holder may have completed the external side effect **in the moment before it died**.3637The ledger can stop the duplicate *state transition*. Only an idempotency key on the external side makes the38external operation safe across that reclaim. If the downstream system has no such key, say so out loud: the39system is at-least-once, and the design has to tolerate it.4041## 3. A lease is owned, and ownership can be lost4243| Event | Correct outcome |44|---|---|45| Handler still running, lease near expiry | renew **with the exact fencing token** |46| Renewal fails | abort the handler, report *lease lost* |47| Lease lost | **not** an acknowledgement and **not** a failure — a third outcome |48| Job force-killed by an operator | a durable `cancelled` state, lease cleared, late acknowledgements rejected |4950Marking a killed job `done` hides the abort and lets reporting claim success. Cancellation is its own51terminal state, scoped to the tenant, and the worker's cooperative abort is a separate step that the queue52mutation must not imply.5354Polling for cancellation is not enough on a long job: the visibility timeout can expire while the handler is55still working, and another worker picks up the same job.5657## 4. Stopping is harder than starting5859- **Clearing a timer does not cancel an in-flight request.** A pending receive or renew is still awaiting a60 remote response, so a worker that closes its server after one pass gets an unhandled rejection later.61 Track an active flag, **check it after every await**, and catch post-stop queue errors so shutdown62 produces a fenced result instead of background noise.63- **Killing a shell child is not killing the process tree** it spawned.64- **Retries need a terminal policy.** An unbounded retry is an outage amplifier; a retry that gives up65 silently is data loss. Name the maximum, name where an exhausted job goes, and make that queue observable.6667## 5. Durable means the store, not the label6869- **A durable queue on top of a volatile store is volatile.** The guarantee belongs to what persists the70 message, not to the interface in front of it.71- **A pub/sub notification channel is not a queue.** It is useful for low-latency fan-out and it loses72 messages across a disconnect, with a payload limit on top. Persist first, then notify, and reconcile.73- **Bootstrap needs serialisation.** A conditional `CREATE TABLE IF NOT EXISTS` makes one statement74 repeatable; it does not make a multi-step bootstrap safe when two fresh replicas start at once. Take a75 transaction-scoped advisory lock and do the whole schema step inside it.76- **Cross-replica retry cannot rely on a filesystem check.** Use a database primary key, insert atomically,77 and compare the stored digest on conflict.7879## 5b. A batch with per-row outcomes8081An import, an export, a bulk recalculation: one job, thousands of rows, each with its own fate. Five rules,82and the first one is where the data actually goes wrong.8384- **Counters are derived, never incremented.** `increment()` on a progress counter from parallel chunks is85 a lost update on every collision, and the drift is invisible because the number still looks plausible.86 Recompute with one aggregate over the rows themselves — they are the source of truth — after each flush,87 each chunk, and each manual edit.88- **Validation is orchestrated by the base, not overridden by the concrete type.** Give it phases — the89 declarative rules, then per-field checks found by convention, then the cross-field hook — so a new import90 type can only fill in the parts, never replace the sequence and silently skip one.91- **The row processor returns an identifier or throws.** It does **not** record its own failure: the caller92 owns the outcome, marks the row and moves on. A processor that writes its own error state and returns93 normally makes a failed row indistinguishable from a successful one that produced nothing.94- **Progress is broadcast at a bounded rate**, not per row. A real-time event per row on a large file is a95 denial of service you wrote yourself.96- **Re-running protects what already completed.** Delete and re-parse only the rows that are not in a97 terminal state, skip the completed ones while parsing, and let the recount include them. Otherwise a98 re-validation silently undoes work somebody already reviewed.99100## 6. Event streams and replay101102- **An event id is not replay.** Persist the event *before* publishing the notification, subscribe before103 replaying, deduplicate across the overlap, and signal a missing or expired cursor explicitly instead of104 returning an empty result. Replay lowers recovery cost; it does not replace refetching authoritative105 state.106- **A server-sent stream is a transport, not a durable event source.** Reconnection converges from the107 authoritative projection, not from what the stream remembers.108- **Signature plus raw body plus clock.** A webhook signature is computed over the exact bytes received, so109 it must be verified before any parsing middleware rewrites them, and it needs a timestamp tolerance to110 stop replays of a genuinely signed request.111112## 7. Worker identity113114A worker that can take any job can take any tenant's job. Require an explicit verifier or a deployment115credential **before boot**, compare it in constant time, keep it out of diagnostics, and enforce the tenant116scope inside dequeue *and* again on acknowledge — see **`padosoft-tenant-isolation`**. A static token is a117visible bootstrap fallback, never the production answer.118119---120121## Gotchas122123- **An in-memory queue test proves your control flow and nothing about the engine**: migration locks, lease124 fencing and serialisation failures only appear against the real store. See125 **`padosoft-evidence-boundaries`**.126- **A timeout is not a negative result.** The effect may have happened. Classify it as *unknown* and127 reconcile against authoritative state before mutating anything else.128- **Priority is not fairness.** A strict priority queue starves the low-priority tenant indefinitely; decide129 which one you meant.130- **Recovery must not depend on the thing that failed.** A cleanup job that only runs inside a healthy131 worker never runs on the day it is needed.132- **Emergency controls have to cross the worker boundary.** A kill switch the workers cannot observe is a133 switch for the dashboard.134135## Checklist136137- [ ] The effect key is claimed atomically **before** the effect, with duplicates and key-reuse both handled138- [ ] Signature/authenticity verified as a separate, prior check139- [ ] Lease renewal uses the fencing token; lease loss is its own outcome, not an ack and not a failure140- [ ] Cancellation is a durable state; late acknowledgements rejected141- [ ] Shutdown checks its active flag after every await, and catches post-stop errors142- [ ] Retry policy bounded, with a named destination for exhausted work143- [ ] The durable store is what persists the message; notifications are not the queue144- [ ] Bootstrap serialised under a transaction-scoped lock145- [ ] Replay: persist before publish, subscribe before replay, explicit missing-cursor signal146- [ ] Webhooks verified on the raw body, with a clock tolerance147- [ ] Worker identity required at boot; tenant scope enforced at dequeue and at ack148- [ ] The journey exercised against the real engine at least once149150## Final report151152```153Path: <producer> → <queue/store> → <worker> → <effect>154Effect key: <what is claimed, where, atomically?> Duplicate handling: <…>155Lease: renew <…> · loss <…> · cancellation <state>156Retries: <max> → <destination> Terminal states: <list>157Store: <durable? what persists the message>158Identity: worker <…> · tenant scope at dequeue/ack: yes | no159Proven against the real engine: yes | no160```