Skip to content

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.