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.
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
- Distributed Systems: leases and fencing (Martin Kleppmann), why locks leak and leases help, checked 2026-10-02.
- PostgreSQL: advisory locks, application-level locks, checked 2026-10-02.
- Redis: SET and locks, building a lock with a time-bound key, checked 2026-10-02.