A lock is not enough: fencing tokens with LEASE

A lock with a TTL keeps two workers apart until one of them pauses for longer than the TTL. Here is what goes wrong then, and how the token that LEASE returns lets the protected side say no.

Salih Cantekin · · 4 min read

Worker 1Worker 2LEASEStoretoken 41token 42paused…✕ stale 41Worker 1 takes the lease and gets fencing token 41.Worker 1 stalls. The lease expires.Worker 2 takes it with token 42. Worker 1 wakes up…The store rejects the stale token 41. No split brain.Only the holder of the newest token can write.
Locks that stay safe when workers stallEvery LEASE returns a fencing token; a stale holder is rejected instead of corrupting data.

The usual distributed lock is SET key me NX PX 30000. It works well right up to the day a worker stops for 31 seconds. A long garbage-collection pause, a VM that got migrated, a laptop lid closed during a demo. The lock expires, a second worker takes it, and then the first one wakes up and carries on writing as if nothing happened. For a while you have two holders, and the store that the lock was protecting has no idea.

The fix has been known for a long time: give every grant a number that only goes up, send that number along with every write, and let the thing you protect reject a number older than the last one it saw. That number is a fencing token. In Ruvio every lock grant returns one.

64-bit
fencing token on every grant, and it only grows
3
commands: LEASE, RENEW, RELEASE
0
the answer to RENEW or RELEASE with a token that no longer holds the lock

The three commands

LEASE job:nightly-report 30000
# 114770882368960 -> you hold it, this is your token
LEASE job:nightly-report 30000
# (nil)           -> someone else holds it
RENEW job:nightly-report 114770882368960 30000
# 1               -> deadline moved to now + 30 s
RELEASE job:nightly-report 114770882368960
# 1               -> released

ClientsRustC#GoJavaPythonNode.js

PackagesRuvio.AspNetCore.DistributedLock NuGet

LEASE gives you the lock and a token, or a null when someone else has it. RENEW moves the deadline for a long job, but only for the token that holds the lock right now. RELEASE frees it, again only for the right token. A stale worker calling RENEW or RELEASE gets 0 back and changes nothing, so it cannot free a lock that by now belongs to someone else.

Where the token earns its keep

The lock on its own still cannot stop the paused worker. What stops it is the other end. Say the job writes a report row. Keep the highest token you have seen next to the data, and make the write conditional on it:

UPDATE reports
SET body = $1, fence = $2
WHERE id = $3 AND fence < $2;
-- 0 rows updated -> a newer holder already wrote; stop here
</code></pre>
<p>Now follow the animation at the top again. Worker 1 gets token 41 and stalls. The lease runs out, worker 2 gets 42 and writes with it. Worker 1 wakes up and tries to write with 41. The <code>WHERE fence < 41</code> no longer matches, zero rows change, and worker 1 learns that it lost the lock instead of overwriting newer data.</p>
<p>The same trick works with anything that can compare a number: a column in Postgres, an <code>IFGT</code> write in Ruvio itself, a version header on an object store, a check in your own service. The important part is that the comparison happens where the data lives, not in the worker that might be asleep.</p>
<h2 id="when-you-need-n-holders-not-one">When you need N holders, not one</h2>
<p>Sometimes the rule is “at most eight exports at a time” rather than “one”. <code>SEMAPHORE</code> gives each holder its own slot, its own TTL and its own token:</p>
<pre><code class="language-redis">SEMAPHORE upstream:billing 8 15000
# 114770882371136 -> a slot, this is your token
# (nil)           -> all 8 slots are taken
RELEASE upstream:billing 114770882371136

ClientsRustC#GoJavaPythonNode.js

A crashed worker frees its slot when its own TTL ends. The others are not affected, and the key goes away when the last holder leaves.

Failover, honestly

Replication in Ruvio is asynchronous by default. If a primary hands out a lease and dies before the replica has that write, the new primary has never heard of the grant and can hand the same lock to someone else. Two things close that gap:

  1. Take the lock on a connection that sent CLIENT DURABILITY REPLICAS 1. The grant is then only answered once a replica holds it on disk, and ruvio coordinator fails over only to a replica that holds every such write.
  2. Keep the protected side checking tokens. Tokens keep growing across a failover, so the check above still rejects the older holder even in the rare case where two grants overlap.

Keep the nodes’ clocks synchronized as well, since a lease is a deadline. None of this makes a lock magically perfect. It moves the guarantee to the place that can actually enforce it, and that is the whole point of fencing.

Rules of thumb

  • Size the TTLLonger than a normal run. Long jobs call RENEW with their token instead of guessing a huge TTL.
  • Release earlyFree the lock as soon as you are done. Waiting for the TTL just makes the next worker wait.
  • Always fencePass the token to the write you protect. A lock without a fence is a hint, not a guarantee.

The full syntax is in the command reference. If you have a lock story of your own, good or bad, I would like to hear it in Discussions.