Read-Write Lock
A Lock lets one caller in at a time, readers included. A
ReadWriteLock lets every reader in at once and keeps writers alone. Reach for
it when a resource is read far more often than it is written: a catalog, a
routing table, a rendered report, a config blob in object storage.
import asyncio
from grelmicro.coordination import ReadWriteLock
from grelmicro.providers.memory import MemoryProvider
async def main() -> None:
# Memory keeps this demo in one process.
# The calls are the same on every backend.
async with MemoryProvider() as provider:
catalog = ReadWriteLock(
"catalog", backend=provider.readwritelock_backend()
)
async with catalog.read as reading:
print("read under generation", reading.generation)
async with catalog.write as writing:
print("write with fencing token", writing.fencing_token)
asyncio.run(main())
catalog.read and catalog.write are two views of one lock. Each is a full
primitive with hold(timeout=...), acquire(timeout=...),
acquire_nowait(), extend(), release(), and a from_thread adapter,
exactly like Lock. async with catalog.read.hold(timeout=5) as reading:
waits at most 5 seconds for a read lease.
Guards
Entering a mode binds a guard. The guard is the only thing that carries the proof you hold the lock, so a function that writes can demand one in its signature and the type checker rejects a caller who never took the lock:
import asyncio
from grelmicro.coordination import ReadGuard, ReadWriteLock, WriteGuard
from grelmicro.providers.memory import MemoryProvider
class Catalog:
"""A resource that only accepts writes carrying a higher fencing token."""
def __init__(self) -> None:
self.rows: list[str] = []
self.highest_token = 0
def read_all(self, guard: ReadGuard) -> list[str]:
print("reading catalog", guard.name)
return list(self.rows)
def replace_all(self, guard: WriteGuard, rows: list[str]) -> bool:
if guard.fencing_token <= self.highest_token:
return False
self.highest_token = guard.fencing_token
self.rows = rows
return True
async def main() -> None:
catalog = Catalog()
# Memory keeps this demo in one process.
# The calls are the same on every backend.
async with MemoryProvider() as provider:
lock = ReadWriteLock(
"catalog", backend=provider.readwritelock_backend()
)
async with lock.write as writing:
assert catalog.replace_all(writing, ["apple", "pear"])
async with lock.read as reading:
assert catalog.read_all(reading) == ["apple", "pear"]
asyncio.run(main())
ReadGuard and WriteGuard are different types. A function annotated
guard: WriteGuard cannot be called with a read guard. Reading
guard.fencing_token after the guard is released, or after its lease expired,
raises LockNotOwnedError instead of handing back a stale token.
| Guard | Carries | Use it for |
|---|---|---|
ReadGuard |
generation, expires_in |
Detecting that a writer landed since you read. |
WriteGuard |
fencing_token, poisoned, expires_in |
Fencing every write, and spotting a crashed predecessor. |
Writers never starve
The lock is writer-preferring. A writer that finds readers in the way records an intent, and new readers wait behind it. Readers already inside finish and the writer goes in next. Without this, a steady stream of readers holds a writer out forever.
The intent carries its own lease. A writer that dies while waiting stops holding readers back as soon as that lease expires.
Releasing
A release that has started finishes on the backend even when the task is
cancelled, by a client disconnect or a shutdown. So does the withdrawal of a
writer's intent when its wait is cancelled. A backend that never answers is
given up on once lease_duration has passed, and a release then raises
LockReleaseError. See Releasing on the lock page.
Poison
A write that crashes halfway leaves the resource in whatever state it reached.
The next writer sees poisoned set to True, which says the previous holder's
lease expired without a release:
import asyncio
from grelmicro.coordination import ReadWriteLock
from grelmicro.providers.memory import MemoryProvider
async def rebuild(rows: list[str]) -> list[str]:
return sorted(rows)
async def main() -> None:
# Memory keeps this demo in one process.
# The calls are the same on every backend.
async with MemoryProvider() as provider:
catalog = ReadWriteLock(
"catalog", backend=provider.readwritelock_backend()
)
async with catalog.write as writing:
if writing.poisoned:
print("the previous writer died mid-write, repairing")
rows = await rebuild(["pear", "apple"])
reading = await writing.downgrade()
print("still holding, now as a reader", reading.generation, rows)
asyncio.run(main())
poisoned is a fact, not a lock state. The write lock is yours either way, and
what a half-finished predecessor means is yours to decide.
Downgrade, and why there is no upgrade
await guard.downgrade() turns a held write lock into a read lock with no gap in
between, so no other writer can slip in. Use it to publish a rebuilt value and
then keep reading it.
There is no upgrade. Two callers that both hold a read lock and both wait to
become the writer wait for each other forever. ReadWriteLock raises
LockUpgradeError rather than shipping a deadlock. Take the write lock from the
start when you might write.
Backends
Every coordination backend implements it.
from grelmicro import Grelmicro
from grelmicro.coordination import Coordination, ReadWriteLock
from grelmicro.providers.redis import RedisProvider
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[Coordination(redis)])
catalog = ReadWriteLock("catalog")
| Backend | Holds readers in | Notes |
|---|---|---|
| Redis, Valkey | A sorted set of reader leases, updated in one server-side step | Fastest. On a cluster, the prefix needs a hash tag. |
| PostgreSQL | Reader rows, updated under an advisory lock (a lock PostgreSQL holds by name) | Tables are created on first connect. Pass auto_migrate=False to manage them yourself. |
| SQLite | Reader rows, updated in one write transaction | One host only. Lease durations round up to the millisecond. |
| Kubernetes | Annotations on the Lease that holds the writer | Coarse-grained. Every reader extension writes to etcd, and annotation size caps readers in the hundreds. A writer lease rounds up to whole seconds. |
| Memory | A process-local dict | Tests and single-process apps. |
SQLite is host-scoped: it shares state with the processes on one host only.
Coordination requires cluster, so a SQLite backend is refused at startup
in staging and production, and warns once when GREL_ENVIRONMENT is unset.
See The backend check.
Every holder has its own lease, so a reader that died is dropped by the next writer's acquire rather than blocking it until a shared expiry fires.
Configuration
Same fields as Lock, read from
GREL_READWRITELOCK_{NAME_UPPER}_*. The default instance drops the name segment
and reads GREL_READWRITELOCK_*.
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_READWRITELOCK_{NAME_UPPER}_WORKER |
worker |
str \| UUID |
generated UUID |
GREL_READWRITELOCK_{NAME_UPPER}_LEASE_DURATION |
lease_duration |
whole seconds or ISO 8601 | 60 |
GREL_READWRITELOCK_{NAME_UPPER}_RETRY_INTERVAL |
retry_interval |
float (>= 0.001) |
0.1 |
GREL_READWRITELOCK_{NAME_UPPER}_RETRY_JITTER |
retry_jitter |
float [0, 1) |
0.1 |
lease_duration covers a reader lease, a writer lease, and a writer intent.