Skip to main content

redis-fifo-lock

A distributed FIFO mutex on Redis. Callers are served strictly in the order Redis saw them arrive, and a holder whose process dies is detected and replaced.

from redis_fifo_lock import FifoLock

lock = FifoLock(redis_client, "invoices")

async with lock.hold() as lease:
    # exactly one caller is here at a time
    await do_work(fence=lease.fence)

Or acquire and release by hand, when the two happen in different places:

lease = await lock.acquire(timeout=30)  # asyncio.TimeoutError if it runs out
try:
    ...
finally:
    await lock.release(lease)  # raises LeaseLost if we no longer held it

What it guarantees

  • Mutual exclusion — at most one caller inside the critical section, as long as the holder's process can run a coroutine at least once per lease_ms and reach Redis.
  • Strict FIFO by arrival at Redis. Not by the order callers started: that is not knowable from the server and never can be.
  • Crash recovery within lease_ms, independent of how long the lock is held. A ten-minute critical section and a one-minute crash-detection window coexist, because a live holder renews its own lease.
  • Idempotence. A retried acquire keeps its place in the queue rather than going to the back; a retried release does not report a false alarm.
  • Loud failure. release() raises LeaseLost if the lock was no longer ours, and lease.lost is set the moment a renewal is refused — so a holder can find out during the critical section, not after.

What it refuses to promise

  • Exclusion under network partition, SIGSTOP, VM pause, or any freeze longer than lease_ms. The holder keeps running while its lease expires underneath it. No lease-based lock solves this — not this one, not Redlock, not any other. The only real defence is to pass lease.fence to whatever you are protecting and have that reject stale tokens. This library cannot enforce that for you, and does not pretend to.
  • Anything across a Redis failover that loses writes, including FIFO order. If you need that, you need consensus — etcd or ZooKeeper.
  • Bounding a holder that is alive but stuck. It is indistinguishable from honest slow work. max_hold_ms caps how long the lock can be renewed, so a caller that vanishes cannot wedge it forever, but that is a backstop and not a guarantee about live holders.
  • Redis Cluster. All keys share a hash tag so they land in one slot, but the scripts write a key computed from arguments rather than declared up front, which is formally undefined. Untested, unadvertised.

Configuration

FifoLock(
    redis_client,
    name,  # required — no shared default
    lease_ms=60_000,
    poll_ms=30_000,
    max_hold_ms=1_800_000,
)

name is required deliberately. A shared default means two unrelated services on one Redis silently share a lock.

lease_ms is how long a grant survives without renewal. It bounds crash detection, not how long you may hold the lock — but it must exceed the longest stretch during which your holder cannot run a coroutine: a blocking call, a GC pause, CPU throttling. This is the one setting whose value can cost you correctness. If the renewal task is ever starved, the library logs a warning naming the lock; treat that as a signal to raise this or to stop blocking the loop.

poll_ms is only latency. A waiter is normally woken immediately; this is the backstop for a wake-up that was lost. Raising it cuts steady-state load roughly linearly — at 10,000 waiters, 2.5s costs about 4,000 script calls a second where 30s costs about 330.

max_hold_ms caps total renewal. It exists for the caller that disappears without its process dying — an abandoned job, an exception between acquire and release. Set it above your longest legitimate critical section. Without it, one abandoned caller holds the lock until the process restarts.

Requirements

  • Redis must not evict the lock's keys. The lease key carries a TTL, which makes it a preferred victim under any volatile-* policy — and evicting it silently frees a held lock while leaving the queue intact, so nothing looks wrong. Amazon ElastiCache ships volatile-lru by default, where Redis itself defaults to noeviction. The library checks on first use and warns if the policy is unsafe — it does not refuse, because losing the lease key makes the next renewal fail, so the holder finds out within one beat and release() raises. Prefer noeviction on that instance, or give the lock its own.
  • Budget a connection per waiter. A waiter holds one pooled connection while it blocks, and redis-py's default pool is 100 — the 101st concurrent waiter in a process fails with MaxConnectionsError, not a timeout. Raise max_connections, or cap concurrency.
  • Do not share an async client across event loops. A client cached in a process global outlives the loop its connections were made on, and the next loop gets RuntimeError: Event loop is closed. This bites anything using async_to_sync (Celery, Django management commands): build the client per loop.

How it works

Five keys, all sharing one hash tag: a sorted set holding the queue, a string whose existence is the lock, two counters, and a record of the last release.

Every state transition is a single Lua script, so the decision and the act cannot be separated by a round trip. Acquiring enqueues and grants in one call; releasing frees and hands off in one call. That is the whole design, and it is the fix for the defect that motivated the rewrite: the previous implementation read the queue in one round trip and dispatched in another, and concurrent callers slipped into the gap.

A waiter is woken by a doorbell, but the doorbell is only an optimisation — the poll is authoritative, and the lock is correct with the doorbell removed entirely. A holder renews its own lease in the background, which is what lets crash detection be fast without capping how long you may legitimately hold the lock.

Testing

uv run --all-extras pytest                  # fast, no Redis, ~0.1s
uv run --all-extras pytest -m adversarial   # real Redis, randomized, ~2.5min

The adversarial suite is the real coverage. It attacks mutual exclusion and FIFO ordering directly — a shared record of who is inside the critical section, never a bookkeeping proxy — with long holds, crashes, cancellations, duplicate releases and retry storms.

Its predecessor shipped a mutual-exclusion bug that survived eight months and 88 green tests, because every concurrency test held the lock for 100ms against a 5000ms wake interval, so no waiter ever woke while a holder was inside. Every test here was demonstrated failing against that implementation before it was trusted. A test that cannot fail is not evidence.

Release files for redis-fifo-lock 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for redis-fifo-lock 0.3.0
File Size Uploaded
redis_fifo_lock-0.3.0.tar.gz 20.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for redis-fifo-lock 0.3.0
File Interpreter ABI Platform
redis_fifo_lock-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 43.6 kB

Release files / redis_fifo_lock-0.3.0.tar.gz

Download URL redis_fifo_lock-0.3.0.tar.gz
Size 20.7 kB
Tags Source
SHA-256 checksum
How to use checksums
80a501005afd116ab3f48a91aaa594a38c1f17e0b1f167163055e847f52ee06d
BLAKE2b-256 checksum
How to use checksums
eedb36d710953acfcfdc783a772b6d832a2f68c5f630af38badce6c1e387e4ae
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 30, 2026.

Transparency log

Release files / redis_fifo_lock-0.3.0-py3-none-any.whl

Download URL redis_fifo_lock-0.3.0-py3-none-any.whl
Size 22.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dc03678d28079f8f7df47ac73a6f811d2ab429e381c2d68da818aaac63954232
BLAKE2b-256 checksum
How to use checksums
455e121db5ffe9e78c7042557a53cc2d7bd1d226be5b531a0406a10537b54cff
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 30, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page