Effects in production
Lanes, the five states, the journal, and the four ways out of a wedge.
This is the page to read before deploying an effect. Everything here is about what happens after the code is right.
Lanes
One reader thread per effect owns its subscription; the invocations run on a worker pool
shared across every effect, bounded by [effects] pool_size (default 16).
An arm’s @key names its lane. One lane processes in log order, and different lanes never
wait on each other.
A failing invocation records itself, parks its lane until its backoff expires, and
returns its worker. So a wedged lane costs a map entry rather than a thread, and even
pool_size = 1 gets the isolation. One unprocessable event stalls one key, not the effect.
position is a low-water mark
This is the single most misread number in the runtime.
position is the highest position every lane has passed. A wedge at position 3 reads as
position: 2, lag: 3 even when positions 4 and 5 in other lanes finished long ago.
So lag on a partitioned effect does not mean the effect is behind. It usually means one lane is stuck and everything else is fine.
| Field | Says |
|---|---|
pinning_key |
which lane is holding the watermark down |
pinning_position |
where it is stuck. This is the position a skip takes |
wedged_lanes |
how many lanes are stuck |
consecutive_failures |
the pinning lane’s attempts, not a count of lanes |
last_error |
the failure, carrying the arm’s own source location |
retry_in_ms |
how long until the next attempt |
terminal_skips |
work abandoned deliberately and advanced past |
The five states
Derived in this precedence order:
| State | Means |
|---|---|
quarantined |
verify mode found a divergence. Nothing clears it on its own |
blocked |
an arm’s @key changed while lanes were outstanding. Nothing is retrying |
wedged |
a lane is retrying under backoff |
lagging |
below the log head, no failures |
healthy |
caught up |
Quarantine outranks the rest because one restored from an earlier process has a zero failure
count. blocked outranks wedged because nothing is retrying. A wedge outranks lag because
a wedged effect lags precisely because it is wedged.
Two retry loops
They are deliberately separate, and knowing which is which saves an afternoon.
heklang re-sends on a transport error and on every status that clears on its own: 408,
425, 429 and any 5xx. Those never reach your arm, which is why status >= 400 inside an arm
is always a real decision.
hekla retries the invocation when that loop is exhausted or a host call fails: capped exponential backoff from 200 ms doubling to 60 s, forever, replaying journaled calls each attempt so nothing already performed fires again.
A Retry-After on a retryable response raises the invocation’s backoff, capped at five
minutes, and never lowers it. The header never reaches a program.
One HTTP attempt is capped at 10 s to connect and 30 s overall. Neither is configurable.
The journal
Every impure call looks itself up first. A recorded result comes back; otherwise the call is performed and its result appended.
Rows commit call by call in autocommit, never one transaction per invocation. That is what makes a crash mid-arm recoverable.
The key is the call itself, for HTTP the verb, URL and body, plus an ordinal separating identical repeated calls, stored as a sha256. It is not a sequence number, so editing or reordering an arm does not corrupt replay: the failure mode of editing during a deploy is “a different path was taken”, not “a payment fired twice”. A duplicate needs an edit to the URL or body of a call that already fired.
log(...) and reveal(...) are not journaled. A duplicated log line is harmless; a
replayed reveal would serve stale plaintext against a destroyed key.
Arguments are hashed and never stored, so a journal cannot outlive the erasure it belonged to.
Retention, and why a wedge is urgent
A sweeper runs hourly and deletes completed invocations older than
[retention] effect_journal_days (default 7), bounded by the effect’s persisted
watermark. Positions above it are exactly what the next boot would replay, and reclaiming
them would let their side effects fire twice.
An effect that has never persisted a watermark is never swept at all.
Getting past a wedge
- Look.
pinning_keynames the lane,pinning_positionis where. Readlast_error, then the journaled calls atGET /admin/effects/{Name}/invocations/{pinning_position}. The first missing call is where it is stuck, which is why that list pages. - Fix the cause and restart. The running invocation replays, and every completed call comes back from the journal instead of firing again.
- Skip it, if the event is genuinely unprocessable:
Honoured only for a position that has already failed. Several can be pending at once, one per wedged lane, and a request the watermark passes is forgotten. Nothing is ever skipped automatically.curl -X POST localhost:8080/effects/NotifyCustomer/skip/412 - Erase the subject a
revealneeds. That turns the failure terminal, which also unwedges it.
Terminal failures
Two things complete a position instead of retrying it: the author’s own fail(...), and a
reveal of an erased subject. Both increment terminal_skips and set
last_terminal_error.
That is the right behaviour for both. Neither can succeed on a later attempt.
blocked
An arm’s @key changed while lanes were still outstanding, so the per-lane rows above the
watermark are keyed under a scheme the new key never produces. last_error names the event
types whose lane moved and how many lanes are outstanding.
Two ways out, and they are not equivalent:
Redeploy the previous key and let it drain. Watch until wedged_lanes and lag are
both 0, then deploy the new key. This costs nothing.
hekla rewind <Effect> <watermark> against a stopped process. It discards the lane rows
and the recorded invocations above the watermark, so those positions run again and
perform their side effects again. An escape hatch, not the alternative.
hekla plan reports the repartition before the deploy, so this is avoidable rather than
something to discover at boot.
on latest
An on latest arm runs once per key per batch, at the newest matching position in it.
Catching up, a key’s whole backlog is one invocation. It is not “skip history”: that one
invocation folded the whole prefix, so it acted on the final state.
latest_collapsed counts what it folded, which is why such an effect’s lag can fall by far
more than its invocation count rises.
rewind
hekla rewind NotifyCustomer 0 .
Moves an effect back to a position so it reprocesses everything after it and performs
those side effects again. It refuses while a server holds the data directory, prints what
it would discard, and asks before doing it. --yes answers the prompt without silencing the
summary; without a TTY and without --yes it refuses.
--live also lowers an on live boundary, and is permanent. It is off by default because
the author of an on live arm declared that history is not news, so overriding that should
be something you typed.
Rewinding to the current watermark is allowed and is not a no-op: that is the escape hatch a
blocked effect needs.
There is deliberately no HTTP equivalent. An on live effect is by definition one where
an accidental rewind re-sends every notification the log has ever seen.
Deploys
Graceful shutdown stops dispatching and waits up to 30 seconds for in-flight invocations, so
the common deploy has nothing in flight. One abandoned by the timeout stays running and
replays at the next start. An in-flight invocation whose recorded source hash differs from
the code on disk is logged by name at startup.