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:
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:
# 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:
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 oneexceptcovers a lock you did not get, whether the call refused to wait at all or waited and ran out.- The builtin
TimeoutError, so anexcept TimeoutErroraround 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:
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_tokenfromasync with lock as held:to every write, so the resource rejects the old holder. See Fencing tokens. - Set a
lease_durationlonger than the work really takes, callextend()before the lease runs out, or hold it withauto_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:
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.