Post

Leader Election with Leases

Electing one processor among several instances, with fencing tokens and priority-based handover

Leader Election with Leases

This guide covers the lease operations on EventStorage — the primitive a framework or an application builds leader election on, so exactly one instance of a deployment processes a stream while the others stand by.

What a Lease Is For

A bookmark says where a reader got to. It does not say which instance is entitled to move it. Three replicas of the same service all holding the reader name order-dispatcher will each happily project and each happily bookmark, and the work that must not happen twice — publishing to an external system, sending a mail, calling a payment provider — happens three times.

A named lease answers the other half: one owner holds it, everybody else stands by, and the holder is the only legitimate processor until it releases the lease or lets it expire.

1
2
3
4
5
6
7
8
9
10
11
12
EventStorage storage = PostgresEventStorage.newBuilder().build();

LeaseResponse response = storage.requestLease(
    new LeaseRequest("order-dispatcher",     // the lease name, globally unique in the storage
                     "pod-7f3a9",            // this instance's identity
                     0,                      // priority
                     Duration.ofSeconds(30)) // time-to-live
);

if (response.status() != LeaseStatus.STANDBY) {
    dispatchPendingOrders(response.fencingToken());
}

Leases live on the storage, not on the EventStore or an EventStream — like importing, this is infrastructure rather than domain. If you built your store with buildStore(), keep a reference to the storage as well when you intend to elect on it:

1
2
EventStorage storage = PostgresEventStorage.newBuilder().build();
EventStore eventStore = EventStoreFactory.get().eventStore(storage);

Everything involved lives in the API module, so no extra dependency is needed: Lease in org.sliceworkz.eventstore.events, and LeaseRequest, LeaseResponse and LeaseStatus nested in EventStorage itself (org.sliceworkz.eventstore.spi).

One Call Does Everything

requestLease(...) is acquisition, renewal and contender registration in a single call. Every instance — leader and standby alike — simply calls it on a timer and reads the answer:

StatusWhat it meansWhat to do
LEADERyou hold the lease and are the single legitimate processorprocess
LEADER_STEP_DOWN_REQUESTEDyou still hold it, but a live contender with a strictly higher priority is waitingfinish the current unit of work, then releaseLease
STANDBYsomeone else holds a live lease; you are registered as a contenderdo nothing, keep requesting

There is no separate “join the election” call, and no watch to register. A standby is simply an instance whose last request was recent enough to count as live.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class DispatcherLoop {

    private static final String LEASE = "order-dispatcher";
    private static final Duration TTL = Duration.ofSeconds(30);

    private final EventStorage storage;
    private final String me;          // stable for this instance's lifetime, unique among instances
    private final long priority;

    void tick() {
        LeaseResponse response = storage.requestLease(
            new LeaseRequest(LEASE, me, priority, TTL));

        switch (response.status()) {
            case LEADER -> dispatchPendingOrders(response.fencingToken());

            case LEADER_STEP_DOWN_REQUESTED -> {
                dispatchPendingOrders(response.fencingToken());   // finish this unit of work
                storage.releaseLease(LEASE, me);                  // then hand over
            }

            case STANDBY -> { /* nothing to do */ }
        }
    }
}

Call tick() on a schedule of roughly a third of the ttl — that leaves two failed attempts before leadership lapses.

The owner string must be stable for the instance’s lifetime and unique among contenders. A pod name, a container id, or a hostname plus a start-up UUID all work; a value that changes on every request turns one instance into an endless stream of new contenders.

Expiry Is Judged on the Storage’s Clock

A lease is live while its last heartbeat is younger than the ttl it was requested with, measured on the storage’s clock — never on any contender’s. Contenders’ clocks therefore need not agree with each other or with the database: they only ever measure durations on their own clock (their polling interval, and the time since their last confirmed renewal), never compare instants across machines.

The guarantee a caller gets: between two calls that both returned LEADER (or LEADER_STEP_DOWN_REQUESTED), no other owner has held the lease — provided the caller stops acting as leader the moment it can no longer confirm a renewal within the ttl it asked for.

1
2
3
4
5
6
7
8
LeaseResponse response;
try {
    response = storage.requestLease(new LeaseRequest(LEASE, me, priority, TTL));
} catch (EventStorageException e) {
    // a renewal that failed or hung is not evidence of still being the leader
    stopProcessing();
    return;
}

That half is the caller’s job and cannot be moved into the store: storage-clock expiry plus self-demotion on the caller’s clock is what keeps two leaders from overlapping. The one case neither can prevent is a process paused past its own ttl — a long GC pause, a suspended VM — which is exactly what the fencing token is for.

Fencing Tokens

Every response carries the lease’s current fencing token: the caller’s own when it is the leader, the current holder’s when it is standing by.

  • It strictly increases on every change of ownership, starting at 1 for the first owner
  • It is stable across renewals by the same owner
  • It never resets — releasing a lease does not delete it, it backdates the heartbeat, precisely so the token survives

Stamp outgoing work with the token, and a downstream store can reject anything arriving with a token lower than the highest it has already seen. That is how a zombie leader — one that was paused past its ttl and woke up still believing it holds the lease — is recognised rather than merely hoped against.

1
2
3
4
5
void dispatchPendingOrders(long fencingToken) {
    // the receiving side keeps the highest token it has accepted for this lease
    // and refuses anything older, so a superseded leader's writes are rejected
    downstream.publish(batch, fencingToken);
}

Priority and Planned Handover

Priority is what makes handover deliberate rather than a race won by whoever happens to poll first.

A renewal turns into LEADER_STEP_DOWN_REQUESTED as soon as a live contender with a strictly higher priority exists. Equal or lower priorities never trigger it. The storage never revokes a live lease itself — a step-down is always the holder’s own act, and a leader that cannot stop safely may keep renewing and keep the lease.

Useful shapes for this:

  • Zone or region preference — the instance closest to the data gets the higher priority, so it takes over as soon as it is healthy
  • Draining a node — raise the priority of the replacement instance and the outgoing one is asked to finish and hand over, instead of the work stopping for a whole ttl
  • Deploy ordering — the newly rolled-out instance outranks the old one, so leadership follows the deploy
1
2
// nudge the current leader into handing over, without killing it
long priority = runningInPreferredZone() ? 10 : 0;

Without a step-down, a takeover still happens — it just waits out the ttl or a voluntary release.

Releasing

1
storage.releaseLease("order-dispatcher", me);

A release makes the lease immediately acquirable, so the next request by any contender acquires it instead of waiting out the ttl. It also withdraws the owner’s contender registration.

It is idempotent and forgiving: releasing a lease you do not hold — because it expired and was taken over, or was never acquired — does nothing and does not throw. A release never touches a lease held by a different owner. Release on graceful shutdown; it is the difference between a rolling restart that pauses for milliseconds and one that pauses for a ttl.

Inspecting Leases

getLeases() returns a snapshot of every lease the storage records, including expired ones that nobody has taken over yet:

1
2
3
4
5
6
7
8
9
10
11
public record Lease (
    String leaseName,
    String owner,
    long priority,
    long fencingToken,
    Instant acquiredAt,     // storage clock
    Instant heartbeatAt,    // storage clock
    Duration ttl
) {
    public boolean isExpiredAt ( Instant now ) { /* ... */ }
}

An expired lease keeps its last owner and heartbeat until someone else acquires it, so liveness is judged against heartbeatAt — not against presence in the list:

1
2
storage.getLeases().forEach(lease -> LOGGER.info("{}: {} (token {}), last heartbeat {}",
        lease.leaseName(), lease.owner(), lease.fencingToken(), lease.heartbeatAt()));

isExpiredAt(now) expects an instant from the same clock that produced heartbeatAt — the storage’s. Passing Instant.now() from an application server compares two clocks, which is exactly what leases exist to avoid; treat that as a monitoring approximation, never as a decision to start processing.

Like bookmarks, leases are addressed globally by name, so the list spans the whole storage rather than any one stream.

Backend Support

The lease methods are optional on the SPI: their defaults throw UnsupportedOperationException, and the TCK gates its lease scenarios on Capability.LEASE so a backend written before leases existed skips them rather than failing them. See Testing.

BackendLeases
PostgreSQLyes — two coordination tables outside the event log
In-memoryyes — real contention between contenders within one storage instance
File-persisted in-memoryyes, but not persisted — a lease held by a process that no longer runs must expire, not be resurrected on reload

Because the in-memory backends contend for real, a test can elect between two contenders without a database — while a single process trivially wins everything it asks for.

On PostgreSQL

Leases are <prefix>leases and <prefix>lease_contenders, written in one short transaction on the ordinary pool and serialized per lease by an advisory lock. What that buys, spelled out:

  • Election traffic never touches the events table and takes no lock that any query or append takes
  • A waiting contender holds no transaction id, and lease writes are milliseconds — so leases neither pin pg_snapshot_xmin nor are held up by it. This is also why a lease is deliberately not modelled as events: event reads sit behind the xmin barrier, and one long writing transaction anywhere in the cluster would otherwise make every lease look expired at once
  • All timestamps are written and compared with now() in SQL, so the database is the single clock

Both tables are created by ENSURE and checked by VALIDATE, and the leases table needs no DELETE privilege. A deployment pinned to VALIDATE or NONE has to have them applied from the shipped DDL, like every other object in the schema. See Configuring PostgreSQL Storage.

Putting It Together with Bookmarks

Leader election and bookmarking answer different questions, and a resilient processor uses both: the lease decides who processes, the bookmark decides from where.

1
2
3
4
5
6
7
8
9
10
11
12
13
void tick() {
    LeaseResponse response = storage.requestLease(new LeaseRequest(LEASE, me, priority, TTL));
    if (response.status() == LeaseStatus.STANDBY) {
        return;                                  // someone else owns this reader right now
    }

    projector.readBookmark();                    // resume where the previous owner left off
    projector.run();                             // bookmarks per batch as it goes

    if (response.status() == LeaseStatus.LEADER_STEP_DOWN_REQUESTED) {
        storage.releaseLease(LEASE, me);         // the next owner picks up from the bookmark
    }
}

Reading the bookmark after winning the lease, rather than caching it at startup, is what makes a takeover correct — the previous owner moved it. That is what readBeforeEachExecution() does for a bookmarked projector; see Bookmark Read Frequencies.

Note what is not claimed here: the lease does not make processing exactly-once. A takeover after a committed batch whose bookmark did not land re-projects that batch, as it always would. Where that matters, the projection holds its own position — see Being Exactly-Once Against Your Own Store.

This post is licensed under CC BY 4.0 by the author.