R3.02.2 · At-least-once — dive 2
Idempotent consumers.
Score twice, scored once. At-least-once delivery hands the consumer a job that may arrive again; an idempotent effect runs once however many times the job arrives.
The fix for a duplicate delivery is not exactly-once transport — that is impossible
— but an idempotent effect: running the same job twice produces the same result as once. A codemojex guess
is the cleanest case: scoring the same emojis writes the same GES, and the one accumulating part,
the per-game attempt count, takes a marker. Select a delivery count below, then read the GES write
and the attempt count hold.
Static verdicts — any delivery count → 1 GES (the write is naturally idempotent) and 1 attempt (the marker guards the count).
Naturally idempotent effects
reliable-queue.md.txt · the idempotency lineAn effect is idempotent when applying it twice changes nothing the second time. SET key value is
the clearest case: it overwrites to the same value however many times it runs, so a redelivered job that sets
the same value is harmless. Set membership is another — adding a member already in the set is a no-op.
Writing a row by a fixed primary key with an upsert is idempotent; so is sending mail keyed by a dedup id the
provider honours.
A naturally idempotent consumer needs no extra machinery. The redelivery runs the effect again, the effect
produces the same state, and at-least-once delivery has already become an exactly-once effect at no cost. The
discipline is to recognise which effects are naturally idempotent and lean on them — choosing a
SET over an INCR, an upsert over a blind insert — before reaching for a
marker.
# naturally idempotent — a redelivery changes nothing SET game:G:secret <value> # run once or twice: the secret is the same either way SADD room:R:players PLR0... # add a member already in the set: a no-op
The cheapest idempotency is the effect that is already idempotent: a SET or a set add
absorbs a redelivery for free. Reach for those before reaching for a marker.
Non-idempotent effects need a marker
redis.io/commands/set · NXThe hard cases are effects where the second run does change the state. INCR counter is
the canonical one — two deliveries increment it twice. A card charge, a row insert with a fresh id, an
outbound message without a dedup id: each accumulates on every redelivery. A non-idempotent consumer
double-counts on the duplicate the previous dive proved is structural.
The guard is a marker keyed by a stable id. Before the effect runs, claim the marker with SET …
NX — set only if absent. If the claim succeeds, this is the first delivery: run the effect. If the
claim fails, the marker is already set, so the effect already ran on an earlier delivery: skip it. The marker
carries the exactly-once decision that the effect itself cannot.
Hover or select an effect below to compare a naturally idempotent SET against an accumulating
INCR over two deliveries; the readout names where the marker is needed.
Static verdicts — SET → same value both deliveries, no marker · INCR with no marker → counter = 2 · INCR under an NX marker → counter = 1.
# a non-idempotent effect, guarded by an NX marker keyed by the stable job id if SET seen:JOB0... 1 NX PX 86400000: # claim the marker; NX = only if absent count_the_attempt() # first delivery: count once else: skip() # a redelivery: the marker is set, do nothing
A naturally idempotent effect is redeliver-safe as-is; an accumulating one is not, and an
NX marker keyed by the job id is what makes it so — best-effort, not a transaction.
The worked example — a codemojex guess
echo/apps/codemojex · ScoreWorkerA codemojex guess is the cleanest illustration. A player submits six emojis;
Codemojex.Guesses.submit/3 mints a branded JOB id, charges the wallet, and enqueues
the job on the player's lane (Lanes.enqueue). The scoring consumer is the authority:
Codemojex.ScoreWorker.handle/1 drains the lane, reads the game's secret through the cache, scores
the six emojis with the pure linear engine, and writes a GES guess. The scoring itself is
naturally idempotent — the same secret and the same six emojis produce the same score every time the job
runs.
The one part that accumulates is the per-game attempt counter, an INCR on
cm:<game>:attempts. Counting an attempt on every redelivery would over-count. That is the
INCR-shaped effect, and a marker keyed by the stable JOB id is what makes it
redeliver-safe: claim seen:<JOB> with SET … NX; on the first delivery
the claim succeeds and the attempt is counted once; on a redelivery the claim fails and the count is skipped.
The score rides on its natural idempotency; the attempt count rides on the marker. Score twice, scored
once.
ScoreWorker carries the discipline at its boundary too: a guess for an unknown game answers
:ok — a drop, never a retry loop. A redelivery that no longer maps to a live game settles
cleanly rather than looping, because the consumer treats the same identity the same way each time it
handles it.
# Codemojex.ScoreWorker.handle/1 — score the guess, write a GES (real, trimmed) def handle(%{id: job_id, payload: payload, group: player}) do {:guess, game, ^player, emojis} = :erlang.binary_to_term(payload) case Cache.fetch_game(game) do %{secret: secret} = g -> s = Scoring.score(secret, emojis) # naturally idempotent: same secret + emojis -> same score gid = EchoData.BrandedId.generate!("GES") Store.put_guess(gid, %{game: game, player: player, emojis: emojis, points: s.total}) Cmd.incr("cm:" <> game <> ":attempts") |> Wire.run(conn) # accumulating — guard with a marker _ -> :ok # unknown game: a drop, never a retry loop end end
Absorb at-least-once redelivery by making the consumer's effect idempotent — natural idempotency
where the effect allows it, an NX marker where it does not.
The scoring is naturally idempotent (same secret and emojis, same GES), the per-game attempt
count is guarded by an NX marker keyed by the JOB id, and an unknown game answers
:ok — so scoring a guess twice equals scoring it once.
Score twice, scored once: the GES write absorbs the redelivery, the marker guards the
attempt count, and an unknown game settles to :ok rather than looping.
A note on producer-side admission
echo_mq/jobs.ex · enqueueEchoMQ refuses a duplicate at enqueue, and it is a different mechanism from the consumer marker
above. EchoMQ.Jobs.enqueue/4 runs one idempotent script whose first guard after the kind check is
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end — two enqueues of the same branded
JOB id collapse to one row, and the host reads the 0 as {:ok,
:duplicate}. That is producer-side admission; it stops two enqueues of the same id
from both creating work. It does not make a single in-flight job exactly-once at the consumer, because the
recovery path can still redeliver a job that entered the queue once. The consumer marker in this dive is
claimed at effect-time, keyed by the job's identity, and is what absorbs that redelivery. Two markers,
two moments — the third dive separates them in full.
Notes on ValkeySET … NX PX <ms> claims the marker
and gives it an expiry in one command, so the guard cannot leave an immortal key if the consumer dies between
two commands. valkey.io/commands/set
The lifecycle the consumer settles into
The acknowledge/finish lifecycle that closes an in-flight job out — Jobs.complete, the
attempts fence, the lane the worker drains — is the EchoMQ Queue pillar.
This dive teaches the idempotent consumer; that pillar teaches the lifecycle the consumer acknowledges into.
References
sources · relatedSources
- Redis — SETthe
NXandPXoptions behind the effect-time idempotency marker - Valkey — SADDthe set add whose membership is a no-op on a redelivery
- Redis — Redis queuethe queue and its delivery-guarantee overview
Related in this course
- R3.02 · At-least-oncethe module hub
- R3.02.1 · At-least-once semanticswhy the duplicate the marker absorbs is structural
- R3.02.3 · Why exactly-once is a lieproducer admission versus consumer idempotency
- R3 · The reliable queuethe family in one place
- R2.01 · Atomic updatesthe atomic SET … NX claim under the marker