Locks and leases coordinate access to shared resources, but their failure behavior depends on the implementation. Here the concern is exclusive writers; locks can also have shared modes. A lease adds a bounded validity period. Neither a familiar name nor a timeout alone proves that a delayed writer cannot damage data after another writer takes over.

The article’s one-clock serial model: A acquires at zero, renews at four to expiry nine, is expired at nine, and B acquires at ten. Separate-resource fencing rejects old tokens after a newer token arrives. B’s later token 3 cannot be released by a delayed token-2 request.
Expiry permits handover without canceling old effects. A separate resource detects obsolete tokens only after seeing newer ones. The article’s co-located, one-clock model additionally checks expiry atomically with each write.

A lock has a defined lifetime

A lock need not survive forever after a holder crashes. PostgreSQL session-level advisory locks are released when the session ends, and transaction-level advisory locks are released when the transaction ends. Detecting a broken remote session can take time, but that is different from a lock with no cleanup mechanism. A timeout while waiting for a lock also differs from expiration of ownership.

Inside one process, a mutex may be the right mechanism, but deadlocks and stalled threads remain possible. Across processes, operating-system or database locks may fit better than an application lease. Choose from the resource, ownership scope, and recovery semantics instead of applying a rule that all distributed work must use leases.

Advisory locks protect only cooperating participants. Code that ignores the lock can still touch an otherwise accessible resource. Database-enforced data locks and an external worker’s advisory ownership have different enforcement points. Identify which component actually prevents the conflicting operation rather than relying on every caller’s good intentions.

A lease allows later handover

A coordinator can grant ownership until a deadline, accept timely renewal, and permit another acquisition after expiry. This helps recover from a vanished holder without waiting for an explicit release. However, expiration does not stop a paused process, retract an already sent request, or cancel a write already executing at another service.

Checking ownership before a write leaves a gap between check and effect. A fencing protocol makes the resource participate, rejecting obsolete acquisition generations. With a separate resource that only remembers the largest token it has seen, an old token becomes stale there after a newer token is observed; that mechanism alone does not detect every lease expiry immediately.

A unique acquisition identifier also belongs on renewal and release requests. Owner names can be reused: a delayed release from an earlier acquisition by worker B must not release B’s later lease. Redis’s compare-value release pattern illustrates acquisition-specific ownership, but a random ownership value is not automatically an ordered fencing token.

Renewal has uncertainty

Choose renewal margins from scheduling delays, network latency, pauses, and the clock assumptions of the protocol. One-third of a TTL is a possible operational choice, not a correctness theorem. A ten-second lease does not guarantee complete service recovery within ten seconds; coordinator availability, retries, scheduling, and resource recovery can add delay.

A renewal timeout means its outcome may be unknown. The holder should stop initiating work once safe authority cannot be established, but attempted cancellation does not prove that in-flight effects stopped. Reacquisition must obtain a new identity, and the resource must enforce the required stale-request rule.

When it breaks

An expired task can resume after another holder starts and overwrite newer work if storage accepts its old request. Client-side checks are particularly vulnerable to a pause immediately after checking. Reproduce delayed effects at the enforcement point, not merely a client noticing that its timer expired.

Clock comparisons need an explicit model. Wall-clock jumps, rate drift, and differing clock origins can invalidate deadline calculations. A fast local clock does not universally extend a lease; with a shared absolute deadline it can expire it early. Monotonic elapsed-time measurement avoids wall-clock steps, but does not remove every protocol timing assumption.

Silent renewal-loop failure, excessively short TTLs, missing releases, and delayed duplicate releases affect different transitions. Test them separately. Preserve a token per acquisition rather than treating a reused owner string as sufficient authorization, and remember that application cancellation and lease invalidation are distinct operations.

Check it quickly

Save this as lease.py and run python3 lease.py. The sequential model combines coordinator and resource in one object with one manual nondecreasing clock. It checks ownership, expiry, and generation atomically with each modeled write. It is intentionally stronger than a separate resource that only tracks tokens already received.

class LeaseLostError(RuntimeError):
    pass


class LeaseStore:
    """Serial, co-located coordinator and resource; one manual clock."""
    def __init__(self):
        self.holder, self.expiry, self.fence, self.data = None, 0, 0, None
        self.clock = 0

    def observe(self, now):
        if now < self.clock:
            raise ValueError("clock moved backwards")
        self.clock = now

    def acquire(self, owner, ttl, now):
        self.observe(now)
        if ttl <= 0:
            raise ValueError("TTL must be positive")
        if self.holder is None or now >= self.expiry:
            self.holder, self.expiry = owner, now + ttl
            self.fence += 1
            return self.fence
        return None

    def valid(self, owner, token, now):
        return self.holder == owner and token == self.fence and now < self.expiry

    def renew(self, owner, token, ttl, now):
        self.observe(now)
        if ttl <= 0:
            raise ValueError("TTL must be positive")
        if not self.valid(owner, token, now):
            return False
        self.expiry = now + ttl
        return True

    def release(self, owner, token, now):
        self.observe(now)
        if not self.valid(owner, token, now):
            return False
        self.holder = None
        return True

    def write(self, owner, token, value, now):
        self.observe(now)
        if not self.valid(owner, token, now):
            raise LeaseLostError("expired, wrong owner, or stale acquisition")
        self.data = value


def rejected(store, owner, token, now):
    before = store.data
    try:
        store.write(owner, token, "forbidden", now)
    except LeaseLostError:
        assert store.data == before
    else:
        raise AssertionError("invalid write accepted")


store = LeaseStore()
a = store.acquire("A", 5, 0)
assert a == 1
store.write("A", a, "a-1", 2)
assert store.renew("A", a, 5, 4) and store.expiry == 9
store.write("A", a, "a-2", 8)
rejected(store, "A", a, 9)  # Equality is expired.
assert not store.renew("A", a, 5, 9)
b = store.acquire("B", 5, 10)
assert b == 2
store.write("B", b, "b-1", 11)
rejected(store, "B", a, 12)
rejected(store, "A", a, 12)
assert store.acquire("C", 5, 12) is None
assert not store.release("A", a, 12)
assert store.release("B", b, 12)
new_b = store.acquire("B", 5, 13)
assert new_b == 3
assert not store.release("B", b, 13)
assert not store.renew("B", b, 5, 13)
store.write("B", new_b, "new-b", 14)
assert store.release("B", new_b, 14)
c = store.acquire("C", 5, 14)
assert c == 4
store.write("C", c, "c-1", 15)
assert store.data == "c-1"
for operation in (lambda: store.acquire("D", 0, 15),
                  lambda: store.renew("C", c, -1, 15),
                  lambda: store.write("C", c, "backwards", 14)):
    try:
        operation()
    except ValueError:
        pass
    else:
        raise AssertionError("invalid TTL or backwards time accepted")
print("PASS: renewal, expiry, fencing, same-owner reacquisition, and input guards")

Expect the PASS line. Renewal moves expiry from five to nine; equality is expired. Wrong owners and stale tokens cannot change data. B then reacquires under a new token, and delayed release and renewal using B’s old token fail. Invalid TTLs and backwards time also fail.

Both revised examples passed with Python 3.12.14 on Linux. This establishes the serial model’s transitions, not network consensus, durable fencing, real clock behavior, PostgreSQL locking, Redis availability, or production concurrency.

Further reading

Sources