A deduplication check is a read that decides a write. Redis pipelines and MULTI/EXEC cannot express that, because neither can branch on what it read. Lua can, and it executes as a single atomic unit — which is the whole guarantee. This runbook builds the complete state machine from Redis cache-based deduplication as four scripts.
Prerequisites. Redis 7 or later (single node or cluster), a client library supporting SCRIPT LOAD and EVALSHA, and familiarity with the key lifecycle state machine.
Step 1 — Model the key as a hash
Key: idem:v1:{cred_7:POST/v1/charges:01HXYZ3ABC...}
Type: hash
Fields:
state pending | completed
fingerprint 64 hex chars (SHA-256 of the canonical body)
owner instance id + attempt nonce, for lease safety
status HTTP status code, present only when completed
body serialised response, present only when completed
A hash rather than a string, because the fields are read and written independently: registration writes state, fingerprint and owner; completion writes state, status and body without touching the fingerprint. A single serialised blob would force a read-modify-write on every transition.
Step 2 — register_or_get
-- register_or_get.lua
-- KEYS[1] = scoped key
-- ARGV[1] = fingerprint ARGV[2] = owner ARGV[3] = lease ms
-- Returns: {'fresh'} | {'hit', state, fingerprint, status, body}
local h = redis.call('HGETALL', KEYS[1])
if #h > 0 then
local m = {}
for i = 1, #h, 2 do m[h[i]] = h[i + 1] end
return {'hit', m.state or '', m.fingerprint or '', m.status or '', m.body or ''}
end
redis.call('HSET', KEYS[1],
'state', 'pending', 'fingerprint', ARGV[1], 'owner', ARGV[2])
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[3]))
return {'fresh'}
Nothing interleaves between HGETALL and HSET, which is the entire correctness argument. Doing the same two commands from the client leaves a window in which a second request also sees an empty hash, and both execute.
Step 3 — complete, release, renew
Each carries an owner check so a stale attempt — one whose lease expired while it was frozen — cannot overwrite a newer attempt’s work. This is the same reasoning as fencing tokens, applied inside the store.
-- complete.lua
-- KEYS[1] = scoped key
-- ARGV[1] = owner ARGV[2] = status ARGV[3] = body ARGV[4] = ttl ms
if redis.call('HGET', KEYS[1], 'owner') ~= ARGV[1] then
return 0 -- a newer attempt owns this key
end
redis.call('HSET', KEYS[1], 'state', 'completed', 'status', ARGV[2], 'body', ARGV[3])
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[4]))
return 1
-- release.lua — only the owner may delete, and only while still pending.
if redis.call('HGET', KEYS[1], 'owner') ~= ARGV[1] then return 0 end
if redis.call('HGET', KEYS[1], 'state') ~= 'pending' then return 0 end
return redis.call('DEL', KEYS[1])
-- renew.lua — extend the lease for a handler that is still legitimately working.
if redis.call('HGET', KEYS[1], 'owner') ~= ARGV[1] then return 0 end
if redis.call('HGET', KEYS[1], 'state') ~= 'pending' then return 0 end
return redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]))
The release script’s state check matters: without it, a slow attempt that finally fails could delete a key another attempt has already completed, resurrecting the whole operation.
Step 4 — Load once, call by SHA
import redis, hashlib
class LuaIdempotencyStore:
def __init__(self, client: redis.Redis, lease_ms=60_000, ttl_ms=86_400_000):
self.r, self.lease_ms, self.ttl_ms = client, lease_ms, ttl_ms
self._sha = {name: client.script_load(src)
for name, src in SCRIPTS.items()}
def _eval(self, name: str, keys: list[str], args: list) -> object:
try:
return self.r.evalsha(self._sha[name], len(keys), *keys, *args)
except redis.exceptions.NoScriptError:
# A failover or FLUSHALL emptied the server-side cache — reload and retry once.
self._sha[name] = self.r.script_load(SCRIPTS[name])
return self.r.evalsha(self._sha[name], len(keys), *keys, *args)
def register_or_get(self, key: str, fingerprint: str, owner: str):
r = self._eval("register_or_get", [key], [fingerprint, owner, self.lease_ms])
if r[0] == b"fresh":
return None
return {"state": r[1].decode(), "fingerprint": r[2].decode(),
"status": int(r[3] or 0), "body": r[4]}
EVALSHA sends 40 bytes instead of the whole script on every call, which matters at thousands of registrations per second. The NOSCRIPT fallback is mandatory, not defensive: a replica promoted during a failover starts with an empty script cache.
Step 5 — Make it cluster-safe
A Lua script may only touch keys in one hash slot. Force that with a hash tag around the parts that identify the operation.
def scoped(credential_id: str, route: str, key: str) -> str:
# Braces mark the slot-hashed substring. Everything the script touches for one
# operation lives in one slot, so the script is legal on Redis Cluster.
return f"idem:v1:{{{credential_id}:{route}:{key}}}"
# Verify the tagging maps a key to a single slot, and that the script runs.
redis-cli -c CLUSTER KEYSLOT 'idem:v1:{cred_7:POST/v1/charges:01HXYZ3ABCDEFGHJKMNPQRSTVW}'
redis-cli -c --eval register_or_get.lua \
'idem:v1:{cred_7:POST/v1/charges:01HXYZ3ABCDEFGHJKMNPQRSTVW}' , deadbeef owner-1 60000
If a future script needs a second key — an audit list, say — it must carry the same tag, or Redis rejects the call with CROSSSLOT.
Verification and testing
Confirm atomicity under real concurrency.
KEY='idem:v1:{t:POST/v1/charges:01HTESTCONCURRENCY000000}'
redis-cli DEL "$KEY" >/dev/null
seq 64 | xargs -P 64 -I{} redis-cli --eval register_or_get.lua "$KEY" , fp owner-{} 60000 \
| grep -c fresh
# expect: 1 — exactly one caller saw a fresh key
Confirm the owner check rejects a stale completion.
redis-cli --eval complete.lua "$KEY" , owner-999 201 '{"id":"ch_1"}' 86400000
# expect: (integer) 0 — a different owner cannot complete this key
redis-cli HGET "$KEY" state
# expect: pending
Confirm the lease actually expires.
redis-cli PTTL "$KEY" # expect a value under 60000
sleep 61 && redis-cli EXISTS "$KEY"
# expect: (integer) 0 — an abandoned pending key clears itself
Confirm the NOSCRIPT path works.
redis-cli SCRIPT FLUSH
python3 -c "from store import s; print(s.register_or_get('k','fp','o'))"
# expect: no exception — the client reloaded and retried
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
Script writes to a key derived inside Lua rather than passed in KEYS, so Redis Cluster routes it to the wrong node |
Pass every key through KEYS; never build key names inside the script. Run the suite against a 3-node cluster in CI so the violation fails at test time. |
redis_cluster_crossslot_errors_total; a startup lint that rejects string concatenation into redis.call key positions |
NOSCRIPT unhandled, so every request fails for minutes after a failover |
Catch NoScriptError, reload, retry once. Do not retry twice — a second failure is a real error. |
idempotency_noscript_total; a burst immediately after a failover event is expected and self-healing |
complete omits the owner check, and a stale attempt overwrites a newer attempt’s stored response |
Add the owner comparison as the first line of every mutating script, and assert it in the test above. | idempotency_stale_completion_rejected_total — a non-zero rate proves the check is doing work |
| Long-running handler exceeds the 60-second lease, so the key expires mid-flight and a duplicate executes | Call renew from a heartbeat while the handler works, or size the lease above the handler’s p999. Do not raise the lease past the client’s retry budget. |
idempotency_lease_expired_while_pending_total; handler_duration_seconds p999 against the configured lease |
| Response bodies stored without a size cap, and one 4 MB response per key exhausts Redis memory | Cap the stored body at 64 KB in the client before calling complete, storing a pointer for larger payloads. |
idempotency_body_bytes histogram; redis_memory_used_bytes against maxmemory |
SRE / observability checklist
idempotency_script_duration_ms— Histogram per script name. Alert ifregister_or_getp99 exceeds15 ms; Lua runs on Redis’s single thread, so a slow script blocks everything.idempotency_noscript_total— Counter ofNOSCRIPTfallbacks. Expected to spike at failover and return to zero; a steady rate means the script cache is being flushed by something.idempotency_stale_completion_rejected_total— Counter of owner-check failures incompleteandrelease. Non-zero proves frozen workers exist and the check is protecting you.idempotency_lease_expired_while_pending_total— Counter from a sweeper comparing pending age against the lease. Any value means handlers are outliving their leases.idempotency_body_bytes— Histogram of stored response sizes. Alert if p99 exceeds64 KB.redis_memory_used_bytesversusmaxmemory— The capacity signal that predicts eviction, which on a deduplication store means silent loss of the guarantee.
Related
- Redis Cache-Based Deduplication — the parent page covering the store’s guarantees, eviction policy and degradation behaviour.
- Using Redis SETNX for Distributed Request Deduplication — the simpler single-command form these scripts generalise.
- Redis Cluster vs Sentinel for Deduplication State — the topology decision that determines whether hash tagging is mandatory.
- Writing Idempotency Middleware for Express and Fastify — the middleware that calls these scripts.