Redis Lua Scripts for Atomic Idempotency State Machines

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
Why the branch has to happen server-side Two execution models on the Redis single thread. A client-side read followed by a write leaves a gap in which another client's commands interleave, so two callers can both observe an absent key. A Lua script containing the same read and write runs as one uninterruptible unit, so no interleaving is possible. client-side HGETALL peer interleaves HSET two winners possible Lua script HGETALL … HSET — one unit exactly one winner MULTI/EXEC cannot express this: it queues commands and cannot branch on what it read.

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.

The four scripts and the transitions they own Three states drawn as rounded boxes: absent, pending and completed. The register_or_get script moves absent to pending and returns the existing record otherwise. The complete script moves pending to completed and extends the expiry to the full time to live. The release script moves pending back to absent by deleting the key. The renew script refreshes the lease while a key stays pending. Each transition is annotated with the owner check that prevents a stale attempt from acting. absent no hash pending lease 60 s, owner set completed TTL 24 h, body stored register_or_get HGETALL + HSET, one unit complete owner must match release — DEL, owner must match renew — PEXPIRE while still working Every arrow is exactly one round trip and one atomic execution.

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
Hash tags keep a script legal on Redis Cluster A key is shown with braces surrounding the credential, route and key portion. Only the braced substring is hashed to a slot, so every key an operation touches lands in the same slot. A second example tags only the credential, which puts every key for the largest tenant on one shard and creates a hot slot. correct idem:v1: {cred:route:key} hashed → one slot hot slot idem:v1: {cred} :route:key one tenant, one shard

SRE / observability checklist

  1. idempotency_script_duration_ms — Histogram per script name. Alert if register_or_get p99 exceeds 15 ms; Lua runs on Redis’s single thread, so a slow script blocks everything.
  2. idempotency_noscript_total — Counter of NOSCRIPT fallbacks. Expected to spike at failover and return to zero; a steady rate means the script cache is being flushed by something.
  3. idempotency_stale_completion_rejected_total — Counter of owner-check failures in complete and release. Non-zero proves frozen workers exist and the check is protecting you.
  4. idempotency_lease_expired_while_pending_total — Counter from a sweeper comparing pending age against the lease. Any value means handlers are outliving their leases.
  5. idempotency_body_bytes — Histogram of stored response sizes. Alert if p99 exceeds 64 KB.
  6. redis_memory_used_bytes versus maxmemory — The capacity signal that predicts eviction, which on a deduplication store means silent loss of the guarantee.