A lease says “you hold this lock for 30 seconds”. It cannot say “you will still be running in 30 seconds”, and that gap is where distributed locks corrupt data. A fencing token closes it by making the resource — not the lock service, and certainly not the client — the final authority on which writer is current. This runbook implements the mechanism described in clock skew & time ordering in distributed systems.
Prerequisites. A working distributed lock — see distributed lock acquisition patterns — and write access to the schema of the resource it protects.
Step 1 — Issue a monotonic token with every grant
The token must increase strictly across grants, and it must come from the same operation that grants the lock.
-- acquire_with_fence.lua
-- KEYS[1] = lock key KEYS[2] = fence counter (same hash tag as KEYS[1])
-- ARGV[1] = holder id ARGV[2] = lease ms
-- Returns: token (integer) on success, 0 if the lock is held
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', tonumber(ARGV[2])) then
return redis.call('INCR', KEYS[2]) -- monotonic within this shard
end
return 0
// etcd: the revision of the successful Put is already strictly monotonic
// across the whole cluster, produced by Raft rather than by a counter.
resp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(lockKey), "=", 0)).
Then(clientv3.OpPut(lockKey, holderID, clientv3.WithLease(leaseID))).
Commit()
if err != nil || !resp.Succeeded { return 0, ErrLockHeld }
token := resp.Header.Revision // the fencing token — no counter needed
etcd and ZooKeeper give you this for free: etcd’s Revision and ZooKeeper’s zxid are strictly monotonic by construction because they come out of a consensus log. Redis’s INCR is monotonic on one shard but can regress across an asynchronous failover, which is the reason Redlock alone is not sufficient for a safety-critical fence.
Step 2 — Carry the token to the write
@dataclass(frozen=True)
class Lease:
resource: str
holder: str
token: int # never optional — a Lease without a token is not a lease
def with_lock(resource: str, fn):
lease = lock_service.acquire(resource, lease_ms=30_000)
try:
return fn(lease) # fn MUST thread lease.token into every write
finally:
lock_service.release(lease)
Make the token a required parameter on every function the lock protects. A signature that accepts an optional token is one refactor away from a call site that forgets it, and the failure is silent until the day a worker pauses.
Step 3 — Enforce at the resource
-- The fence lives in the WHERE clause of the write itself. One statement,
-- no read-then-write, no clock.
UPDATE account_balances
SET balance = balance - :amount,
fence_token = :token
WHERE account_id = :account
AND fence_token < :token
RETURNING balance;
-- Zero rows returned = a newer holder has already written. Abort, do not retry.
def debit(conn, account: str, amount: int, lease: Lease) -> int:
row = conn.execute(DEBIT_SQL,
{"amount": amount, "token": lease.token, "account": account}
).fetchone()
if row is None:
metrics.inc("lock_fence_rejected_total")
raise StaleLeaseError(f"token {lease.token} superseded on {account}")
return row.balance
For DynamoDB the same idea is a condition expression:
table.update_item(
Key={"pk": account},
UpdateExpression="SET balance = balance - :amt, fence_token = :tok",
ConditionExpression="attribute_not_exists(fence_token) OR fence_token < :tok",
ExpressionAttributeValues={":amt": amount, ":tok": token},
)
And for an S3-style object store, use a conditional write on an ETag, or encode the token in the object key so a stale writer simply produces a version nobody reads.
Step 4 — Migrate an existing table
-- 1. Add the column, nullable, with no default rewrite.
ALTER TABLE account_balances ADD COLUMN fence_token bigint;
-- 2. Backfill in bounded batches so no long lock is taken.
UPDATE account_balances SET fence_token = 0
WHERE fence_token IS NULL AND account_id IN (
SELECT account_id FROM account_balances WHERE fence_token IS NULL LIMIT 10000);
-- 3. Once backfilled, make it mandatory.
ALTER TABLE account_balances ALTER COLUMN fence_token SET NOT NULL;
ALTER TABLE account_balances ALTER COLUMN fence_token SET DEFAULT 0;
During step 2, writers must tolerate NULL:
-- Transitional predicate: accepts rows not yet backfilled.
WHERE account_id = :account AND (fence_token IS NULL OR fence_token < :token)
Remove the IS NULL branch as soon as the backfill completes. Leaving it means any row that somehow loses its token becomes permanently unfenced.
Step 5 — Prove it
def test_frozen_worker_cannot_write(db, lock_service):
account = "acct_fence_probe"
lease_a = lock_service.acquire(account, lease_ms=1_000) # token N
time.sleep(1.5) # lease A expires
lease_b = lock_service.acquire(account, lease_ms=30_000) # token N+1
debit(db, account, 100, lease_b) # B writes first
with pytest.raises(StaleLeaseError):
debit(db, account, 100, lease_a) # A wakes and tries
assert db.scalar("SELECT balance FROM account_balances WHERE account_id=:a",
a=account) == INITIAL - 100 # exactly one debit
# The real-world version: freeze a worker with SIGSTOP past its lease.
kill -STOP "$(pgrep -f settlement-worker)"
sleep 45 # lease (30 s) expires; a peer takes over
kill -CONT "$(pgrep -f settlement-worker)"
sleep 5
psql -c "SELECT account_id, balance, fence_token FROM account_balances
WHERE account_id = 'acct_fence_probe'"
# expect: exactly one debit applied, fence_token equal to the newer holder's token
grep -c 'lock_fence_rejected' worker.log # expect: at least 1
A non-zero lock_fence_rejected_total in production is good news. It means workers really do freeze past their leases and the fence is catching it — which also means that before you added the fence, those were silent corruptions.
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
Token checked in application code before the write rather than in the write’s WHERE clause |
Move the comparison into the same statement as the update. A separate read-then-write reintroduces the race the fence exists to remove. | lock_fence_rejected_total flat at zero while lease_expired_while_held_total is non-zero — the fence is not firing |
| Redis-issued tokens regress after a failover, so a new holder receives a token lower than one already written | Move token issuance to etcd or ZooKeeper, whose revisions come from consensus. If Redis must stay, persist the counter’s high-water mark and restore it on promotion. | lock_token_regression_total from a monitor comparing issued tokens against the last observed maximum; page on any value |
| One resource protected by two lock services (a migration left both running), each with its own counter | Never run two token sources for one resource. Migrate by having the new service seed its counter above the old one’s maximum, then decommissioning the old path. | lock_service label on the fence-rejection counter; two distinct values for one resource is the signature |
Backfill left some rows NULL, and the transitional IS NULL predicate was never removed, so those rows stay unfenced forever |
Complete the backfill, set NOT NULL, and remove the transitional branch in the same release. |
SELECT count(*) FROM account_balances WHERE fence_token IS NULL as a gauge; must be zero |
| Fence added to the balance table but not to an audit or projection table written by the same handler | Fence every table the lock protects, or write them all in one transaction so the fenced statement gates the whole unit. | A schema audit listing tables written under each lock and whether they carry fence_token |
SRE / observability checklist
lock_fence_rejected_total— Counter of writes rejected by the fence, labelled by resource. Non-zero proves the fence is earning its place; a sudden rise means workers are freezing more often.lock_token_regression_total— Counter from a monitor comparing each issued token against the last observed maximum. Any value is a correctness emergency.lease_expired_while_held_total— Counter from the lock client noticing its lease expired mid-work. Correlate withlock_fence_rejected_total; the two should track each other.gc_pause_seconds(JVM) orgo_gc_pause_seconds— Histogram p99. A p99 approaching the lease duration predicts fence rejections before they happen.fence_token_null_rows— Gauge of unfenced rows during a migration. Must reach zero and stay there.fence_tokenin the write’s structured log — So a rejected write can be traced back to the lease that issued it and the pause that stranded it.
Related
- Clock Skew & Time Ordering in Distributed Systems — the parent page on why time cannot order distributed decisions.
- Handling Stale Locks in Distributed Systems — lease expiry, orphan detection and recovery.
- Redlock vs Single-Instance Redis Locks — why replication alone does not give a safe fence.
- etcd vs ZooKeeper vs Redis for Distributed Locking — choosing a lock service that issues consensus-backed tokens.