Lesson 30 · Consensus · Module 3

Consensus as a Product

Lessons 27 to 29 built a consensus algorithm. Nobody wants a consensus algorithm. What people want is for exactly one worker to run the nightly job, and for the other workers to find out promptly when it dies. This lesson is about the service that sits between those two sentences.

The win in this lesson: you will be able to build leader election out of ephemeral sequential znodes, and explain why the herd-avoiding version has each contender watch only its immediate predecessor rather than the whole set.

1. Everyone rebuilds the same four things, badly

Before the mechanism, the observation that motivates it. Coordination is not one problem; it is a small, stubbornly recurring family of them:

"Large-scale distributed applications require different forms of coordination. Configuration is one of the most basic forms of coordination. […] Group membership and leader election are also common in distributed systems: often processes need to know which other processes are alive and what those processes are in charge of. Locks constitute a powerful coordination primitive that implement mutually exclusive access to critical resources."

ZooKeeper: Wait-free coordination for Internet-scale systems, Hunt, Konar, Junqueira & Reed, USENIX ATC 2010, §1 — de-hyphenated across the PDF's line breaks

Four needs, and every team meets them the same way: a row in the database with a locked_until column, a heartbeat table, a config file shipped by the deploy pipeline. Each of those is a small, private, untested consensus implementation, and lesson 26 has already told you how those end.

The obvious response is to ship a service per primitive — a lock service, an election service, a membership service. ZooKeeper's authors refused:

"When designing our coordination service, we moved away from implementing specific primitives on the server side, and instead we opted for exposing an API that enables application developers to implement their own primitives. Such a choice led to the implementation of a coordination kernel that enables new primitives without requiring changes to the service core."

Hunt et al., §1 — de-hyphenated

That is the product decision this whole lesson hangs on. The server ships a tiny, boring data model. The interesting primitives — locks, elections, barriers — are library code on the client. The server never learns the word "lock".

2. The data model: a tiny file system that forgets

"ZooKeeper provides to its clients the abstraction of a set of data nodes (znodes), organized according to a hierarchical name space."

Hunt et al., §2.1 — de-hyphenated

It looks like a file system and it is deliberately not one. Znodes hold at most a megabyte by default — "Each znode in the tree stores a maximum of 1MB of data by default" (§4) — reads and writes are whole-value, and there is no open() or close(): every request carries the full path. What you store here is metadata about your system, not your system's data.

Three flags on create() do nearly all the work.

KindCreated byDeleted byWhat it is for
Regular create(path, data, 0) An explicit delete(), and nothing else Configuration, ownership records, anything that must outlive its author
Ephemeral create(…, EPHEMERAL) An explicit delete, or the death of the creating session Liveness: "I am still here". Cannot have children
Sequential create(…, SEQUENTIAL) As above; the flag only affects the name A queue position, handed out by the service, never by a client

The ephemeral flag is the one that removes work from you:

"Ephemeral: Clients create such znodes, and they either delete them explicitly, or let the system remove them automatically when the session that creates them terminates (deliberately or due to a failure)."

Hunt et al., §2.1 — de-hyphenated

Note "or due to a failure". This is failure detection you do not have to write. The sequential flag is the other half:

"Nodes created with the sequential flag set have the value of a monotonically increasing counter appended to its name. If n is the new znode and p is the parent znode, then the sequence value of n is never smaller than the value in the name of any other sequential znode ever created under p."

Hunt et al., §2.1 — de-hyphenated

Set both flags at once and a single call buys you two things that are painful to build separately: a position in a global queue, and an entry that disappears the moment you do.

/ /config regular · data: endpoint=db-a · version 7 /members regular · its children are the live set worker-7 ephemeral · session 0xA1 alive worker-9 ephemeral · session 0xB4 alive /elect regular · its children are the queue n-0000000001, ...02, ...03 ephemeral + sequential · ...01 leads
One namespace, three jobs. /config survives everything; the children of /members vanish with their sessions, so listing them is a liveness check; the children of /elect are the same trick plus an order. No server-side feature distinguishes these three — only the flags the client passed to create().

3. Watches: one shot, and that is the point

Polling a coordination service is how you turn it into your bottleneck. Watches replace the poll:

"When a client issues a read operation with a watch flag set, the operation completes as normal except that the server promises to notify the client when the information returned has changed."

Hunt et al., §2.1 — de-hyphenated

Then two sentences that every bug report about ZooKeeper eventually traces back to:

"Watches are one-time triggers associated with a session; they are unregistered once triggered or the session closes. Watches indicate that a change has happened, but do not provide the change."

Hunt et al., §2.1

A watch is not a subscription and not a message queue. It is a cache invalidation: what you hold is stale. It carries no payload and it fires at most once. Read the value again — with a new watch — or stop caring.

client · reader its server · local replica some writer arming the watch getData("/config", watch=true) data v7 · one watch now registered for this session setData v8 NodeDataChanged · no data attached · watch consumed no watch is registered from here until the client sets another one setData v9 no notification — nothing is watching setData v10 no notification — still nothing watching client handles the event it re-reads, it does not replay getData("/config", watch=true) data v10 · watch re-armed · v8 and v9 were never seen three changes, one notification, and the client still ends up correct
Missing v8 and v9 is not a failure — it is the design. The paper puts it plainly: a client that misses three notifications loses nothing, "since those three events would have simply notified the process of something it already knows: the information it has for zc is stale" (§2.4). Build on watches only when the latest value is what you need. If you need every value, you want a log, not a watch.
The one idea worth remembering

A watch is an invalidation, not an event stream. It tells you that something changed, never what, and it tells you at most once. Any design that counts watch notifications, or expects one per write, is already wrong.

One ordering guarantee makes watches safe to build on, and it is easy to miss. The notification overtakes the data: "if a client is watching for a change, the client will see the notification event before it sees the new state of the system after the change is made" (§2.3, de-hyphenated). You can therefore never read the new value while still believing your watch has not fired.

4. The bargain: linearizable writes, local reads

ZooKeeper promises exactly two orderings, and it is worth reading what is not in them.

"Linearizable writes: all requests that update the state of ZooKeeper are serializable and respect precedence; […] FIFO client order: all requests from a given client are executed in the order that they were sent by the client."

Hunt et al., §2.3 — de-hyphenated, and the two guarantees are separated by their labels in the original

Writes go through atomic broadcast to the leader and a majority — lesson 29's machinery, wearing a different name. Reads do not go anywhere:

"Because only update requests are A-linearizable, ZooKeeper processes read requests locally at each replica. This allows the service to scale linearly as servers are added to the system."

Hunt et al., §2.3

That is the whole trade, and the paper does not hide the bill:

"One drawback of using fast reads is not guaranteeing precedence order for read operations. That is, a read operation may return a stale value, even though a more recent update to the same znode has been committed."

Hunt et al., §4.4 — de-hyphenated
OperationPath through the ensembleOrdering you getWhat it costs
setData, create, delete Forwarded to the leader, broadcast, majority, logged to disk Linearizable with every other write A round trip to a quorum plus a disk force
getData, getChildren, exists Answered from the local replica's memory. Nothing leaves the server Your own writes, in order; other clients' writes, eventually Nothing — and it may be arbitrarily stale
sync then a read Queued at the leader behind pending writes, then read locally Sees everything committed before the sync was issued Roughly a lightweight write, without the broadcast

The escape hatch is one call:

"sync(path) : Waits for all updates pending at the start of the operation to propagate to the server that the client is connected to. […] To guarantee that a given read operation returns the latest updated value, a client calls sync followed by the read operation."

Hunt et al., §2.2 and §4.4 — de-hyphenated

You need it in exactly one situation, and it is worth being able to recognise: when the news reached you by some path other than ZooKeeper. A teammate's service tells you over HTTP that it has published new config; you read the znode and see the old value, because your replica has not caught up.

client A follower F1 leader follower F2 client B a write: leader, quorum, disk setData("/x", 2) forwarded proposal, majority ack, logged ok — the write is now committed and durable F2 has not applied it yet — and nothing forces it to A tells B over its own channel: "config is updated" getData("/x") returns 1 — stale, and not an error B is not wrong to be surprised; it learned the news outside ZooKeeper sync() queued at the leader behind everything pending pending writes delivered · F2 applies 2 getData("/x") returns 2
The stale read is not a bug in F2; F2 is doing exactly what the design asks. The bug, if there is one, is the dashed arrow — a second channel carrying causality that ZooKeeper cannot see. Lesson 21's happens-before is invisible to a system that was never told about the edge.

5. The recipes

Now the payoff. Every primitive below is client library code over the same four calls.

Group membership

"We take advantage of ephemeral nodes to implement group membership. […] If the process fails or ends, the znode that represents it under zg is automatically removed."

Hunt et al., §2.4 — de-hyphenated

Each member creates one ephemeral child of /members and then forgets about it. Membership is getChildren("/members"). There is no deregistration path to get wrong, no reaper job, no tombstone with a TTL — the liveness check is the session itself.

Configuration

Read the config znode with a watch; on notification, read it again with a new watch. The subtle part is publishing a set of values atomically, and the trick is a second znode used as a flag:

"the new leader can designate a path as the ready znode; other processes will only use the configuration when that znode exists. The new leader makes the configuration change by deleting ready , updating the various configuration znodes, and creating ready . […] Because of the ordering guarantees, if a process sees the ready znode, it must also see all the configuration changes made by the new leader."

Hunt et al., §2.3 — de-hyphenated

FIFO client order does the work. No transaction spans the writes; the ordering guarantee makes one unnecessary. And if the publisher dies halfway, ready simply never reappears, so nobody uses the half-written config.

Locks and leader election, the naive version

The simplest lock is a race to create one ephemeral znode: "To acquire a lock, a client tries to create the designated znode with the EPHEMERAL flag. If the create succeeds, the client holds the lock" (§2.4). Losers watch it and retry when it disappears. This works, and it has a failure mode you met in lesson 03:

"While this simple locking protocol works, it does have some problems. First, it suffers from the herd effect. If there are many clients waiting to acquire a lock, they will all vie for the lock when it is released even though only one client can acquire the lock."

Hunt et al., §2.4

One deletion wakes 500 clients. Of those, 499 lose and go back to watching. The cache stampede in lesson 03 had exactly this shape, and so does the fix: make sure only one waiter is woken.

The herd-avoiding version — the one to memorise

Here is the recipe, reproduced from the paper's Lock pseudo-code (§2.4), with its line numbers and spacing normalised:

Lock
  1  n = create(l + "/lock-", EPHEMERAL|SEQUENTIAL)
  2  C = getChildren(l, false)
  3  if n is lowest znode in C, exit
  4  p = znode in C ordered just before n
  5  if exists(p, true) wait for watch event
  6  goto 2

Unlock
  1  delete(n)

Six lines, and three of them are load-bearing. Line 1 gets you a queue position from the service: "The use of the SEQUENTIAL flag in line 1 of Lock orders the client's attempt to acquire the lock with respect to all other attempts" (§2.4, de-hyphenated). Line 3 is the entire winning condition — lowest wins, and no client has to be told it won. Line 5 is the herd fix:

"By only watching the znode that precedes the client's znode, we avoid the herd effect by only waking up one process when a lock is released or a lock request is abandoned."

Hunt et al., §2.4

Count the watches. Every znode in the queue is watched by exactly one other client — the one directly behind it. So a deletion anywhere in the queue can only ever fire one notification:

"The removal of a znode only causes one client to wake up, since each znode is watched by exactly one other client, so we do not have the herd effect; […] There is no polling or timeouts;"

Hunt et al., §2.4

Leader election is this recipe with the names changed. "Holds the lock" becomes "is the leader"; the queue becomes the succession order. Nothing else differs.

C1 C2 C3 ZooKeeper ensemble everyone creates one node under /elect create("/elect/n-", EPHEMERAL|SEQUENTIAL) n-0000000001 create("/elect/n-", EPHEMERAL|SEQUENTIAL) n-0000000002 create(...) n-0000000003 each client lists the children once, then decides alone ...01 is lowest C1 is the leader exists("/elect/n-0000000001", watch=true) — predecessor only exists("/elect/n-0000000002", watch=true) steady state: ...01 watched by C2, ...02 watched by C3, ...03 watched by nobody two waiters, two watches — not two watches each, and not one watch per waiter on the whole set C3 is not watching the leader at all, and does not need to be C1 session expires crash, GC pause or partition no heartbeat within the session timeout — ensemble deletes n-0000000001 NodeDeleted — sent to C2 and to nobody else getChildren("/elect") — ...02 is now lowest, C2 leads C3 gets nothing, keeps its one watch on ...02 one failure, one deletion, one notification, one new leader
Compare the cost with lesson 28's Raft election: no votes, no terms, no rounds — because the consensus already happened inside the ensemble when the create calls were ordered. The clients are only reading off a queue. Note that C2 must still call getChildren after waking: its predecessor may have been an abandoned request rather than the leader, in which case C2 is still not lowest.

That last point is line 6 — goto 2 — and the paper spells out why it is not optional: "Once the znode being watched by the client goes away, the client must check if it now holds the lock. (The previous lock request may have been abandoned and there is a znode with a lower sequence number still waiting for or holding the lock.)" (§2.4, de-hyphenated). Waking up is not winning.

6. Residual risk

Three prices, all of them design choices rather than defects, and all of them things you will meet in production before you meet them in the documentation.

Your read may be stale, and there is no error code for it. A follower that has fallen behind answers happily. If a decision depends on a value another client wrote, and you learned of that write outside ZooKeeper, you need sync first — or you need to restructure so the news arrives as a watch. The authors were candid that this is the deal they struck:

"Although our consistency guarantees for reads and watches appear to be weak, we have shown with our use cases that this combination allows us to implement efficient and sophisticated coordination protocols at the client even though reads are not precedence-ordered and the implementation of data objects is wait-free."

Hunt et al., §7 — de-hyphenated

Watch semantics punish clever code. One shot; no payload; re-arm on every read. Two extra traps: notifications are tracked only by the server you happen to be attached to — "Only the server that a client is connected to tracks and triggers notifications for that client" (§4.4) — and connection-loss events arrive on the same callback, precisely "so that clients know that watch events may be delayed" (§2.1, de-hyphenated). Treat a session event as "everything you believe may now be wrong", not as noise to be logged and ignored.

Session expiry is the failure detector, and it is a timeout. That means it can be wrong:

"To detect client session failures, ZooKeeper uses timeouts. The leader determines that there has been a failure if no other server receives anything from a client session within the session timeout."

Hunt et al., §4.4 — de-hyphenated

And the boundary conditions of the service itself: availability holds only while "a majority of ZooKeeper servers are active and communicating" (§2.3, de-hyphenated), writes cost a quorum round trip plus a disk force, and the design is tuned for a read-heavy world — the abstract names a "2:1 to 100:1 read to write ratio". A coordination service used as a database will disappoint you in a way the paper never promised otherwise.

7. Check yourself

8. Back to your world

Find the place in your own system where exactly one thing must be true at a time — one scheduler, one migration runner, one consumer of a partition. Ask it three questions.

First: when the holder dies without cleaning up, what deletes its claim? If the answer is a timestamp column and a cron job, you have written an ephemeral node badly. Second: when the claim disappears, how many processes wake up? If the answer is "all of them", you have the naive lock, and the fix is to give the waiters an order and have each watch only the one in front. Third: when a waiter wakes, does it re-check, or does it assume it won? Line 6 exists for a reason.

Then the uncomfortable one. Your lock protects something — a table, a file, an external API. If the holder pauses for thirty seconds and loses its session, that something will receive writes from two processes that both believe they hold the lock. Does it reject the older one?

Ask me things. "show me the leader election recipe as real code with the reconnect handling" · "why is a watch on the parent's children different from a watch on one child?" · "when exactly do I need sync, with a concrete example?" · "what does a session expiry look like from inside the client library?" · "I want to use this as a config database for 200 services. Grill me."

Read the primary source

ZooKeeper: Wait-free coordination for Internet-scale systems, Patrick Hunt, Mahadev Konar, Flavio P. Junqueira and Benjamin Reed, USENIX ATC 2010. Read §2.1 to §2.4 — data model, API, guarantees and the recipes — which is under five pages and is the whole of this lesson. §4.4 is where the stale-read admission and sync live. Quotations here are taken from the PDF text and de-hyphenated where the typesetting broke a word across a line; elisions are marked […].

Carry on

  • Previous: Lesson 29 · Course home: index · Plan: CURRICULUM.md
  • Next: what happens when the coordination service itself is the thing you cannot reach — and why "just add a lock" is usually a sign the design needs changing rather than fencing.