Process A acquires payment-export and reads its fencing token with holder.Token(). Tokens are monotonic, so each new holder gets a higher one. Call A's token N.
1A takes the lock
2A is paused
3The lease expires
4B takes the lock
5B writes with its token
6A wakes up and is rejected
7Without fencing
←→ step · space play
All steps as text
A takes the lock. Process A acquires payment-export and reads its fencing token with holder.Token(). Tokens are monotonic, so each new holder gets a higher one. Call A's token N.
A is paused. A long garbage-collection pause freezes A. It still believes it holds the lock, but its lease keeps running out.
The lease expires. The lock's TTL runs out and the lock is reclaimed automatically. A does not know this yet.
B takes the lock. Process B acquires the same lock and gets a higher token, N+1.
B writes with its token. B sends its write together with token N+1. The storage wrapper records N+1 as the highest token it has seen and accepts the write.
A wakes up and is rejected. A resumes and tries to write its stale data with token N. The wrapper rejects any write whose token is lower than the highest it has seen, so A's write fails and B's data stays.
Without fencing. If the storage did not check tokens, A's late write would overwrite B's fresh one, even though A no longer held the lock.
The code from the README. The storage side (here s3.PutWithFence) is your own small wrapper that remembers the highest token and refuses anything lower:
This is the defence from Martin Kleppmann’s “How to do distributed locking”. The lock alone cannot protect you here, because the paused process does not know its lease has ended; the token check at the storage is what catches it.