Fencing Tokens and Lease-Based Locking

Distributed Systems advanced Building with AI ~40 min read

Overview

A distributed lock is not a local mutex with a network call in front of it. The lock holder can pause, the network can delay packets, the lock service can expire a lease, and another client can acquire the same logical lock while the first client is still alive. If the protected resource accepts writes from both clients, the lock has failed even if the lock service behaved as designed.

Lease-based locking adds a timeout so abandoned locks eventually release. That helps liveness, but it weakens safety unless every operation under the lock is fenced. A fencing token is a monotonically increasing number issued when a client acquires the lease. The protected resource stores the highest token it has accepted and rejects any request with an older token.

Kleppmann’s Redlock critique made this pattern widely known: a lock service alone cannot stop a paused client from resuming after its lease expired and writing stale data. Fencing moves the final safety check to the resource being protected. The lock grants authority, but the resource verifies that the authority is still newer than any authority it has already seen.

This design is the standard answer for correctness-critical distributed locking. Use approximate locks for efficiency-only deduplication. Use consensus-backed leases plus fencing tokens for exclusive access that protects data integrity.

Core Concepts

Leases

A lease is a lock with an expiration time. The client must keep it alive or renew it before expiry. Leases prevent permanent deadlock after client failure, but they rely on timing for ownership transfer. A slow client can continue executing after the lock service has already considered the lease expired.

Fencing Tokens

A fencing token is a strictly increasing value assigned to each successful acquisition. If client A gets token 33, pauses, loses its lease, and client B gets token 34, the storage system can reject A’s later write because token 33 is older than the highest accepted token.

Resource-Side Enforcement

The token must be checked by the database, file service, payment processor, job table, or external system being protected. If only the client checks the token, a paused or buggy client can still write. The resource needs an atomic compare-and-update around the highest accepted token and the protected mutation.

Key Concept: Locks Are Hints Until the Resource Fences

A lease says who the coordinator currently believes owns the lock. A fencing token lets the protected resource reject stale owners. Correctness depends on the second property, not merely on the first.

How It Works

Acquire, Act, Fence

The lock service must be able to generate monotonically increasing tokens. ZooKeeper can use zxid or znode version information. etcd exposes creation revisions for election leader keys and lock keys that can be used in transactions. A database can use a sequence inside a serializable transaction.

type LeaseGrant struct {
    LockName string
    Owner    string
    Token    int64
    Expires  time.Time
}

func AcquireLease(tx Tx, name, owner string, ttl time.Duration) (LeaseGrant, error) {
    row := tx.GetLockForUpdate(name)
    now := tx.Now()
    if row.Owner != "" && row.Expires.After(now) {
        return LeaseGrant{}, ErrLocked
    }
    row.Token++
    row.Owner = owner
    row.Expires = now.Add(ttl)
    tx.Save(row)
    return LeaseGrant{Name: name, Owner: owner, Token: row.Token, Expires: row.Expires}, nil
}

Reject Stale Writes

The protected table or service stores the highest token accepted for each resource. The update succeeds only when the incoming token is greater than or equal to the stored token and the mutation is applied in the same atomic operation.

func WriteWithFence(tx Tx, resourceID string, token int64, payload []byte) error {
    resource := tx.GetResourceForUpdate(resourceID)
    if token < resource.HighestFenceToken {
        return ErrStaleLease
    }
    resource.HighestFenceToken = token
    resource.Payload = payload
    resource.UpdatedAt = tx.Now()
    tx.Save(resource)
    return nil
}

Handle Lost Ownership

Clients should still stop work when they detect lease loss, but that is an optimization and cleanup path. The safety property must hold even if the client is paused through expiration, misses a watch event, receives delayed success responses, or resumes with stale local state.

Practical Applications

Distributed Schedulers

A scheduler leader may enqueue jobs, update checkpoints, or send emails. If two leaders overlap after a GC pause or network partition, duplicated work can be merely expensive or can corrupt state. Fencing each checkpoint write ensures an old leader cannot overwrite a newer leader’s progress.

Exclusive Writers to External Storage

Object stores, file systems, and search indexes often sit outside the lock service. A worker that holds a lease should include its fencing token with every manifest update or batch commit. The storage layer should reject stale tokens before making the write visible.

Leader Election and Configuration

etcd’s election API returns a leader key with a creation revision that can be tested in transactions. ZooKeeper exposes globally ordered zxids and per-znode versions. Those monotonic values are useful because they let later leaders prove freshness to systems that need more than a best-effort lock.

Implementation Patterns

Prefer Consensus-Backed Token Sources

A token generator must not hand out the same or lower token after failover. That usually means using a linearizable database sequence, ZooKeeper, etcd, Consul sessions with modify indexes, or a Raft-backed service. Independent Redis nodes with local counters do not provide one global monotonic sequence without another consensus layer.

Make the Fence Part of the Schema

Store highest_fence_token next to the protected row, object manifest, shard lease, or job checkpoint. Update it in the same transaction as the protected mutation. This makes stale-owner rejection auditable and testable.

UPDATE shard_checkpoint
SET highest_fence_token = :token,
    last_processed_offset = :offset,
    updated_by = :worker
WHERE shard_id = :shard
  AND highest_fence_token <= :token;

Separate Efficiency Locks from Correctness Locks

If duplicate work only wastes money, a best-effort lock with a clear failure mode may be enough. If duplicate work can corrupt data, the protected operation needs fencing, idempotency keys, compare-and-swap, or serializable transactions.

Performance Considerations

Common Pitfalls

Further Reading