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. Every backend behaves the same.
async with MemoryProvider() as provider:
catalog = ReadWriteLock("catalog", backend=provider.readwritelock())
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 acquire(timeout=...), acquire_nowait(), extend(),
release(), and a from_thread adapter, exactly like Lock.
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. Every backend behaves the same.
async with MemoryProvider() as provider:
lock = ReadWriteLock("catalog", backend=provider.readwritelock())
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.
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. Every backend behaves the same.
async with MemoryProvider() as provider:
catalog = ReadWriteLock("catalog", backend=provider.readwritelock())
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 | 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 whole seconds. |
| Kubernetes | Annotations on the Lease that holds the writer | Coarse-grained. Every reader renewal writes to etcd, and annotation size caps readers in the hundreds. |
| Memory | A process-local dict | Tests and single-process apps. |
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 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_READWRITELOCK_{NAME_UPPER}_WORKER |
worker |
str \| UUID |
generated UUID |
GREL_READWRITELOCK_{NAME_UPPER}_LEASE_DURATION |
lease_duration |
float (> 0) |
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.