Skip to content

Lock

The lock is a distributed lock that synchronizes access to a shared resource.

The lock supports the following features:

  • Async: the lock is acquired and released asynchronously.
  • Distributed: the lock is shared across multiple workers.
  • Non-reentrant: a nested acquire from the same task or thread raises LockReentrantError. Use separate instances if you need independent locks.
  • Idempotent backend: the backend lets the same token re-acquire the lock, which extends the lease. Call extend() if you need to extend the lease explicitly.
  • Expiring: each hold is a lease that ends on its own after lease_duration, so a crashed holder never blocks the others for good.
  • Non-blocking: lock operations do not block the async event loop.
  • Backend-agnostic: several backends are supported, including Redis, PostgreSQL, SQLite, and the Kubernetes Lease API.
import asyncio

from grelmicro import Grelmicro
from grelmicro.coordination import Coordination, Lock
from grelmicro.providers.memory import MemoryProvider

# Memory keeps this example in one process.
# The calls are the same on every backend.
micro = Grelmicro(uses=[Coordination(MemoryProvider(), requires="process")])

lock = Lock("resource_name")


async def main() -> None:
    async with micro, lock:
        print("Protected resource accessed")


if __name__ == "__main__":
    asyncio.run(main())

Load a backend first, see Backends.

Warning

The lock is built for one async event loop and is not thread-safe or process-safe.

Configuration

Build the lock with keyword arguments. The positional name is always required and acts as the instance identity.

import asyncio

from grelmicro import Grelmicro
from grelmicro.coordination import Coordination, Lock
from grelmicro.providers.memory import MemoryProvider

# Memory keeps this example in one process.
# The calls are the same on every backend.
micro = Grelmicro(uses=[Coordination(MemoryProvider(), requires="process")])

lock = Lock("cart", lease_duration=60, retry_interval=0.1)


async def main() -> None:
    async with micro, lock:
        print("Protected resource accessed")


if __name__ == "__main__":
    asyncio.run(main())

Environment variables

Tune any field in deployment without code changes.

Prefix: GREL_LOCK_{NAME_UPPER}_. The default instance drops the name segment and reads GREL_LOCK_*.

Environment variables are read by default

A GREL_* variable that fills a component field is read unless GREL_ENV_LOAD=0. With GREL_ENV_LOAD=0, a variable that is set is ignored, the default applies, and startup warns with its name. Passing env_load=False to a component turns its reads off and stays quiet. See How a value is resolved for the three ways to configure a component, including a local .env.

Env var Config field Type Default
GREL_LOCK_{NAME_UPPER}_WORKER worker str \| UUID generated UUID
GREL_LOCK_{NAME_UPPER}_LEASE_DURATION lease_duration whole seconds or ISO 8601 60
GREL_LOCK_{NAME_UPPER}_RETRY_INTERVAL retry_interval float (>= 0.001) 0.1
GREL_LOCK_{NAME_UPPER}_RETRY_JITTER retry_jitter float [0, 1) 0.1

Concrete example for Lock("cart"):

GREL_LOCK_CART_WORKER=web-1
GREL_LOCK_CART_LEASE_DURATION=120
GREL_LOCK_CART_RETRY_INTERVAL=0.2
GREL_LOCK_CART_RETRY_JITTER=0.2

The code stays the same, the environment fills the fields in:

import asyncio

from grelmicro import Grelmicro
from grelmicro.coordination import Coordination, Lock
from grelmicro.providers.memory import MemoryProvider

# Memory keeps this example in one process.
# The calls are the same on every backend.
micro = Grelmicro(uses=[Coordination(MemoryProvider(), requires="process")])

# With GREL_LOCK_CART_LEASE_DURATION=60 and GREL_LOCK_CART_RETRY_INTERVAL=0.1
# present in the environment, Lock("cart") resolves both from env.
# Fields not set in env fall back to LockConfig defaults.
lock = Lock("cart")


async def main() -> None:
    async with micro, lock:
        print("Protected resource accessed")


if __name__ == "__main__":
    asyncio.run(main())

Advanced

For custom env prefixes with env_prefix=, the from_config declarative path, and pydantic-settings composition, see Declarative configuration.

Dynamic-key Locks

Most Locks are declared once at module load (lock = Lock("cart")) and reused across requests. When the lock key is computed per request, build a fresh Lock each time:

fragment
lock = Lock(f"order:{order_id}")
async with lock:
    ...

This is the right pattern when locking by business identity (order_id, user_id, tenant_id).

Advanced

On a measured hot loop that builds many Locks per request, pre-build a single LockConfig and call Lock.from_config(name, cfg) to skip per-call validation and the env read. See Declarative configuration.

Bounded acquire

async with lock: waits as long as it takes. Use hold(timeout=) to wait at most a few seconds, then keep the lock for the body:

fragment
# Wait up to 5 seconds, then raise LockTimeoutError.
async with lock.hold(timeout=5) as held:
    await save(fencing_token=held.fencing_token)

timeout bounds the wait, never the lease: once acquired, the lock is held until the body ends or lease_duration runs out. timeout=0 makes one attempt. When the wait runs out, the body does not run. From a worker thread, write with lock.from_thread.hold(timeout=5) as held:.

acquire(timeout=) waits the same way when you release the lock yourself:

fragment
held = await lock.acquire(timeout=5.0)

It carries the lock and the wait that elapsed, and it subclasses two things you may already catch:

  • WouldBlockError, so one except covers a lock you did not get, whether the call refused to wait at all or waited and ran out.
  • The builtin TimeoutError, so an except TimeoutError around a bounded acquire keeps working.

Over HTTP it becomes a 503 problem detail, the same one a non-blocking acquire produces.

Extending the lease

Call extend() on a Lock to extend the lease without releasing the lock. The fencing token stays the same, only the expiry time advances:

fragment
lock = Lock("cart")
async with lock as held:
    token_before = held.fencing_token
    extended = await lock.extend()
    assert extended.fencing_token == token_before  # same token, new TTL

extend() raises LockNotOwnedError when the lease was lost on the backend (expired or taken over by another holder), and LockExtendError when the backend call fails.

Hold a lock for a long job

Pass auto_extend=True to hold() when the body may run longer than lease_duration. The lease is extended every third of lease_duration until the body ends:

import asyncio
from datetime import timedelta

from grelmicro import Grelmicro
from grelmicro.coordination import Coordination, Lock
from grelmicro.providers.memory import MemoryProvider

# Memory keeps this example in one process.
# The calls are the same on every backend.
micro = Grelmicro(uses=[Coordination(MemoryProvider(), requires="process")])

lock = Lock("migration", lease_duration=timedelta(seconds=1))


async def migrate(*, fencing_token: int) -> None:
    # Runs for two leases. The lease is extended every third of it.
    await asyncio.sleep(2)
    print(f"Migrated with fencing token {fencing_token}")


async def main() -> None:
    async with micro, lock.hold(timeout=5, auto_extend=True) as held:
        await migrate(fencing_token=held.fencing_token)


if __name__ == "__main__":
    asyncio.run(main())

When the lease is lost, the body is cancelled at its next await and the async with raises LockNotOwnedError. The lease is lost when the backend no longer holds it, or when the backend keeps failing until only a tenth of the lease is left. One warning names the lock. A cancellation from outside is raised as CancelledError, as usual. When the body raises its own error after the lease is lost, that error propagates, and the loss shows only in the warning.

A body that does not await again after the loss runs to its end, then the async with raises. Its side effects have already happened, which is one more reason to pass the fencing token.

auto_extend is available on async with lock.hold(...). The thread form, lock.from_thread.hold(), and the read-write lock holds do not take it.

Renewal does not survive a process pause, such as a long garbage collection or a suspended VM, so pass held.fencing_token to the resource as well. See How to do distributed locking and Fencing tokens.

When the body outlives its lease

The lease bounds how long the lock is yours. Once it expires, another holder can acquire the lock while your body still runs. Two bodies then run at once. Leaving async with lock: afterwards raises LockNotOwnedError, because the lock is no longer yours to release.

Protect against it in two ways:

  • Pass held.fencing_token from async with lock as held: to every write, so the resource rejects the old holder. See Fencing tokens.
  • Set a lease_duration longer than the work really takes, call extend() before the lease runs out, or hold it with auto_extend=True.

Releasing

A release that has started finishes on the backend even when the task is cancelled, by a client disconnect or a shutdown. The cancellation is raised once the release ends, so the lock is never left held until its lease expires.

A release the backend never answers gives up once lease_duration has passed and raises LockReleaseError. By then the backend has freed the lock on its own.

Fencing tokens

A fencing token is a strictly increasing integer the backend mints for a lock name. Each acquisition returns a LockHandle that carries it. Read it from the value the context manager binds:

fragment
async with Lock("cart") as held:
    print(held.fencing_token)

The token grows by one on every free-to-held transition: a new holder, or a takeover after the previous lease expired. It keeps climbing across release and re-acquire cycles, so a token is never reused for a name. The same holder extending its lease keeps the same token.

acquire() and acquire_nowait() also return the LockHandle. The handle is per-acquisition, so a Lock shared by several tasks gives each holder its own handle with its own token.

Every backend mints tokens that are strictly monotonic per name. Redis is strictly monotonic against its master.

The resource enforces, grelmicro mints

A fencing token only protects a resource that checks it. grelmicro hands you the token. The resource you write to must record the highest token it has accepted and reject any write that arrives with a lower or equal token. Without that check on the resource, a paused or partitioned old holder can still write after a new holder took over.

The pattern: read held.fencing_token, pass it to the resource on every write, and have the resource compare it against its stored high-water mark.

import asyncio

from grelmicro.coordination import Lock
from grelmicro.providers.memory import MemoryProvider


class Resource:
    """A protected resource that records the highest fencing token it accepts."""

    def __init__(self) -> None:
        self.highest_token = 0

    def write(self, *, fencing_token: int, value: str) -> bool:
        """Accept the write only when its token beats every prior one."""
        if fencing_token <= self.highest_token:
            return False
        self.highest_token = fencing_token
        return True


async def main() -> None:
    resource = Resource()

    # Memory keeps this demo in one process.
    # The calls are the same on every backend.
    async with MemoryProvider() as provider:
        lock = Lock("cart", backend=provider.lock_backend())

        # A stale holder writes with an old token.
        stale = await lock.acquire()
        await lock.release()

        # The new holder gets a strictly greater token.
        async with lock as held:
            assert resource.write(fencing_token=held.fencing_token, value="new")

        # The stale token is now too low: the resource rejects it.
        assert not resource.write(
            fencing_token=stale.fencing_token, value="stale"
        )


asyncio.run(main())

Want to understand how worker identity and lock tokens work internally?

See Coordination Internals for details on UUID generation, token scoping, and design guarantees.

Watching a lock in production

With the metrics component registered, every lock reports what happened to it, so the three ways a lock can stop the work under it are told apart.

Signal What it means
grelmicro.lock.attempts with grelmicro.outcome="error" the backend is unreachable, so nothing is running anywhere
the same counter with unavailable another worker holds it, which is contention rather than failure
grelmicro.lock.extensions with lost the lease expired while the work was still running, so a second worker may already hold it
grelmicro.lock.holders how many holders this worker has, which is 0 when it holds nothing and more than 1 only for a read lease

grelmicro.lock.mode separates an exclusive lock, a task lock, and a read or write lease, so one dashboard covers every lock in the app.

Page on lost. It is the signal that the at-most-once guarantee no longer holds, and the fix is a lease_duration longer than the work really takes.