A lease lock that hands out ever-increasing tokens, and a storage service that refuses the late write of a client whose lease ran out while it was paused.
Client 1 takes a lock with a 10-second lease, then freezes in a long garbage-collection pause. The lease runs out, client 2 takes the lock and writes. Client 1 wakes up, still sure it holds the lock, and sends its write. Without protection that write lands on top of client 2's: the lesson's "CORRUPTED DATA!" (Day 5, section 7.1). The fix is a fencing token (sections 2.1 and 2.2): every grant of the lock comes with a number that only ever goes up, the client sends it with every write, and the storage service rejects any write whose token is lower than the highest it has already accepted.
Build both halves.
LockService(clock, lease_seconds=10.0, first_token=1), a lock service that hands out leases:
| Method | Returns |
|---|---|
acquire(name, client_id) |
the new fencing token (an int) if lock name is free, otherwise None |
renew(name, client_id, token) |
True and extends the lease to now + lease_seconds if client_id holds lock name with this token and the lease hasn't expired; otherwise False |
release(name, client_id, token) |
True and frees the lock at once, under the same condition as renew; otherwise False and nothing changes |
holder(name) |
the client_id holding a live lease on name, or None |
FencedStorage(), the resource the lock protects:
| Method | Returns |
|---|---|
write(key, value, token) |
True if accepted; False if token is lower than the highest token this key has accepted |
read(key) |
the key's value, or None |
highest_token(key) |
the highest token the key has accepted, 0 if none |
Rules the tests check:
t is live while now < t + lease_seconds. At exactly t + lease_seconds it has expired and the lock is free.first_token, every later grant (of any lock name) gets the next integer. A token is never handed out twice. renew keeps the token; only a new grant makes a new one.acquire on a lock with a live lease returns None, even when the caller is the holder (use renew to keep a lock).acquire again and gets a new token. A late release must never free someone else's lock.token >= highest_token(key) (the holder may write several times with its token), and then the key's value and highest token are updated. A rejected write changes nothing. Each key keeps its own highest token, like the lesson's fence_token column (section 2.2).clock.now() (seconds).In JavaScript and TypeScript the methods are acquire(name, clientId), renew(name, clientId, token), release(name, clientId, token), holder(name), write(key, value, token), read(key) and highestToken(key); the constructor is new LockService(clock, leaseSeconds = 10, firstToken = 1), and "none" is null.
The chaos tests run three clients against one lock with a 3-second lease. Each takes the lock, works (or stalls) for a random time, writes with its token and releases; one of them may crash mid-job and restart. Storage must never accept a write with a token older than one it has already accepted.
The paused client can't notice its own pause, so a "check the lease, then write" in the client always leaves a gap. Martin Kleppmann's "How to do distributed locking" (2016) makes this argument and draws exactly this scenario, tokens 33 and 34 included; the check has to live in the resource, atomically with the write.
Write your code, then run 3 example tests and 9 hidden tests. Each run is limited to 8 seconds: an infinite loop gets stopped and the runtime restarts, your code stays.