Home › Distributed systems › Coordination
algorithm low

Leases

Problem A lock held by a crashed node blocks everyone forever. In leader election, shard ownership or cached reads, this lets ownership expire safely so another node can take over without hearing from the old holder.

A lock that expires on its own: bounded-time ownership that survives crashes and partitions, given bounded clock drift and fencing tokens on writes.

O(1) per grant/renew; one round trip to acquire; renew every ~1/3 of the term
coordinationlockingleader-electionfencingtime
Overview

A lock held by a crashed node blocks everyone forever. A lease grants ownership for a fixed term: the holder renews before expiry, and after the term the grantor may reassign without hearing from the old holder. Safety rests on durations, not synchronized clocks: the holder starts its timer before sending the request and assumes a shorter term, the grantor a longer one.

Key facts

Use when

  • Leader election, shard ownership, local linearizable reads at a consensus leader.
  • Time-limited ownership in other fields: file-cache consistency and write locks (Gray and Cheriton 1989, HDFS), network addresses (DHCP leases, RFC 2131), cluster controllers (Kubernetes leader election).

Avoid when

  • The resource can't check fencing tokens and correctness matters.

Deep dive

How it works

In: Acquire(client, term d) and Renew requests · Out: a grant with a fencing token, or a refusal while another holder's lease is live · State: grantor: holder, expiry (its own monotonic clock) and the last token; holder: its local expiry and token · Cost: O(1) per grant or renew, one round trip

Holder H, grantor G, term d, max drift rate ρ, small safety margin ε.

  1. H records t0 = monotonic_now() before sending Acquire.
  2. G, if the lease is free or expired (by G's own monotonic clock), increments a fencing token, grants for d, and records expires = now_G + d·(1+ρ) + ε.
  3. H, on the grant, considers itself owner until t0 + d·(1−ρ) − ε. Network delay only shortens H's view, never lengthens it.
  4. H renews well before expiry (e.g. at d/3 remaining); renewals keep the same token.
  5. Every write H makes to shared storage carries the token; storage remembers the highest token seen and rejects lower ones.
  6. If H can't renew, it must stop acting at its expiry; G will reassign after its (later) expiry.
H:  t0 ──Acquire──►                       H believes:  [t0 ........ t0+d(1-ρ)-ε]
G:         grant at tG (token 34) ──►     G believes:  [tG .............. tG+d(1+ρ)+ε]
                                          G's interval always ends after H's
storage: write(token 34) ok; later write(token 33) from a paused old holder → rejected

Code

use std::time::{Duration, Instant};

pub struct Grant { pub token: u64, pub term: Duration }

pub struct LeaseServer {
    holder: Option<(u64, Instant)>, // (client, expiry on the server's monotonic clock)
    token: u64,
    drift: f64,                     // e.g. 1e-4 = 100 ppm, generous for quartz
    margin: Duration,
}

impl LeaseServer {
    pub fn new(drift: f64, margin: Duration) -> Self { LeaseServer { holder: None, token: 0, drift, margin } }

    /// Acquire or renew. Renewal by the current holder keeps its token.
    pub fn acquire(&mut self, client: u64, term: Duration, now: Instant) -> Option<Grant> {
        match self.holder {
            Some((h, exp)) if h != client && now < exp => return None, // held by someone else
            Some((h, exp)) if h == client && now < exp => {}            // renewal
            _ => self.token += 1,                                       // new owner: new epoch
        }
        let exp = now + term.mul_f64(1.0 + self.drift) + self.margin;  // server waits longer
        self.holder = Some((client, exp));
        Some(Grant { token: self.token, term })
    }

    pub fn release(&mut self, client: u64) {
        if matches!(self.holder, Some((h, _)) if h == client) { self.holder = None; }
    }
}

pub struct LeaseHolder { pub token: u64, valid_until: Option<Instant>, term: Duration, drift: f64, margin: Duration }

impl LeaseHolder {
    pub fn new(drift: f64, margin: Duration) -> Self {
        LeaseHolder { token: 0, valid_until: None, term: Duration::ZERO, drift, margin }
    }
    /// `sent_at` must be captured BEFORE the request went out.
    pub fn on_grant(&mut self, sent_at: Instant, g: Grant) {
        self.token = g.token;
        self.term = g.term;
        // Holder assumes a shorter lease; saturates to sent_at if margin > term.
        let held = g.term.mul_f64(1.0 - self.drift).saturating_sub(self.margin);
        self.valid_until = Some(sent_at + held);
    }
    pub fn is_valid(&self, now: Instant) -> bool { self.valid_until.map_or(false, |v| now < v) }
    pub fn should_renew(&self, now: Instant) -> bool {
        self.valid_until.map_or(true, |v| v.saturating_duration_since(now) < self.term / 3)
    }
}

/// The resource enforces ordering: a paused ex-holder's late write is rejected.
#[derive(Default)]
pub struct FencedStore { max_token: u64, pub data: Vec<u8> }

impl FencedStore {
    pub fn write(&mut self, token: u64, data: &[u8]) -> Result<(), u64> {
        if token < self.max_token { return Err(self.max_token); }
        self.max_token = token;
        self.data = data.to_vec();
        Ok(())
    }
}

is_valid must be checked right before each externally visible action, and the action must still carry the token, because the process can pause between the check and the I/O.

Complexity and constants

  • Acquire: 1 RTT (plus a consensus write if the grantor is replicated, as it should be).
  • Renew traffic: one RPC per holder every ~d/3; 10,000 sessions with 12 s terms ≈ 2,500 renewals/s.
  • Unavailability on holder failure: up to d·(1+ρ) + ε + detection; Kubernetes defaults ⇒ order of 15-20 s.
  • Drift margin: 100 ppm on a 10 s lease = 1 ms; the network RTT and GC pauses dominate, not drift.

Efficiency

  • Cost: one round trip to acquire (plus a consensus write if the grantor is replicated), then one small RPC per holder every d/3: 10,000 sessions with 12 s terms are about 2,500 renewals/s. After a holder fails, the resource is unavailable for up to d·(1+ρ) + ε plus detection, order of 15-20 s with Kubernetes defaults. Drift margin is tiny (100 ppm on 10 s = 1 ms). Bound: network latency, pauses and renewal load on the grantor.
  • Levers:
    • one session or lease shared by many locks — renewals per client stay at one stream, not one per lock (Chubby sessions, etcd leases) — clients holding many resources
    • longer term — fewer renewals but slower failover (arithmetic above) — stable holders
    • renew at d/3 on a dedicated thread — survives one or two lost renewals without losing the lease (above) — every deployment
    • leader lease for reads — local reads at the leader with no quorum round per read (Spanner, CockroachDB) — read-heavy replicated stores
    • batch renewals into one keepalive stream — fewer RPCs at the grantor (order of) — thousands of sessions
  • Naive → standard → state of the art:
    • lock with no expiry, released by hand · blocks forever when the holder crashes
    • lease with a term, fencing token on writes · 1 RTT to acquire, 1 renewal per d/3
    • shared session leases and leader leases for reads · renewals per client, not per lock; reads without a quorum round

Implementation notes

  • Use CLOCK_MONOTONIC/Instant for every duration; never compare wall clocks between machines.
  • CLOCK_MONOTONIC on Linux stops during system suspend (CLOCK_BOOTTIME doesn't); a suspended holder would think less time passed. Use BOOTTIME where suspend is possible.
  • The grantor itself must be fault-tolerant: store lease state in a Raft/Paxos group (etcd leases, Chubby). A new grantor leader must wait out the maximum outstanding lease term before granting.
  • Leader leases for reads: a Raft/Paxos leader serves reads locally while its lease, granted by a majority, is valid. Followers promise not to vote for another leader until the lease runs out (Spanner, CockroachDB range leases).
  • Renew asynchronously on a dedicated thread with its own timeouts, so a slow request path doesn't starve renewals.
  • On losing a lease, fail fast: stop serving, abort in-flight work, and exit if unsure.

Pitfalls

  • Starting the holder's timer when the response arrives instead of before the request: the holder overestimates its lease by the RTT.
  • GC pause / VM stall between "lease valid?" and the write: the classic split-brain. Only fencing tokens at the resource fix it.
  • Using a lease in a store that doesn't check tokens (e.g. a plain distributed lock around writes to S3) gives no safety, just probable mutual exclusion.
  • Renew interval too close to the term: one slow renewal loses the lease and triggers failover storms.
  • Assuming lease expiry implies the old holder has stopped: it implies only that it should have.

Variants and alternatives

Fencing tokens / epochsChubby sequencers, ZooKeeper zxid; also usable with no lease at all if the storage is the arbiter.
Raft ReadIndexlinearizable reads with a heartbeat round instead of clock assumptions.
Two-phase commitwhen several resources must change together, not exclusive ownership.
Session-based ephemeral nodes (ZooKeeper/etcd)leases tied to a client session that holds many locks.

In production

Google Chubby (sessions + KeepAlive, 12 s default lease), Spanner Paxos leader leases (10 s), etcd leases (Kubernetes leader election, distributed locks), CockroachDB range leases, HDFS NameNode client leases for file writers, Kafka controller epochs as fencing tokens.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
etcd-clientRustetcdv3/etcd-clientcargo add etcd-client————etcd v3 client: lease grant, keep-alive, revoke
etcdGoetcd-io/etcdbrew install etcd————leases attached to keys; basis of Kubernetes leader election
Apache ZooKeeperJavaapache/zookeeperbrew install zookeeper————session-bound ephemeral nodes
ConsulGohashicorp/consulbrew install consul————sessions with TTL for locks and leader election
client-go leaderelectionGokubernetes/client-gogo get k8s.io/client-go————tools/leaderelection with a Lease object

Sources

  • Cary G. Gray, David R. Cheriton, Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency, SOSP 1989. paper
  • Mike Burrows, The Chubby Lock Service for Loosely-Coupled Distributed Systems, OSDI 2006. paper
  • James C. Corbett et al., Spanner: Google's Globally-Distributed Database, OSDI 2012. paper
  • Martin Kleppmann, Designing Data-Intensive Applications, O'Reilly 2017 (ch. 8, fencing tokens). book
  • Droms, Dynamic Host Configuration Protocol, RFC 2131, 1997. RFC

Builds on

Used by

Cache invalidationA service keeps copies of data on many cache servers or edge nodes. When the source changes, some copies keep serving the old value, and nobody can find all copies quickly or tell a late refill from a fresh one.Chain replicationA storage system keeping several copies of data in one datacenter must stay consistent and survive node crashes while moving large amounts of data. This is a way to replicate writes without voting.
consumer group rebalancing · plannedAssigning partitions to group members with generations, sticky/cooperative rebalance
Distributed ID generationMany servers or shards need to create unique record IDs without asking a central counter, which would be a bottleneck and single point of failure, while IDs still index well and roughly follow creation time.Durable job queueBackground work such as emails, webhooks, media processing and billing must not vanish if a worker or the queue crashes. This makes sure every submitted job eventually runs, with retries.Idempotency keysAnyone calling a service over an unreliable network must retry when a reply is lost, yet repeating an action such as a charge, a booking or a server launch must not perform it twice.
ip address management · plannedCIDR allocation, subnet carving and lease tracking with bitmaps or prefix trees
linearizable reads · plannedReadIndex and leader-lease reads without writing to the log
Multi-PaxosReplicated systems need several machines to agree on one ordered log of commands despite failures. This is the classic consensus protocol, preferred when you need more flexibility than Raft offers.
online schema change · plannedShadow table, backfill, change replay and atomic cutover; expand-contract migrations
parallel file system striping · plannedStriping files across object storage targets (Lustre, GPFS); collective I/O and metadata servers
Primary-backup replicationA stateful service on one machine must keep running after that machine dies, without losing updates it already confirmed to clients, at the price of extra machines and some added write latency.
quota reservation · plannedReserve, consume and release usage units against balances with validity timers (credit control)
RaftSeveral machines must agree on an ordered log of commands and keep identical state, surviving failures, for metadata, locks, configuration or sharded databases. This is the understandable consensus protocol for that.
schema migration versioning · plannedVersioned forward migrations for local and server databases; expand-contract changes
service discovery · plannedRegister endpoints with health and watch for changes (DNS, registries, watches)
viewstamped replication · plannedVSR: primary-backup consensus with view changes and recovery

Systems (54):

Alternatives and related