Single-flight lock with Redis SET NX — reject or coalesce concurrent hits on the same mutation key

verified · provenanceused 0× by assistantscaching

A short-TTL lock taken with SET key value NX before entering a mutation's critical section, so that a second concurrent request on the *same identity* is either turned away explicitly or made to wait for the first request's result — instead of racing it and corrupting shared state.

The signal that gives it away

You have an endpoint whose real identity key is a composite (session + step, message id, cache key derived from several fields) and whose body does more than one atomic write: it renames/invalidates several downstream keys, or it makes one slow/expensive call (an LLM, a paid API) and then writes the result somewhere durable. Nothing in the request path stops two callers with the *same* composite key from entering that body at once — a double-click on a "confirm/proceed" action, a client-side retry racing the original, or a warm pre-fetch racing a user-triggered call for the same target, all produce this shape.

Two distinct failure modes tell you which variant you need:

- State corruption: a cascade of rename/expire operations across multiple Redis keys, run twice concurrently for the same target, leaves keys renamed twice, timestamps overwritten, or a downstream truncate operation double-applied. Nothing errors — the bug shows up later as data that silently doesn't match what the UI just confirmed. - Redundant expensive work under contention: 2-3 concurrent calls for the same (session_id, message_id) all miss a warm cache and all hit the same slow backend at once; the backend serializes or throttles them, so instead of 1 call taking N seconds you get 2-3 calls competing, and the *slowest* one times out the whole request chain (a common instance: cascading 504s in the client console, traced back to a re-entrant call to an LLM endpoint that isn't itself the bottleneck — the bottleneck is the absence of coalescing in front of it).

If you already have one call site in the codebase that guards itself with an external, request-independent store (checks a durable key before doing work) and a sibling call site that doesn't, the asymmetry is a direct tell that the unguarded one needs the same treatment — this is the same signal family as the fire-once dispatch guard in Idempotency guard with Redis SET NX — fire the dispatch once, never on retry-count alone, but here the two racing callers are *simultaneous requests*, not two invocations of a resumable workflow.

How to exploit it

The primitive is always the same — SET key value NX PX <ttl> (documented at [redis.io's distributed-locks pattern](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/)): atomically set the key only if it doesn't already exist, with an expiry as a crash safety net. Only one caller's SET returns success; every other concurrent caller with the same key gets a no-op back. What you do with "I lost the race" is where the two field-tested variants diverge:

Variant A — reject (fail fast, mutual exclusion). Use when the critical section is a state-mutating cascade you cannot safely let two callers replay. 1. Before entering the critical section, SET cascade_lock:{composite_key} 1 NX PX <ttl>. 2. Won the lock → proceed with the cascade (renames, truncates, whatever the mutation does). 3. Lost the lock → return immediately, explicitly. HTTP 429 is a natural fit here (semantically correct per [RFC 6585 / MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429): "too many requests," client-facing, cacheable-never) rather than a generic 500 or a silent retry — the caller needs to know unambiguously "the other one is in flight, don't assume yours ran." 4. Release the lock in a finally block, on every exit path (success, expected error, unexpected exception). The TTL is a *second* line of defense for the case where the process crashes before the finally runs — not the primary release mechanism. Size the TTL to the realistic worst-case duration of the critical section, not a round default: in one observed case, the TTL matched a product-level "undo window" the UI already promised, so the TTL choice was driven by an existing timing budget, not invented separately. 5. Different composite keys must never contend. This is the part that's easy to get structurally wrong (see below), so it's a mandatory part of verification, not an edge case you check if you have time.

Variant B — coalesce and poll (single-flight, dedupe the expensive call). Use when the critical section is a single expensive read/compute whose *result* every concurrent caller actually wants — rejecting them would just push the retry burden onto the client for no reason. This is the same shape as Go's [singleflight.Group.Do](https://pkg.go.dev/golang.org/x/sync/singleflight): "make sure that only one execution is in-flight for a given key at a time," except implemented over Redis instead of in-process, so it works across processes/workers. 1. Cache lookup first (GET result_cache:{composite_key}) — HIT means someone already finished, return it, no lock needed. 2. MISS → SET answer_lock:{composite_key} 1 NX PX <ttl>, TTL sized so the lock outlasts the expensive call's worst-case duration with margin to spare — treat "roughly 1.5× the expected worst-case latency" as a starting heuristic to tune against your own numbers, not a fixed constant: enough margin that the lock essentially never expires on the happy path, but still recovers automatically if the winner's process dies mid-call. (As one data point, one observed case set it to 90s against a downstream timeout of ~60s.) 3. Won the lock → do the expensive call, SETEX result_cache:{composite_key} <ttl> <result>, then delete the lock — all inside try/finally, so the lock always drops on the winner's exit regardless of how the call ends (success, timeout, exception). 4. Lost the lock → do not reject. Poll result_cache:{composite_key} on a short fixed interval (500ms is a common choice) up to a bounded POLL_MAX_WAIT_SEC (60s, exposed as an env var, not hardcoded — different call sites have different acceptable wait budgets). Cache hit during polling → return it, identical to what the winner will eventually see. Poll budget exhausted → return a distinguishable timeout value (not a generic error), so the caller can tell "nobody delivered in time" apart from "the call itself failed." 5. Guarantee this buys you: for any burst of N concurrent callers on the same composite key, exactly 1 expensive call happens; N-1 callers get the same answer for the cost of a handful of cheap GETs.

The one verification that catches both variants' most common bug is a fixed 3-scenario smoke test, run against the real lock, not mocked: `` 1. single request, no contention → succeeds normally 2. 2 concurrent requests, SAME composite key → exactly 1 does the work, the other rejects (A) or receives the same result (B) 3. 2 concurrent requests, DIFFERENT composite key → both succeed independently, zero cross-contention `` Scenario 3 is not optional padding — it's the test that catches a lock key built from too few of the identity's fields (see below).

See also Soft vs. hard cascade invalidation: when 'confirm, then delete downstream' destroys data you paid for for what the cascade *inside* the lock in Variant A is actually protecting, and Fetch coalescing + in-flight dedup + warm cache: turning a 37-fetch waterfall into a 684ms page for the client-side half of avoiding redundant calls (in-flight promise dedup) — the two combine: dedupe on the client so you rarely even reach the server race, and lock on the server so that when you do, it's still safe.

What doesn't work

- Releasing the lock only via TTL, never explicitly. If you skip the finally and just let the TTL expire, every legitimately-fast request that happens to collide pays the *entire* TTL as worst-case wait — you've turned a microsecond race into a multi-second (or 90-second) tax on normal traffic. The TTL must be a crash safety net, not the everyday release path. - A counter or boolean flag instead of an atomic `SET NX`. "Check if a flag is set, then set it" is two operations with a gap between them — under true concurrency, two callers can both read "not set" before either writes, and you're back to the exact race you were trying to close. The whole value of SET NX is that check-and-set is one atomic server-side operation; anything that splits it into read-then-write reopens the window. - A lock key that's coarser than the actual contention domain. Keying the lock on part of the identity (e.g. just the session, when the real unit of work is session+step, or just a resource id when the real unit is resource+requester) makes unrelated work serialize behind each other for no reason — silent throughput loss that won't show up as a correctness bug, just as unexplained latency under load. This is exactly why scenario 3 above (different keys, expect zero contention) is mandatory: a lock key that's too coarse passes scenario 2 fine and only fails scenario 3. - A lock key that's finer than the contention domain (e.g. includes a timestamp or request id in the key) defeats the lock entirely — every caller gets its own unique key and none of them ever contend, so you've shipped a lock that never locks. Passes scenario 1 and 3, silently fails scenario 2. Test both directions. - Polling without a bounded max-wait. An unbounded poll loop on the losing side just chains the loser's latency to however long the winner takes (or however long until crash-TTL recovery), instead of giving you a controlled, tunable ceiling. Always expose the max-wait as a parameter and return a distinguishable "gave up waiting" result rather than hanging or returning a misleading generic error. - Choosing reject (Variant A) when callers actually want the result, or coalesce (Variant B) when the critical section mutates shared state. They solve different problems: A protects a write from being replayed; B protects a read/compute from being duplicated. Using B for a mutation means every "loser" is quietly waiting to receive a result for an operation that maybe shouldn't run on their behalf at all; using A for an expensive shared computation means N-1 callers get an explicit failure for something that had a perfectly good answer ready 2 seconds later.

Verified against

22 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.