Skip to content

Distributed Locks

Concurrent PATCH/DELETE on the same upload must not interleave — two racing PATCHes at the same offset can otherwise write chunk bytes and advance the offset out of order and corrupt the committed data. LockBackend serializes those writes per upload_id.

TusServer defaults to an in-process InMemoryLockBackend, so a server embedded in a threaded or async host (FastAPI, threaded WSGI) is safe within one process out of the box. Multi-instance deployments — Kubernetes replicas, ALB-fronted servers behind shared cloud storage — need cross-host coordination and must pass a distributed RedisLockBackend. (TusServerCore has no default lock.)

Enabling

TusServer already installs InMemoryLockBackend; pass lock_backend only to tune the timeouts, switch to Redis, or opt out:

from resumable_upload import SQLiteStorage, TusServer
from resumable_upload.locks import InMemoryLockBackend

server = TusServer(
    storage=SQLiteStorage(),
    lock_backend=InMemoryLockBackend(),  # the default; shown for the timeout tuning
    lock_ttl_seconds=60.0,  # auto-release if the holder crashes
    lock_wait_seconds=5.0,  # 423 Locked when contention exceeds this
)

# Opt out entirely (no serialization — only safe if writes can never race):
server = TusServer(storage=SQLiteStorage(), lock_backend=None)

When a lock_backend is set, every PATCH and DELETE acquires a key keyed by upload_id before performing the write. Contention beyond lock_wait_seconds returns 423 Locked — the standard "try again later" signal.

Backends

InMemoryLockBackend

The default for single-process deployments. Per-key expiry plus a global mutex. Replaces nothing in SQLiteStorage's flock, but extends coverage to write paths that don't go through the file system (e.g., cloud storage backends).

RedisLockBackend

For multi-host deployments. Requires the [redis] extra:

pip install resumable-upload[redis]
import redis
from resumable_upload.locks.redis_lock import RedisLockBackend

client = redis.from_url("redis://localhost:6379/0")
server = TusServer(
    storage=...,
    lock_backend=RedisLockBackend(client),
    lock_ttl_seconds=60.0,
    lock_wait_seconds=5.0,
)

The old resumable_upload.locks_redis path (deprecated since 0.0.6) has been removed.

Implementation: SET key token NX PX <ttl> for atomic acquire; a Lua script ("delete only if I'm still the holder") for atomic release. Works against any Redis deployment, including Cluster (the script operates on a single key).

The Redis client is yours to bring — the backend does not configure connection pooling or retry policy. RedisLockBackend.release() is idempotent and swallows transient errors, by contract.

Custom Backends

Subclass LockBackend to plug in an alternative coordinator (Zookeeper, etcd, Postgres advisory locks):

from resumable_upload.locks import LockBackend


class MyLockBackend(LockBackend):
    def acquire(self, key: str, ttl_seconds: float, wait_timeout: float = 0.0) -> str | None:
        # Return a non-empty token on success, None on contention/timeout.
        # wait_timeout=0.0 MUST return immediately — see below.
        ...

    def release(self, key: str, token: str) -> None:
        # Idempotent: silently succeed on stale tokens or unknown keys.
        ...

The acquire token is a capability for release — the server passes back exactly the value it received. Implementations must enforce ttl_seconds so a crashed holder's lock eventually expires without external intervention.

wait_timeout=0.0 must not block

This is a hard requirement, not a convention. LockBackend.acquire_async — which the async server path (handle_request_async, TusASGIApp) uses — bridges your sync acquire by polling it with wait_timeout=0.0 and awaiting between attempts. A single attempt hops to a worker thread; the waiting happens on the event loop.

Note that this is the opposite of some familiar conventions, where a zero or absent timeout means "block until acquired" (threading.Lock.acquire(timeout=-1), redis-py's Lock(blocking_timeout=None)). If your acquire blocks on wait_timeout=0.0, the async path pins an executor worker per waiter for as long as it blocks — lock_wait_seconds is silently ignored, 423 Locked is never returned, and because the lock holder's storage calls draw from that same default executor, enough waiters will starve the very holder that would release them.

Overriding the async path

acquire_async / release_async are ordinary methods with working defaults, so a sync-only acquire / release pair is enough. Override them when your coordinator has a native async client, or to skip the thread hop entirely:

class MyLockBackend(LockBackend):
    async def acquire_async(
        self, key: str, ttl_seconds: float, wait_timeout: float = 0.0
    ) -> str | None: ...

    async def release_async(self, key: str, token: str) -> None: ...

InMemoryLockBackend does exactly this — its state is a dict behind a mutex held for microseconds, so it polls inline and leaves the executor free.