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: the lock has a timeout that auto-releases the lock to prevent deadlocks.
  • 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.
from grelmicro.coordination import Lock

lock = Lock("resource_name")


async def main():
    async with lock:
        print("Protected resource accessed")

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.

from grelmicro.coordination import Lock

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


async def main():
    async with lock:
        print("Protected resource accessed")

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 opt-in

A GREL_* variable that fills a component field is read only when GREL_ENV_LOAD is truthy (1, true, yes, on), the flag itself and GREL_ENVIRONMENT excepted. Without it the variable is ignored and the default applies. Setting one while the flag is unset warns at startup, so the mistake is not silent. Passing env_load=False is a deliberate opt-out 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 float (> 0) 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:

from grelmicro.coordination import Lock

# 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():
    async with lock:
        print("Protected resource accessed")

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:

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

Pass timeout= to acquire() to limit how long the call waits. When the deadline passes without winning the lock, TimeoutError is raised:

# Wait up to 5 seconds, then raise TimeoutError.
held = await lock.acquire(timeout=5.0)

The context manager (async with lock) calls acquire() with no timeout and waits indefinitely. Use acquire(timeout=...) directly when you need a bounded wait and want to handle the failure yourself.

Extending the lease

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

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).

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:

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 renewing or 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. Every backend behaves the same.
    async with MemoryProvider() as provider:
        lock = Lock("cart", backend=provider.lock())

        # 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.