Cover
About this book
A distributed system is a set of programs on separate machines that work together over a network and present themselves to their users as a single service. The machines are not reliable. Any of them may stop at any moment, a disk may be lost, and a message may arrive late, twice, or not at all. This book is about designing services that keep their promises while such things are happening, and about understanding exactly which promises can be kept.
It has 2 parts, of different character.
The first part designs and builds a single system from its foundations, a key-value store that runs on many machines. Chapter 1 is its system design: the requirements, the interfaces, the components, and the reasoning about sharding, replication and failure. Chapters 2 to 6 are its low-level design, in which each component is written in Rust, measured, and subjected to the failures it is claimed to survive. These are the chapters that print code. The ideas established here, the log, the majority, the leader, the lease and the conditional write, are the vocabulary of everything that follows.
The second part consists of system designs, each complete in itself: a rate limiter, a cache, a message queue, a payment system, a chat service, an exchange and others. Each begins from what the system is for, considers the algorithms and arrangements that could serve, and explains why one is chosen and what it costs. Where an algorithm proceeds in steps, a picture shows it step by step, at the reader’s own pace. These chapters print no code. Every one of them has a small simulation in the book’s repository, which produced the figures the chapter quotes, and which the reader is encouraged to run and to alter.
Running the code
The programs are in code/, a Cargo workspace. They use the Rust 2024 edition and are
built with Rust 1.99.0, which code/rust-toolchain.toml selects.
$ cd code
$ cargo build --release
$ cargo run -p crashsim
$ cargo run -p ratelimit
The programs run on any Linux machine. A single small board is enough for all of them, and 2 or 3 boards or cloud machines on a network make it possible to stop a real machine and watch the system continue. The measurements quoted in the book were taken on an NVMe disk, and timings on other machines will differ.
What we assume
We assume that the reader has used a Linux shell, curl and HTTP. The first part
assumes in addition that the reader can read Rust. Every other term is explained
where it first appears, and each chapter ends with a table of the terms it
introduced.
About the cover
The cover shows 5 lighthouses in a storm, each kept by a crab. Beams of light join their
lamps. Lightning has struck one lighthouse and its lamp is dark, and the other 4 still
shine, so the paper boat that carries a PUT between them still finds its way.
Each chapter opens with an animal at work on the chapter’s subject.
| Chapter | Character | Scene |
|---|---|---|
| 1 | Draft the owl | a blueprint of a router, the ring and a shard of 3 nodes |
| 2 | Ledger the tortoise | a ledger that receives every change before the answer is given, while lightning strikes |
| 3 | Relay the pigeons | pigeons that carry the newest line of the tortoise’s ledger to 2 other ledgers |
| 4 | The ballot cats | 3 cats in numbered boxes, 2 holding up their votes and the third wearing a crown |
| 5 | Switch the octopus | an octopus that hands a parcel labelled user-1 to the stack of boxes it belongs to |
| 6 | Bundle the beaver | a beaver that carries 4 logs to the disk in a single trip |
| 7 | Toll the gnu | a gnu beside a sign with the limit: 5 per second, a burst of 10 |
| 8 | Stamp the squirrel | a squirrel beside the 3 parts of an id |
| 9 | Recall the elephant | an elephant beside the rule for what a cache keeps |
| 10 | Latch the hermit crab | a hermit crab beside 2 tokens, of which the lower is refused |
| 11 | Post the pigeons | 2 pigeons beside a log with a mark at the committed offset |
| 12 | Shift the daemon | a daemon beside the 3 states of a job |
| 13 | Chime the penguin | a penguin beside the growing pauses between tries |
| 14 | Dash the hare | a hare beside a number written in base 62 |
| 15 | Echo the owl | an owl beside the 2 ways to build a feed |
| 16 | Crate the gopher | a gopher beside an object cut into chunks |
| 17 | Reel the whale | a whale beside a video cut into pieces of 4 seconds |
| 18 | Chirp the pigeons | 2 pigeons beside messages numbered 1 to 4 |
| 19 | Ledger the tortoise | a tortoise beside 2 entries that add up to 0 |
| 20 | The seat cats | a cat in a box numbered 12, held for 5 minutes |
| 21 | Dash the hare | a hare beside a grid of 9 squares |
| 22 | Hint the owl | an owl beside the completions of ka |
| 23 | Reach the octopus | an octopus beside the rules of a polite crawler |
| 24 | Gauge the owl | an owl beside a series stored as changes |
| 25 | Carry the beaver | a beaver beside a file moving from version 3 to 4 |
| 26 | The 2 penguins | 2 penguins beside cat becoming chats |
| 27 | Tally the squirrel | a squirrel beside a buyer and a seller who have not met |
| 28 | Count the hare | a hare beside a click counted when it happened |
The crab that appears beside the listings is Ferris, the mascot of the Rust community.
Key-Value Store: System Design
A key-value store is the simplest database that is of any practical use. It offers 2 operations, to store a value under a key and to return the value that was stored under a key, and it makes no attempt to understand what the values mean. Simple as it is, it lies underneath a remarkable number of other systems. A web application keeps its users’ sessions in one. A cluster of machines keeps its configuration in one. A file system uses one for its directory of files, and an object store for its index of objects. Most of the larger storage systems in use today contain a key-value store as their innermost component, and the designs in the later chapters of this book rely on one repeatedly.
On a single machine, a key-value store is an afternoon’s work: a table in memory and a file on disk. The difficulty, and the subject of this chapter and the 5 that follow, begins when the store has to outgrow 1 machine and to outlive it. The data may be larger than any 1 disk. The requests may be more than any 1 processor can answer. And the machine will certainly fail at some point, by losing power, or its disk, or its connection to the network, and a store on which other systems depend cannot be permitted to lose its data or to stop answering on that account. Meeting these demands means spreading the data across many machines and keeping several copies of every part of it, and the moment there are several copies, the questions arise that define the whole field: which copy is right when they differ, who decides, and what happens when the machines cannot reach each other to agree.
This chapter designs such a store from its requirements. It is the system design, and it stays at the level of components and the messages between them. Chapters 2 to 6 then build each component in Rust and measure it.
This chapter covers
- what the store is for, stated as use cases and as requirements that can be tested
- strong, eventual and tunable consistency, and why this design chooses the first
- the entities of the design, and an estimate of how many machines it needs
- an HTTP API, with conditional writes that prevent 1 client from overwriting another
- the protocol that storage nodes speak among themselves
- the components of the system and the path of a request through them
- dividing keys among machines with consistent hashing and virtual nodes
- how every part of the system learns where a key lives, and what happens when that knowledge is out of date
- keeping 3 copies of every key, with a leader and a majority
- what the system does when a machine, a disk or the network fails
- what the design guarantees, and what it gives up in return
Why such a store is needed
Consider a shop whose web site runs on 3 web servers behind a load balancer. A customer’s requests are distributed among the 3, so that the request which adds an item to the basket may be handled by 1 server and the request which displays the basket by another. The basket therefore cannot be kept in the memory of any 1 web server. It has to be held by some service that all 3 can reach.
This arrangement is typical, and it divides the system into parts of 2 very different characters. The web servers hold nothing that matters. Any of them can be restarted, replaced or duplicated at any moment without loss, and a component of this kind is called stateless. The shared store is the opposite. Everything of value is in it, a failure of it is a failure of every web server at once, and it cannot be restarted on a fresh machine, since a fresh machine would not have the data. The simplicity of the stateless part of a system is paid for by the difficulty of the part that holds the state, and this chapter is about that part.
What is demanded of the store follows directly from its position. Each demand has a consequence for the design, and each consequence is the subject of a later section.
| What is demanded | What the design must therefore do | Where it is treated |
|---|---|---|
| A crash in the middle of a write loses no data that was confirmed | write each change to disk before confirming it | the last section, and chapter 2 |
| The loss of a machine loses no data | keep each change on more than 1 machine | Replication |
| The data may exceed the disk of 1 machine | divide the keys among machines | Sharding |
| The failure of a machine does not stop the service | let another machine take over its work | Failure handling |
| Clients working at the same time do not undo each other’s changes | allow a write to state what it was based on | The API |
Use cases
The store is used by 3 kinds of party. An application is any service that keeps its data in the store, such as the web servers of the shop. An operator is whoever looks after the machines, whether a person or an automated system. And monitoring is the system that watches the health of every machine and raises an alarm when something is wrong.
| Actor | Use case | An example |
|---|---|---|
| Application | store a value | save the basket of customer 41 |
| Application | read a value | load the basket when the customer’s next request arrives |
| Application | delete a key | remove the basket once the order has been paid for |
| Application | write only if unchanged | add an item to the basket without discarding an item that another web server has just added |
| Application | list keys by prefix | find all the sessions that belong to user 7 |
| Operator | add a node | increase capacity before a sale |
| Operator | remove a node | take a machine out of service for maintenance |
| Operator | replace a failed node | a machine’s disk has failed |
| Monitoring | check health | stop sending requests to a node that is not well |
| Monitoring | read metrics | plot the number of keys on each node |
Functional requirements
The functional requirements state what the store does. Each is derived from a use case and is specific enough that a test could establish whether it is met.
| No. | Functional requirement |
|---|---|
| 0 | A client can set the value of a key. A key consists of 1 to 256 characters from A-Z, a-z, 0-9, ., _ and -. A value is 0 to 1048576 bytes of any content. |
| 1 | A client can read the current value of a key, together with its version. |
| 2 | A client can delete a key. |
| 3 | Every write gives the key a new version. A client can make a write conditional on the version it last read, or on the key’s not existing. |
| 4 | A client can list the keys that begin with a given prefix, in sorted order and in pages of limited size. |
| 5 | An operator can add, remove and replace nodes while the store continues to serve requests. |
| 6 | Every node reports whether it is able to serve requests. |
Non-functional requirements
The non-functional requirements state how well the store must do these things: how much data, how many requests, how quickly, and through which failures. It is these, far more than the functional requirements, that determine the architecture. A program that kept 1 file on 1 machine would satisfy functional requirements 0 to 6 completely, and would satisfy almost none of what follows.
| No. | Property | Non-functional requirement | Remark |
|---|---|---|---|
| 0 | Durability | An acknowledged write survives the loss of any 1 machine, including its disk. | A write is acknowledged when the client has received a successful response to it. |
| 1 | Availability | 99.95% of requests succeed, measured over a month. | A month of 30 days has 43200 minutes, of which 0.05% is 21.6 minutes. |
| 2 | Consistency | Strong: a read that begins after a write was acknowledged returns that write or a later one, whichever node answers. | Explained below, with its 2 alternatives. |
| 3 | Latency | 99% of writes complete within 20 ms and 99% of reads within 5 ms, for clients in the same data centre. | The value below which 99% of measurements fall is the 99th percentile, written p99. |
| 4 | Scale | 1 billion keys with values of 1 KB on average, 20000 writes per second and 100000 reads per second. | |
| 5 | Elasticity | Capacity can be increased by adding machines while the store is in service. |
Durability and availability are easily confused and should be kept apart. Durability concerns data that has already been written, and requires that none of it be lost. Availability concerns requests that are yet to come, and requires that they be answered. Data can be perfectly durable and entirely unavailable, as when every copy of a key is on a machine that has been switched off for an hour and will return intact. The design provides for the 2 properties by different mechanisms, and they sometimes pull in opposite directions.
Which kind of consistency
The word “consistency” has to be given an exact meaning before anything can be designed to provide it, since storage systems offer 3 quite different things under that name. They are best distinguished by a single situation: a value has just been written, 1 of the copies of the data has not yet received the change, and a reader happens to be answered by that copy.
Under strong consistency, the reader receives the new value. The system behaves, as far as any client can tell, as though there were only 1 copy of the data, and a write that has been acknowledged is visible to every subsequent read. The precise form of this property is called linearizability. Its cost is that reads and writes must involve a leader or a majority of the copies, which makes them slower and means that they fail when a majority cannot be reached.
Under eventual consistency, the reader may receive the old value. The system promises only that, once writes cease, all copies will in time come to hold the same value. Any copy can answer any request, which makes the system fast and keeps it available through almost any failure, and the burden passes to the applications, which have to cope with stale values and with conflicting ones.
Tunable consistency lets each request choose. With N copies, a write waits for W of them to confirm and a read consults R of them. Whenever R + W is greater than N, the copies consulted by any read must include at least 1 that took part in the latest write, and the read sees that write. With 3 copies, the choice of W = 2 and R = 2 behaves strongly, and W = 1 and R = 1 is fast and only eventually consistent. The flexibility is real, and so is its cost, which is that every caller must choose correctly for every request.
| Model | What a read returns | What it costs | Where it is used |
|---|---|---|---|
| Strong | the latest acknowledged write | slower requests, and no service on the smaller side of a network split | money, stock levels, locks, anything guarded by a conditional write |
| Eventual | possibly an older value, for a while | applications must tolerate stale and conflicting values | counters of likes, caches, activity feeds |
| Tunable | whatever the request asked for | every caller has to choose correctly | varied workloads on 1 store |
This design chooses strong consistency, and the reason lies in the fourth use case. “Add an item to the basket, unless somebody else has changed the basket in the meantime” works only if the version that a client reads is the latest. If reads could be stale, 2 web servers might each read version 1, each conclude that nobody had changed the basket, and each write, with 1 of the 2 items lost. This is precisely the lost update that conditional writes exist to prevent, and a conditional write over stale reads prevents nothing. What strong consistency costs is set out in the section on guarantees: during a split of the network, the smaller side stops answering.
Core entities
The core entities are the things that the system stores, together with the things it stores them on. Naming them at this stage, with their fields, provides a vocabulary that the API, the protocol and the code of later chapters all share.
| Entity | What it is | Identified by |
|---|---|---|
| Entry | the current value of a key, and its version | the key |
| Record | 1 change to a key, in the order in which the changes occurred | a sequence number |
| Node | a machine that runs the storage program | a node id |
| Shard | a range of keys, together with the 3 nodes that store it | the last position of its range |
| Ring | the assignment of ranges of keys to nodes | a version number |
The first 2 entities are 2 representations of the same data, and the distinction
between them runs through every chapter that follows. The records are a history: “set
user-1 to ada”, “set user-1 to ada lovelace”, “delete user-2”. The entries
are the state that results from applying that history in order. Both are kept, because
they serve different purposes. Records are what is written to disk and copied to other
nodes, since a sequence of changes is easy to append to, to transmit and to compare.
Entries are what reads are answered from, since nobody wishes to replay a history in
order to learn the present value. The version of an entry is the sequence number of
the last record that changed it, which fastens every entry to a definite point in the
history.
Capacity estimate
Arithmetic ahead. This estimate turns non-functional requirement 4 into a number of machines. The result is 24 storage nodes, each holding 138 GB, and nothing else from this section is needed later.
Show the calculation
The estimate depends on 1 quantity that has to be measured and cannot be assumed: the
time a disk takes to store a write durably. Chapter 2 measures it with a program
called synccost. On an NVMe disk, appending a record and waiting for the disk to
confirm took 5.4 ms, so that a node which waits once for each record stores 185
records per second. Readers can run synccost on their own hardware and repeat the
calculation with their own figure.
Storage. An entry consists of a key of about 32 bytes, a value of 1024 bytes and about 44 bytes of bookkeeping, 1100 bytes in all.
| Quantity | Calculation | Result |
|---|---|---|
| Size of 1 copy of the data | 1000000000 × 1100 bytes | 1.1 TB |
| Size of 3 copies, as non-functional requirement 0 demands | 1.1 TB × 3 | 3.3 TB |
Writes. Every write is stored on 3 nodes, so that 20000 writes per second become 60000 records per second across the cluster. At 185 records per second for each node, 325 nodes would be needed. The time the disk takes, however, is nearly the same for 1 record as for 20, and a node can therefore gather the records that arrive during 1 wait and store them together. Storing several records with a single wait is called group commit, and it is the subject of chapter 6. With 20 records for each wait on average, a node stores 185 × 20 = 3700 records per second.
| Quantity | Calculation | Result |
|---|---|---|
| Records to store per second | 20000 writes × 3 copies | 60000 |
| Records per node per second, with 1 wait for each record | 1 ÷ 0.0054 s | 185 |
| Records per node per second, with 20 records for each wait | 185 × 20 | 3700 |
| Nodes required | 60000 ÷ 3700 | 16.2, rounded up to 17 |
| Nodes chosen | 24 | |
| Capacity of 24 nodes | 24 × 3700 | 88800 records per second |
| Data on each node | 3.3 TB ÷ 24 | 138 GB |
We choose 24 nodes. At the required rate of writes, a cluster of that size works at 68% of its capacity, which leaves the surviving nodes room to carry the load of one that has failed.
Reads. Reads are answered from memory and do not wait for the disk. Spread over 24 nodes, 100000 reads per second come to about 4200 for each node, far below what a node can answer from memory. The rate of reads therefore has no influence on the number of nodes.
The API
Applications reach the store over HTTP. The API follows the style known as REST, in which the things a service holds are presented as resources, each identified by a URL, and the standard methods of HTTP are the operations upon them. Adhering to the standard meanings of the methods and status codes has a practical reward. Every existing HTTP client, proxy and cache already understands them, and so handles the API correctly without knowing anything about key-value stores.
The rules the API follows
| Rule | How it is applied here |
|---|---|
| A URL identifies a resource, and consists of nouns. | /v1/keys/user-1 identifies the key user-1. The API has no URLs such as /setKey. |
| A collection is named by a plural noun, and a member by the collection followed by its identifier. | /v1/keys is the collection of keys, and /v1/keys/user-1 is 1 member of it. |
| The HTTP method expresses the operation. | GET reads a resource, PUT replaces it and DELETE removes it. |
GET has no side effects. | Clients and intermediaries may repeat, cache and retry a GET freely. |
PUT and DELETE are idempotent. | An operation is idempotent if performing it several times has the same effect as performing it once, so that a client may repeat these requests after a lost response. |
| The status code reports the outcome. | 201 for a key that was created, 204 for success with nothing to return, 404 for a key that does not exist, 412 for a condition that did not hold. |
| Errors have a body that a program can read. | Error responses use the application/problem+json format defined in RFC 9457. |
| The version of the API is part of the path. | An incompatible future version can be served under /v2/ beside /v1/. |
| Collections are returned in pages. | The limit parameter bounds the size of a page, and each response says how to ask for the next. |
Reference
Each operation is specified below by its request and its possible responses.
store.example stands for the address of the load balancer.
Set the value of a key
| Request | PUT /v1/keys/{key} |
| Request headers | Content-Type: application/octet-stream. Optionally If-Match: "<version>" or If-None-Match: *. |
| Request body | the value: 0 to 1048576 bytes, stored exactly as sent |
| Success | 201 Created for a new key, 204 No Content for a value that was replaced |
| Response headers | ETag: "<version>", the version of the entry after the write |
| Response body | none |
| Errors | 400, 412, 413, 503 |
PUT /v1/keys/user-1 HTTP/1.1
Host: store.example
Content-Type: application/octet-stream
Content-Length: 3
ada
HTTP/1.1 201 Created
ETag: "1"
Content-Length: 0
Read the value of a key
| Request | GET /v1/keys/{key} |
| Request headers | optionally If-None-Match: "<version>" |
| Request body | none |
| Success | 200 OK, or 304 Not Modified when the entry is still at the version given in If-None-Match |
| Response headers | ETag: "<version>", Content-Type: application/octet-stream |
| Response body | the value, as it was stored, and empty for 304 |
| Errors | 400, 404, 503 |
GET /v1/keys/user-1 HTTP/1.1
Host: store.example
HTTP/1.1 200 OK
ETag: "1"
Content-Type: application/octet-stream
Content-Length: 3
ada
Delete a key
| Request | DELETE /v1/keys/{key} |
| Request headers | optionally If-Match: "<version>" |
| Request body | none |
| Success | 204 No Content |
| Response body | none |
| Errors | 400, 404, 412, 503 |
DELETE /v1/keys/user-2 HTTP/1.1
Host: store.example
If-Match: "3"
HTTP/1.1 204 No Content
List keys
| Request | GET /v1/keys |
| Query parameters | prefix: only keys that begin with this text, empty by default. after: only keys that sort after this key. limit: the largest number of keys to return, from 1 to 1000, 100 by default. |
| Request body | none |
| Success | 200 OK |
| Response headers | Content-Type: application/json |
| Response body | a JSON object with the fields below |
| Errors | 503 |
| Field of the response body | Type | Meaning |
|---|---|---|
keys | array | the keys of this page, in sorted order |
keys[].key | string | the key |
keys[].version | number | the version of the entry |
keys[].size | number | the length of the value in bytes |
next_after | string or null | the value to pass as after for the next page, and null on the last page |
GET /v1/keys?prefix=user-&limit=2 HTTP/1.1
Host: store.example
HTTP/1.1 200 OK
Content-Type: application/json
{
"keys": [
{ "key": "user-1", "version": 2, "size": 12 },
{ "key": "user-2", "version": 3, "size": 5 }
],
"next_after": "user-2"
}
Errors
Every error response has the content type application/problem+json and a body with
these fields.
| Field | Type | Meaning |
|---|---|---|
type | string | identifies the kind of error, and is the field a program should compare |
title | string | a description of the kind of error, the same on every occurrence |
status | number | the HTTP status code, repeated in the body |
detail | string, optional | a description of this particular occurrence |
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "/problems/condition-failed",
"title": "The condition on the key's version does not hold",
"status": 412,
"detail": "the key is at version 2"
}
Health of a node
| Request | GET /healthz |
| Success | 200 OK |
| Response body | a JSON object: status (a string), keys (the number of keys), last_sequence (the sequence number of the last record) |
HTTP/1.1 200 OK
Content-Type: application/json
{ "status": "ok", "keys": 2, "last_sequence": 5 }
The sections that follow take up the parts of this reference that embody a decision, and show each exchange as it was actually performed by the server of chapter 2.
Writing and reading a key
PUT stores the body of the request as the value of the key. Since values are opaque
bytes, the body is sent without any encoding. The first write of a key is answered
with 201 Created. All the responses in this section were produced by the server of
chapter 2, with its date header removed.
$ curl -i -X PUT http://127.0.0.1:7001/v1/keys/user-1 --data-binary 'ada'
HTTP/1.1 201 Created
etag: "1"
content-length: 0
The version of an entry is conveyed in the etag header. In HTTP, an entity tag, or
ETag, identifies 1 particular state of a resource, which is exactly what a version is.
GET returns the value together with its tag.
$ curl -i http://127.0.0.1:7001/v1/keys/user-1
HTTP/1.1 200 OK
content-type: application/octet-stream
etag: "1"
content-length: 3
ada
Conditional writes
Functional requirement 3 is directed at a problem called the lost update, which arises when 2 clients read a value, each modify it independently, and each write it back.
| Step | Web server A | Web server B | Value in the store |
|---|---|---|---|
| 1 | reads the basket: tea | tea | |
| 2 | reads the basket: tea | tea | |
| 3 | writes tea, milk | tea, milk | |
| 4 | writes tea, sugar | tea, sugar |
Both writes succeed, and milk is missing from the final value. Nothing failed, and
no error was reported to anyone. The write at step 4 was computed from what B read at
step 2, which by then was no longer current, and the store, having been told only to
set the value, set it.
The remedy is to let a write state which version it was based on. HTTP provides the
If-Match header for this purpose: the server performs the write only if the present
version of the resource is the version named in the header.
$ curl -i -X PUT http://127.0.0.1:7001/v1/keys/user-1 -H 'If-Match: "1"' --data-binary 'ada lovelace'
HTTP/1.1 204 No Content
etag: "2"
The key was at version 1, the condition held, and version 2 resulted. The same request sent a second time is refused, since version 1 is no longer current.
$ curl -i -X PUT http://127.0.0.1:7001/v1/keys/user-1 -H 'If-Match: "1"' --data-binary 'ada byron'
HTTP/1.1 412 Precondition Failed
content-type: application/problem+json
content-length: 144
{"type":"/problems/condition-failed","title":"The condition on the key's version does not hold","status":412,"detail":"the key is at version 2"}
The status 412 Precondition Failed informs web server B that its copy is out of
date. B reads again, obtains tea, milk at version 2, adds the sugar, and writes with
If-Match: "2". The procedure of reading, modifying and writing conditionally,
repeated until the write succeeds, is the general method by which clients of a store
cooperate without locks, and it recurs throughout the later chapters under the name of
compare-and-set.
The header If-None-Match: * expresses the complementary condition, that the write
is to be performed only if the key does not exist. With it, several clients may all
attempt to create the same key, and exactly 1 succeeds.
$ curl -i -X PUT http://127.0.0.1:7001/v1/keys/user-1 -H 'If-None-Match: *' --data-binary 'someone else'
HTTP/1.1 412 Precondition Failed
content-type: application/problem+json
content-length: 144
{"type":"/problems/condition-failed","title":"The condition on the key's version does not hold","status":412,"detail":"the key is at version 2"}
On a GET, If-None-Match with a version asks for the value only if it has changed
since that version. If it has not, the server answers 304 Not Modified with no body,
which spares the transfer of data that the client already holds.
$ curl -i http://127.0.0.1:7001/v1/keys/user-1 -H 'If-None-Match: "2"'
HTTP/1.1 304 Not Modified
etag: "2"
Listing keys
GET /v1/keys returns the keys that begin with prefix, in sorted order, and no more
than limit of them. When more remain, the response carries next_after, and the
client obtains the next page by passing that value as after.
$ curl -i 'http://127.0.0.1:7001/v1/keys?prefix=user-&limit=1'
HTTP/1.1 200 OK
content-type: application/json
content-length: 71
{"keys":[{"key":"user-1","version":2,"size":12}],"next_after":"user-1"}
$ curl -i 'http://127.0.0.1:7001/v1/keys?prefix=user-&limit=1&after=user-1'
HTTP/1.1 200 OK
content-type: application/json
content-length: 66
{"keys":[{"key":"user-2","version":3,"size":5}],"next_after":null}
The position in the listing is thus expressed as the last key of the previous page.
A page number would not serve, for a reason that applies to every listing of data that may
change. If a key is inserted ahead of the reader’s position between 2 requests, every
later key moves down by 1 place, and a request for “page 2” returns a key that the
reader has already received. A request for the keys after user-1 is unaffected by
insertions and deletions elsewhere. Chapter 15 meets the same problem in a news feed
and solves it in the same way.
Deleting a key
$ curl -i -X DELETE http://127.0.0.1:7001/v1/keys/user-2
HTTP/1.1 204 No Content
Errors
A failed request is answered with a status code in the 400s or the 500s and a body in
the format of RFC 9457. The type field identifies the category of the error and is
what a client program should test. The title is a fixed description of the category,
and detail, when present, describes the particular occurrence.
$ curl -i http://127.0.0.1:7001/v1/keys/user-2
HTTP/1.1 404 Not Found
content-type: application/problem+json
content-length: 80
{"type":"/problems/key-not-found","title":"The key does not exist","status":404}
| Status | type | Condition |
|---|---|---|
400 Bad Request | /problems/invalid-key | The key is empty, exceeds 256 characters, or contains a character outside the permitted set. |
400 Bad Request | /problems/invalid-header | The If-Match or If-None-Match header is malformed. |
404 Not Found | /problems/key-not-found | A GET or DELETE names a key that does not exist. |
405 Method Not Allowed | The resource does not support the method. The allow header lists those it does. | |
412 Precondition Failed | /problems/condition-failed | The condition in If-Match or If-None-Match does not hold. |
413 Payload Too Large | The value exceeds 1048576 bytes. | |
503 Service Unavailable | /problems/store-unavailable | The node is unable to store the write. The client should try again. |
The 2 ranges of status codes call for different behaviour on the part of the client. A code in the 400s signifies a fault in the request, and sending the same request again will produce the same error. A code in the 500s signifies that the server was unable to carry out a valid request, which may well succeed if repeated.
Retries
Suppose that a client sends a request and loses its connection before any response arrives. It has no means of knowing whether the operation was performed. This situation, the lost response, is the most characteristic difficulty of distributed systems and reappears in nearly every chapter of this book. The API meets it by making every operation idempotent, so that repeating a request is safe whichever was the case.
| Request | Effect of repeating it |
|---|---|
GET | None, since GET does not change the store. |
PUT without a condition | The key is set to the same value again. The value is unchanged and the version increases. |
PUT with If-Match | If the first attempt succeeded, the repetition returns 412, and a GET then shows that the key holds the client’s value. |
DELETE | If the first attempt succeeded, the repetition returns 404, which confirms that the key is absent. |
The protocol between nodes
The API described so far serves applications. The storage nodes also communicate with one another. A node sends the records it has written to the nodes that hold the other copies, and every node obtains the current ring from the coordinator. This internal traffic has different needs from a public API and uses a protocol of its own.
| Public API | Internal protocol | |
|---|---|---|
| Who calls it | programs in any language, and people with tools such as curl | only the store’s own programs |
| What matters most | readability, compatibility with HTTP infrastructure, stability over years | compact encoding, low cost of processing, strict types |
| Format | HTTP with JSON and raw bytes | Protocol Buffers over gRPC |
Protocol Buffers is a format in which the types of messages are declared in a schema file and encoded in a compact binary form. gRPC is a protocol for remote procedure calls that carries such messages over HTTP/2. The schema file is the specification of the internal protocol, and it is written before any implementation.
The Record message corresponds to the Record entity.
Listing 1.1 The Record message. proto/store.proto
// A change to one key, as the leader of a shard wrote it to its log.
message Record {
uint64 sequence = 1;
oneof change {
Put put = 2;
Delete delete = 3;
}
}
message Put {
string key = 1;
bytes value = 2;
}
message Delete {
string key = 1;
}
Every field of a message has a number, and it is by the number that the binary encoding identifies the field. Names occur only in the schema. This arrangement is what allows nodes running different versions of the software to communicate, which they must do whenever a cluster is upgraded 1 node after another without stopping. A newer version may add a field under a number not previously used. An older node disregards numbers it does not recognise, and a newer node supplies a default value where a field is missing. The single rule that makes this work is that a field number, once used, keeps its meaning for ever.
A leader sends records to its followers by means of the Append call. The terms
leader, follower and term are defined in the section on replication.
Listing 1.2 The replication service. proto/store.proto
// Sent by the leader of a shard to each follower.
service Replication {
// Appends records to the follower's log. A call with no records is a
// heartbeat: it tells the follower that the leader is alive.
rpc Append(AppendRequest) returns (AppendResponse);
}
message AppendRequest {
// The shard, named by the last position it owns.
fixed64 shard = 1;
// The term of the sender. A follower rejects a leader of an older term.
uint64 term = 2;
// The sequence of the record that precedes `records` in the leader's log.
uint64 previous_sequence = 3;
repeated Record records = 4;
// Every record up to this sequence is on a majority of the replicas.
uint64 committed_sequence = 5;
// Added for leader election. The term of the record at previous_sequence,
// and the id of the sender.
uint64 previous_term = 6;
uint32 leader = 7;
}
message AppendResponse {
uint64 term = 1;
// False when the follower's log does not end at `previous_sequence`.
bool appended = 2;
// The sequence of the last record in the follower's log.
uint64 last_sequence = 3;
}
The field previous_sequence is what keeps a follower’s log identical to its
leader’s. A follower appends the records it is sent only if its own log at present
ends at previous_sequence. If it does not, the follower answers appended = false
together with the actual last sequence number of its log, and the leader sends again
from that point. No follower’s log can therefore contain a gap or hold records in a
different order from the leader’s.
The coordinator stores the ring as a Ring message.
Listing 1.3 The ring, as the coordinator stores it. proto/store.proto
// The ring, as the coordinator stores it. Routers and nodes read it and watch it
// for changes.
message Ring {
uint64 version = 1;
repeated Node nodes = 2;
repeated Shard shards = 3;
}
message Node {
string id = 1;
string address = 2;
// The positions this node owns on the ring.
repeated fixed64 tokens = 3;
}
// A range of the ring and the nodes that hold its keys.
message Shard {
// The shard owns the positions from first_token to last_token, both included.
fixed64 first_token = 1;
fixed64 last_token = 2;
uint64 term = 3;
string leader = 4;
repeated string followers = 5;
}
Routers and storage nodes read the ring when they start and thereafter watch it. A watch is a request that the coordinator holds open and answers whenever the stored value changes, so that every component learns of a new version of the ring within milliseconds and none of them has to ask repeatedly.
High-level design
The high-level design names the components of the system, states the role of each, and traces the path of a request through them. Figure 1.7 shows the whole. The numbers in the figure are the steps of a write, and they appear again in its legend and in the text below.
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Load balancer | accepts connections from clients and passes each to a healthy router | none | a standby takes over the service address |
| Router | finds the node responsible for a key and forwards the request to it | a copy of the ring | the load balancer stops using it |
| Storage node | stores the replicas of the shards assigned to it | a log on disk, and the entries in memory | the shards it led elect new leaders |
| Coordinator | stores the ring and the present leader of every shard | the ring | the remaining 2 of its 3 machines continue to serve |
| Monitoring | reads the health and the metrics of every node | time series | requests are unaffected, and alerts are unavailable |
The coordinator is a small replicated store for configuration. It runs on 3 machines, keeps a complete copy of its data on each, and remains available as long as a majority of them is running. It may seem odd that a key-value store should depend on a second, smaller store, and the reason is a circularity that cannot otherwise be escaped. The ring is what tells every component where a key is to be found, and the store cannot keep the ring within itself, since the ring would be needed in order to find it. The ring is therefore kept in a separate system at a fixed address that every component knows. Chapter 4 shows how a group of 3 machines agrees on a value, which is what the coordinator does.
A write to the key user-1 passes through the following steps.
- Before any request arrives, every router holds a copy of the ring, and the coordinator informs the routers of every change to it.
- An application sends
PUT /v1/keys/user-1to the load balancer. - The load balancer forwards the request to a router that passed its most recent health check.
- The router hashes
user-1, looks up the resulting position in its copy of the ring, finds that the leader of the shard isnode-10, and forwards the request there. node-10appends a record to its log and waits for its disk to confirm.node-10sends the record to both followers,node-1andnode-6.- As soon as 1 follower has confirmed,
node-10applies the record and answers201 Created. - The response returns to the application by way of the router and the load balancer.
A read follows steps 1 to 3 and is then answered by node-10 from memory, with steps
4 to 6 omitted.
2 features of this arrangement deserve to be drawn out, since they are deliberate.
The routers are stateless. A component without state can be run in any number of instances, added and removed at will, and replaced after a failure without any procedure of recovery. The design confines all lasting state to the storage nodes and the coordinator, so that those are the only components which require replication and the careful treatment of failure that occupies the rest of this chapter.
And, as step 0 indicates, the coordinator takes no part in the handling of requests. If it becomes unreachable, reads and writes continue undisturbed on the copies of the ring that the routers already hold. Only changes to the ring are suspended: a change of leader cannot be recorded and a node cannot be added until the coordinator returns. A component that every request had to consult would be both a bottleneck and a single point of failure, and keeping the coordinator off the path of requests avoids both.
Sharding
Sharding is the division of the data into parts that are stored on different nodes, each part being a shard. With sharding, the total volume of data can exceed the capacity of any 1 node, and the load of requests is distributed among all of them. The central question is by what function a key is assigned to a node, and the obvious answer turns out to have a serious flaw.
Hashing modulo the number of nodes
The obvious method hashes the key and takes the remainder on division by the number
of nodes, node = hash(key) mod N. Provided that the hash function distributes its
output evenly, every node receives an equal share of the keys, and any component that
knows N can locate any key by a single calculation.
The flaw appears when N changes. A key stays where it is only if its hash leaves the same remainder under the old number of nodes and the new. The following table shows, for a few hash values, what happens when N increases from 4 to 5.
hash(key) | mod 4 | mod 5 | Does the key move |
|---|---|---|---|
| 0 | 0 | 0 | no |
| 1 | 1 | 1 | no |
| 4 | 0 | 4 | yes |
| 5 | 1 | 0 | yes |
| 9 | 1 | 4 | yes |
| 10 | 2 | 0 | yes |
Of any 20 consecutive hash values, only 4 leave the same remainder modulo 4 and modulo 5. The addition of a fifth node therefore relocates 80% of all keys, although the new node has need of only 20% of them, and the other 60% merely change places among the existing nodes to nobody’s advantage. During such a relocation the cluster expends a large part of its capacity on copying data, at exactly the time when a node was added because capacity was short.
Consistent hashing
Consistent hashing is a method of assignment under which a change in the set of nodes relocates only the keys that have to move. The range of the hash function is regarded as a circle, called the ring, on which the largest value is followed by the smallest. Every node is given a position on the ring, called its token. A key is hashed to a position by the same function, and it belongs to the node whose token is the first at or after that position, proceeding clockwise.
In figure 1.8 the ring has only 100 positions, to keep the arithmetic easy. Node B has the token 37, and the token before it is 12, so that B owns the positions 13 to 37. A key that hashes to position 20 belongs to B. A key that hashes to 90 lies beyond the last token, passes the end of the range, continues from 0, and belongs to A at position 12.
The gain of the method is seen when a node is added.
Node E is given the token 50, which lies between the token of B and that of C. E takes over the positions 38 to 50, which formerly belonged to C, and every other position keeps its owner. The addition has relocated 13% of the positions, and every relocated key has gone to E. The removal of a node has the reverse effect: its positions pass to its clockwise neighbour, and no other key is touched.
hash mod N | Consistent hashing | |
|---|---|---|
| Keys relocated when a node is added to N nodes | about N ÷ (N + 1) of all keys | about 1 ÷ (N + 1) of all keys |
| Where the relocated keys go | to every node | to the new node only |
| What is needed in order to locate a key | the number of nodes | the tokens of all the nodes |
The last row is the price. Under the first method a component needs to know 1 number, and under the second it needs the whole ring. How every component comes to have the ring, and what happens when its copy is out of date, is taken up below.
Virtual nodes
With a single token for each node, the size of a node’s range depends on where its own token and its predecessor’s happen to fall, and the ranges can differ enormously. Furthermore, when a node is removed its entire range passes to 1 neighbour, which then carries a double load.
Both defects are removed by giving every node many tokens. The tokens of a node are called its virtual nodes. Each node then owns many small ranges scattered around the ring. Its total share is the sum of many random range sizes, which varies far less than any single one of them, and the ranges of a removed node pass to many different neighbours.
The program ringsim measures the effect. It places 100000 keys on a ring of 24
nodes, with 1, 8, 64 and 256 tokens for each node, and reports the smallest and the
largest share of keys held by any node. It then adds a node and counts the keys whose
owner has changed.
$ cargo run --release -p ringsim
100000 keys on 24 nodes, ideal share 4.17%
1 tokens per node: smallest share 0.16%, largest share 11.26%
8 tokens per node: smallest share 1.42%, largest share 7.83%
64 tokens per node: smallest share 3.35%, largest share 5.07%
256 tokens per node: smallest share 3.86%, largest share 4.73%
adding node 25 to a ring with 256 tokens per node, ideal movement 4.00%
on the ring: 4332 keys changed owner (4.33%), 4332 of them to the new node
hash mod N : 96001 keys changed owner (96.00%)
replicas of user-1: node-10, node-1, node-6
position of user-1: 0x7c7615c11438e5c9
| Output | What it shows |
|---|---|
| 1 token: shares from 0.16% to 11.26% | With 1 token for each node, the most heavily loaded node holds 70 times as many keys as the least. |
| 256 tokens: shares from 3.86% to 4.73% | With 256 tokens, every node is within 14% of the ideal share of 4.17%. |
| 4332 keys changed owner, all to the new node | Adding a 25th node relocated 4.33% of the keys, close to the ideal of 4.00%, and no key moved between existing nodes. |
hash mod N: 96001 keys changed owner | The same change relocates 96% of the keys when the node is chosen by remainder. |
We use 256 tokens for each node. With 24 nodes, the ring contains 6144 tokens, and each range between adjacent tokens holds about 180 MB on average. A larger number of tokens would improve the balance further, at the cost of a larger ring to store and to distribute.
The ring uses a 64-bit hash function, xxHash3, for tokens and keys alike. The token of
a virtual node is the hash of the name of its node followed by a number, as in
node-7#12. Since the tokens are derived from the names, every router and node that
knows the names of all the nodes computes an identical ring, and no table of tokens
has to be transmitted.
The tokens are kept in a sorted map. To find the owner of a key, a lookup takes the tokens from the key’s position to the end of the map, followed by those from its beginning, which is clockwise order on the ring. The cost is logarithmic in the number of tokens.
Shards on the ring
Each range between adjacent tokens is a shard. The replicas of a shard are placed on
the first 3 distinct nodes encountered going clockwise from its range. The node that
owns the range is the preferred leader of the shard, and the other 2 are its
followers. For the key user-1, ringsim reports node-10, node-1 and node-6,
and these 3 nodes serve as the example in every chapter that follows.
The word “distinct” matters. Consecutive tokens on the ring may belong to the same physical node, since each node has 256 of them, and 2 replicas placed on 1 machine would give no protection against the loss of that machine. Tokens of a node already chosen are therefore passed over.
The ring in a running system
The ring has so far been described as a data structure. It remains to describe how the components of a running cluster obtain it, use it and change it, and it is here that most of the practical difficulty lies.
Where the ring is kept. There is a single authoritative copy, the Ring message
held by the coordinator. It contains the name, address and tokens of every node, and
the present leader of every shard. Every change increases its version number by 1.
Routers and storage nodes hold a copy in memory and keep it current by means of a
watch.
How a request is routed. Routing requires no communication with the coordinator or with other routers. A router hashes the key and performs a binary search in its sorted list of tokens, which with 6144 tokens takes 13 comparisons.
What happens when a ring is out of date. A router may forward a request an instant
before a new version of the ring reaches it, and the request then arrives at a node
that is no longer responsible for the key. If that node were to answer, it might
return a stale value or accept a write that belongs elsewhere, and strong consistency
would be lost through nothing more than a delayed notification. Routers therefore
attach the version of their ring to every forwarded request, in a Ring-Version
header. A node that holds a newer ring and is not responsible for the key refuses the
request with the status 421 Misdirected Request, which HTTP defines for a request
sent to a server that cannot answer for the resource. On receiving it, the router
waits for the newer ring and forwards again, and the client observes nothing but a
slightly slower response. The principle, that a component acting on possibly stale
information should declare which version it is acting on so that the recipient can
check, reappears in this book wherever stale knowledge is dangerous.
How a node joins. A new node cannot assume ownership of its ranges at once, since it does not yet hold their data. Ownership is therefore transferred range by range, each after its data has been copied.
How a node leaves. When an operator removes a node, the procedure runs in reverse. The departing node copies each of its ranges to the node that will own it, and the coordinator reassigns a range when its copy is complete. When a node fails without warning, there is no opportunity to copy. Its ranges are still held by the other 2 replicas of each shard, the shards it led elect new leaders, and a replacement node is supplied with the data by the surviving replicas.
Gossip as an alternative. Some systems distribute the ring without any coordinator. Each node holds a copy, and the nodes exchange their copies in pairs, each repeatedly choosing another at random and comparing versions, so that a change spreads through the cluster as a rumour does through a crowd.
A gossip protocol removes the dependence on a coordinator and scales to very large clusters, since no node has to communicate with more than a few others. Its disadvantage is that for some seconds after a change, different nodes hold different versions of the ring and may direct the same key to different nodes. Designs founded on gossip have to tolerate such disagreement, and accordingly tend to be those that offer eventual consistency. We use a coordinator, since a single authoritative ring is far easier to reason about when strong consistency is the aim, and a cluster of 24 nodes has no need of the scalability of gossip.
The cost of sharding
An operation on a single key involves a single shard. An operation on many keys may involve many. Listing keys by prefix, functional requirement 4, is of the second kind, because hashing deliberately destroys the order of keys: keys with a common prefix hash to unrelated positions and are scattered over all the nodes. A router has to send such a request to every node and merge their sorted results, so that a listing takes time proportional to the number of nodes.
This is the general price of hashing, and systems whose work consists largely of range queries choose differently. They divide the key space into contiguous ranges of keys, without hashing, so that neighbouring keys remain on the same node and a range query touches few nodes. In exchange they must actively move the boundaries of the ranges in order to keep the load even, since nothing in an unhashed key space guarantees that keys are evenly spread.
Replication
Replication is the keeping of copies of the same data on several nodes, each copy being a replica. Non-functional requirement 0 lays down that an acknowledged write survives the loss of any 1 machine, from which it follows that a write must be stored on at least 2 machines before it is acknowledged.
Replication with a leader
If every replica accepted writes independently, 2 clients might write different values for the same key to 2 replicas at the same moment, and the replicas would then hold conflicting data, to be reconciled afterwards by some rule that is bound to discard one client’s work. This design avoids such conflicts by appointing 1 replica of every shard as its leader. All writes to the shard go to the leader, which assigns each the next sequence number, so that the writes have a single agreed order, and sends the resulting record to the other replicas, the followers. A follower applies the records in the order of their sequence numbers and thereby arrives at the leader’s state.
Acknowledgement by a majority
The leader acknowledges a write as soon as its record is stored in 2 of the 3 logs, its own and that of 1 follower. It does not wait for the second follower.
The rule has 2 consequences, both of them wanted. The record is on 2 machines at the moment the client receives its acknowledgement, as non-functional requirement 0 demands. And the response time of a write is governed by the faster of the 2 followers, so that a follower which is slow, restarting or unreachable delays nothing, which is what keeps the p99 latency within the bound of non-functional requirement 3.
Any set containing more than half of the replicas is called a majority. With 3 replicas, a majority consists of 2. The whole importance of majorities derives from a single property: any 2 majorities of the same shard have at least 1 node in common.
To see what this property accomplishes, consider what happens when the leader of a shard fails. Its followers elect a new leader, and a candidate requires the votes of a majority. The majority that stored record 42 and the majority that elects the new leader share at least 1 node, and that node holds record 42. By the rule of the election, a node votes only for a candidate whose log is at least as complete as its own. The node holding record 42 therefore refuses its vote to any candidate that lacks it, and such a candidate cannot assemble a majority. Every newly elected leader consequently holds every acknowledged record, and no acknowledged write is lost in a change of leader. Chapter 4 implements this argument and tests it.
| Replicas | Majority | Failures tolerated | Storage required |
|---|---|---|---|
| 1 | 1 | 0 | 1× |
| 2 | 2 | 0 | 2× |
| 3 | 2 | 1 | 3× |
| 4 | 3 | 1 | 4× |
| 5 | 3 | 2 | 5× |
The table shows that an even number of replicas tolerates no more failures than the odd number below it, while costing more. We therefore use 3 replicas. A deployment that must survive 2 simultaneous failures uses 5.
Asynchronous replication
In asynchronous replication, the leader acknowledges a write as soon as its own log holds the record and sends it to the followers afterwards. Writes are faster, since they wait for 1 disk and for no network round trip. But if the leader’s machine is lost immediately after an acknowledgement, no other machine has the record, and a write that was acknowledged is gone. Asynchronous replication is therefore incompatible with non-functional requirement 0, and this design uses acknowledgement by a majority. Later chapters adopt the asynchronous form where its risk is acceptable, as in the cache of chapter 9, and reject it where it is not, as in the lock service of chapter 10.
Failure handling
A distributed system is defined as much by what it does when its parts fail as by what it does when they work. This section takes each kind of failure in turn and states how it is detected, what clients observe, and how the system recovers.
Failure of a leader
A leader sends an Append call to each of its followers every 100 ms. When there are
no new records, the call is empty and is known as a heartbeat, a periodic message
whose arrival shows that its sender is running. A follower that receives no Append
for 1 second concludes that its leader has failed.
Every election opens a new term, identified by a number that increases by 1. Terms
are how nodes tell the present leader from a former one. In figure 1.16,
node-10 still regards itself as the leader of term 4 when it returns. The first
message it receives carries term 5. Since that is higher than its own, node-10 gives
up its leadership and continues as a follower. Every Append call carries the term of
its sender so that the receiver can make this comparison, and a returning leader that
has been superseded is thereby prevented from doing any harm.
Network partition
A network partition is a failure of the network that divides the nodes into groups which cannot communicate with each other, while every node continues to run. Seen from inside one group, a partition is indistinguishable from the failure of all the nodes in the other, and no node can discover which has occurred.
In figure 1.17, node-10 is isolated and still regards itself as the leader.
It accepts a write and appends a record to its own log, but it cannot send the record
to a follower. Since no majority holds the record, node-10 does not acknowledge, and
its client receives an error. Meanwhile 2 of the 3 replicas are in the other group,
which elects a new leader and continues to process writes.
The rule of the majority thus resolves, without any further mechanism, the danger that a partition presents, namely 2 leaders each accepting writes that the other knows nothing of. At most 1 group can contain a majority of a shard’s replicas, so at most 1 leader can acknowledge writes, and no node is required to determine why it has lost contact with the others.
Summary of failures
| Failure | How it is detected | What clients observe | Recovery |
|---|---|---|---|
| The process of a storage node stops | Append calls to or from the node go unanswered | nothing for the shards it follows, and an election for the shards it leads | The process restarts, rebuilds its state from its log, and receives the records it missed. |
| A leader fails | its followers receive no heartbeat for 1 second | writes to the shard fail for a few seconds, and other shards are unaffected | The followers elect a new leader. |
| A follower fails | the leader’s Append calls go unanswered | nothing, since 2 of 3 replicas remain | The follower returns and receives the missing records from the leader. |
| A disk is lost | the node cannot start, or cannot write | the same as for a stopped node | The operator replaces the node, and the leaders copy the affected shards to it. |
| The network is partitioned | each group ceases to receive messages from the other | requests to the majority group succeed, and requests to the minority group fail | When the partition ends, the nodes of the minority group continue as followers. |
| A router fails | the load balancer’s health check fails | open connections to it are broken, and retried requests reach another router | A new router is started and obtains the ring. |
| The coordinator is unreachable | routers and nodes cannot connect to it | nothing, unless a leader fails during the outage | The coordinator runs on 3 machines and needs 2 of them. |
| 2 of the 3 replicas of a shard are lost | no majority can be formed | the shard is unavailable | A replica is restored from the surviving node. |
The last row marks the limit of the design. With 3 replicas, the store tolerates the loss of any 1 machine and remains consistent under any partition of the network. A shard that has lost a majority of its replicas is unavailable, and has to be so, since the single surviving replica cannot know whether it holds every acknowledged write.
Guarantees and trade-offs
During a network partition, a distributed store cannot offer both consistency and availability. It must either refuse some requests in order to keep all its answers consistent, or accept all requests and allow the 2 groups to diverge. This result is known as the CAP theorem, and it is less a theorem to be proved than an observation that follows directly from the previous section: a node that cannot reach the others must choose between answering from what it has, which may be out of date, and declining to answer. This design gives priority to consistency.
| Property | Provided | By what means, or why not |
|---|---|---|
| An acknowledged write survives the loss of 1 machine (non-functional requirement 0) | yes | The record is in 2 of 3 logs before it is acknowledged. |
| A read returns the latest acknowledged write (non-functional requirement 2) | yes | Reads are served by the leader, and a leader that cannot reach a majority stops serving. |
| Requests succeed while 1 machine is down (non-functional requirement 1) | yes, after an election lasting a few seconds | A new leader is elected by a majority. |
| Requests succeed in the minority group of a partition | no | The minority group cannot store a record on a majority. |
| A write completes without a network round trip | no | Acknowledgement by a majority is required for non-functional requirement 0. |
| Listing by prefix costs the same as a read | no | Hashing distributes adjacent keys over all the nodes. |
The availability allowed by non-functional requirement 1 can be related to elections by a short calculation. An allowance of 21.6 minutes a month is 1296 seconds. If an election takes 5 seconds and the failed node led 1/24 of all the shards, the election causes 1/24 of all writes to fail for 5 seconds and consumes about 0.2 seconds of the allowance. Several thousand such failures a month would be within it. In practice, therefore, the requirement is not threatened by the failures of individual machines, which this design absorbs, and is limited by events that affect many nodes together, such as a faulty release of the software or the loss of a segment of the network.
The design, deployed
The figure of the high-level design showed 1 box for each kind of component. A running system has many of each, and their number and placement are part of the design. Applications reach the store through a pair of load balancers, of which 1 is active and the other takes over its address if it fails. Behind them are 12 routers, which can be any number, since they are stateless. The 24 storage nodes are divided equally among 3 availability zones, which are separate buildings or halls with their own power and network, and the ring is constructed so that the 3 replicas of every shard fall in 3 different zones. The loss of an entire zone then removes 1 replica of each shard and leaves a majority of every one. The coordinator has 1 machine in each zone for the same reason.
How real systems do it
The design of this chapter is not the only way of building a key-value store, and it is instructive to place it among the systems that are in use.
Its 2 ancestors are quite different from each other. Dynamo, described by Amazon in 2007, introduced to a wide audience the combination of consistent hashing, virtual nodes and replication to the next nodes on the ring that this chapter uses for placement. In every other respect Dynamo chose the opposite course. It has no leaders. Any replica accepts a write, a request succeeds when W of N replicas have answered, conflicting versions are detected with vector clocks and handed back to the application to be reconciled, and membership spreads by gossip. It is the model of eventual and tunable consistency described earlier, and Cassandra and Riak descend from it. The other ancestor is the line of consensus protocols, Paxos and later Raft, in which a leader orders the writes and a majority must store each one. The coordinator of this chapter is a system of that kind, as etcd and ZooKeeper are.
The design here combines the 2: placement by a hash ring, as in Dynamo, with a consensus group for each shard, as in Raft. Systems that are built this way in practice, among them CockroachDB, TiKV and YugabyteDB, differ from our design in 1 important respect. They do not hash keys. They divide the ordered key space into contiguous ranges, each replicated by its own Raft group, and they split a range when it grows beyond a set size and merge ranges that shrink. This keeps neighbouring keys together, so that a listing by prefix reads 1 or 2 ranges where ours must ask every node, and it lets the system move load by moving the boundaries. The price is the machinery for splitting, merging and rebalancing, which a hash ring does not need. Google’s Bigtable and Spanner also partition by range.
Several things that a production store requires are absent from this chapter and from the code that follows. A log that grows for ever must be cut short by taking snapshots of the state. Changing the set of members of a shard while it runs needs a protocol of its own, which Raft calls joint consensus. Reads served by a leader on the strength of a recent heartbeat depend on clocks behaving reasonably, and stricter systems confirm leadership with a majority for each read. And none of this has been tested here in the way that such systems are tested in earnest, by a tool such as Jepsen, which partitions a real cluster at random and checks every response against the definition of linearizability.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
Assigning keys by hash mod N | Adding 1 node moves nearly every key. | Use consistent hashing, so that only the new node’s share moves. |
| 1 token for each node | Shares differ by a factor of 70, and a removed node doubles 1 neighbour’s load. | Give each node many tokens. |
| Placing a shard’s replicas on the next 3 tokens | 2 replicas can land on the same machine. | Take the next 3 distinct nodes. |
| Answering a request with an outdated ring | A node that no longer owns a key serves stale data or accepts a misplaced write. | Send the ring’s version with each request, and refuse with 421 when it is behind. |
| Acknowledging before a second copy exists | The loss of the leader loses an acknowledged write. | Acknowledge only when a majority holds the record. |
| Waiting for every replica | 1 slow or failed follower delays or blocks every write. | Wait for a majority only. |
| An even number of replicas | More storage, and no additional failure tolerated. | Use 3 or 5. |
| Letting any node with data answer reads | A follower that is behind returns an old value. | Serve reads from the leader, which must be able to reach a majority. |
| Putting the coordinator on the path of requests | Every request depends on it, and its outage stops the store. | Let routers hold copies of the ring, and consult the coordinator for changes only. |
| Writing without a condition after reading | 2 clients overwrite each other’s changes. | Send If-Match with the version that was read. |
| Paging by page number | Insertions shift the pages, and keys are repeated or skipped. | Page by the last key received. |
| Reusing a field number in the protocol | Old and new nodes interpret the same bytes differently. | Keep the meaning of every number for ever. |
A first look at the low-level design
Every guarantee stated above rests on an assumption that has so far been taken for granted: that a record which a node has reported as stored is still in that node’s log after the node has crashed and restarted. If a node could lose such a record, a record counted as stored on 2 of 3 nodes might in truth be stored on 1, and the whole argument from majorities would be without foundation. A storage node justifies the assumption by observing 2 rules.
- Every change is appended to a log on disk, and the node waits for the disk to confirm, before the change is applied in memory and before any response is sent.
- On starting, a node rebuilds its state in memory by reading its log from beginning to end.
Chapter 2 implements a storage node on these 2 rules: a format for records on disk, a log with its procedure of recovery, and an HTTP server that provides the API of this chapter. It includes a simulation that cuts a log short at every possible byte and verifies that recovery returns exactly the records that were complete. The entities of this chapter appear in its code under the same names.
The simulation of the ring
Simulation in the repository. The program code/ringsim simulates this design. To run it, clone the repository and type cargo run --release -p ringsim in the code directory.
References
- G. DeCandia and others, “Dynamo: Amazon’s Highly Available Key-value Store”, SOSP 2007.
- D. Ongaro and J. Ousterhout, “In Search of an Understandable Consensus Algorithm”, USENIX ATC 2014.
- D. Karger and others, “Consistent Hashing and Random Trees”, STOC 1997.
- F. Chang and others, “Bigtable: A Distributed Storage System for Structured Data”, OSDI 2006.
- J. Corbett and others, “Spanner: Google’s Globally-Distributed Database”, OSDI 2012.
- M. Herlihy and J. Wing, “Linearizability: A Correctness Condition for Concurrent Objects”, ACM TOPLAS, 1990.
- S. Gilbert and N. Lynch, “Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services”, ACM SIGACT News, 2002.
- M. Kleppmann, Designing Data-Intensive Applications, O’Reilly, 2017.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Key-value store | a database that stores a value under a key and returns it on request |
| Stateless | holding no data whose loss would matter |
| Acknowledged | confirmed to the client by a successful response |
| Durability | the property that acknowledged data is not lost |
| Availability | the property that requests are answered |
| Strong consistency, linearizability | every read returns the latest acknowledged write |
| Eventual consistency | all copies come to agree once writes cease |
| Tunable consistency | each request chooses how many copies must take part |
| Entry | the current value of a key, with its version |
| Record | 1 change to a key, with a sequence number |
| Idempotent | having the same effect whether performed once or several times |
| Lost update | a change overwritten by a write that was based on an older value |
| Conditional write, compare-and-set | a write performed only if the version is the one expected |
| Sharding | dividing the data among nodes |
| Shard | 1 part of the data, here a range of the ring |
| Consistent hashing | assigning keys to nodes so that a change of nodes moves few keys |
| Ring | the hash range regarded as a circle, with the nodes’ tokens on it |
| Token, virtual node | a position on the ring that belongs to a node |
| Coordinator | a small replicated store that holds the ring |
| Watch | a request that is answered when a stored value changes |
| Gossip protocol | spreading information by repeated exchanges between pairs of nodes |
| Replication | keeping copies of the data on several nodes |
| Leader, follower | the replica that orders writes, and a replica that copies it |
| Majority | more than half of the replicas |
| Heartbeat | a periodic message showing that its sender is running |
| Term | the number of an election, by which a current leader is told from a former one |
| Network partition | a failure that divides the nodes into groups that cannot communicate |
| CAP theorem | during a partition, a store cannot be both consistent and available |
Summary
- The non-functional requirements determine the architecture: durability under the loss of a machine demands replication, the volume of data demands sharding, and the availability target limits how long an election may take.
- Strong consistency is chosen because conditional writes are meaningless over stale reads, and its price is that the minority side of a partition stops answering.
- The capacity estimate starts from a measured disk latency of 5.4 ms, which gives 185 records per second for a node that waits for each record and 3700 with group commit, and arrives at 24 nodes.
- The API uses the standard meanings of HTTP methods and status codes, every operation is idempotent, and
If-Matchprevents lost updates. - Nodes communicate in Protocol Buffers, whose numbered fields allow different versions of the software to work together.
- Consistent hashing places nodes and keys on a ring: adding a node to 24 relocated 4.33% of the keys in the measurement, against 96% for
hash mod N. - With 256 tokens for each node, every node’s measured share lay between 3.86% and 4.73%, against an ideal of 4.17%.
- Routers hold copies of the ring and send its version with every request, so that a node can refuse a request based on an outdated ring.
- Each shard has a leader that orders writes and 2 followers, and a write is acknowledged when 2 of the 3 logs hold its record.
- Any 2 majorities share a node, from which it follows that a new leader holds every acknowledged record and that at most 1 group in a partition accepts writes.
Key-Value Store: Storage Node (Low-Level Design)
The design of chapter 1 distributes a key-value store over 24 machines, keeps every key on 3 of them, and reasons at length about what happens when machines fail. All of that reasoning rests on a single assumption about each individual machine, which chapter 1 stated in its closing section and did not justify: that a write which a node has acknowledged will still be there after the node has crashed and been restarted. If 1 node can quietly lose a write it has confirmed, then a write “stored on 2 of 3 nodes” may be stored on 1, and nothing built on top of that count can be trusted.
This chapter builds the component that makes the assumption true. A storage node is the program that holds the entries of the store on 1 machine, and its whole difficulty is concentrated in the few milliseconds during which a write is in progress and the machine may lose power. We begin by examining what a crash can do to a write at each stage, which leads to the technique of writing every change to a log before doing anything else. We then define the format of the log on disk, write the code that recovers from a damaged log, and build on top of it the store and the HTTP API of chapter 1. The chapter ends by trying, in 3 different ways, to make the node lose data: by damaging its log at every possible byte, by killing it under load, and by killing it under load from another machine.
This chapter covers
- what a crash does to a write at each stage
- write-ahead logging, and why the log is written before memory is changed
- a format for records, with a length and a checksum
- recovery: reading a log, and cutting off a damaged end
- the system calls
writeandfsync, and what each of them costs - what to do when the disk reports an error
- a store with entries, versions and conditional writes
- an HTTP server that provides the API of chapter 1
- a simulation that damages a log at every byte
- a test that kills the server under load, on 1 machine and on 2
Storing data durably
A storage node keeps its entries in memory, so that a read requires no access to the disk. Memory is lost when the process stops, and so the node must keep the data on disk as well, in a form from which the contents of memory can be reconstructed.
The first design that comes to mind keeps a file which mirrors the contents of memory and, on every write, overwrites the affected part of that file. In the presence of crashes this is unsafe, and the reason is worth seeing concretely. Suppose that a value of 1 KB is being replaced by another of the same size, and that the power fails when half of the new bytes have reached the disk. The file now contains the first half of the new value followed by the second half of the old. The entry is corrupt, its former value can no longer be recovered, and, worst of all, nothing in the file indicates that anything is wrong.
The fault in this design is that an update in place destroys the old state before the new state is complete, so that there is a moment at which neither exists. The standard remedy is to give up updating in place altogether. The node appends a description of each change to the end of a file and modifies the file in no other way. A file used in this manner is called a log. Because every change is entered in the log before it is applied to any other structure, the technique is known as write-ahead logging, and the file as a write-ahead log.
With a file that is only ever appended to, a crash during a write can affect nothing but the record that was being appended. All the earlier records are intact, since nothing ever writes to that part of the file again.
The node maintains 4 invariants, conditions that hold at every moment, whatever requests arrive and wherever a crash occurs.
- Every change is in the log, and confirmed by the disk, before its client receives a response.
- Every change is in the log before it is applied to the map in memory.
- The map is equal to the result of applying all the records of the log in order.
- The log is modified only by appending. The bytes of a confirmed record are never written again.
Each invariant purchases something specific. The first is durability itself. The second and third together ensure that the map contains nothing that is absent from the log, so that the loss of the map loses no data. And the fourth confines the damage that a crash can do to the end of the file, where recovery knows to look for it.
Crashes during a write
Given this ordering of steps, the outcome of a crash can be worked out for each stage of a write.
The third case requires comment. The record has been stored durably, and the node
crashes before it has sent its response. After the restart the write is present,
although its client was never told that it had succeeded. This does not violate the
guarantee of durability, which covers acknowledged writes and is silent about writes
whose outcome was never reported. From the client’s point of view the outcome of such
a write is unknown, which is the lost response that chapter 1 discussed under
retries. The client resolves the uncertainty by sending the request again, and can do
so safely because PUT is idempotent. This case is produced deliberately, by a test,
in the section on killing the server.
The format of a record
When a log is read back, the reader has to determine where each record ends, since records differ in length. It must also be able to recognise a record that is incomplete or damaged, since the end of the file may contain the partial output of a write that a crash interrupted.
Both needs are met by beginning every record with a frame of 8 bytes, which holds the length of the data that follows and a checksum of it. The checksum is a CRC-32, a value of 32 bits computed from the data in such a way that any change to the data will almost certainly change the value. When a record is read, the checksum is computed afresh and compared with the one that was stored.
The following are the first bytes of a log after the 2 writes to user-1 shown in
chapter 1.
$ xxd data/kv.log | head -4
00000000: 1400 0000 8dd3 da1d 0100 0000 0000 0000 ................
00000010: 0106 0075 7365 722d 3161 6461 1d00 0000 ...user-1ada....
00000020: cefd 7d53 0200 0000 0000 0000 0106 0075 ..}S...........u
00000030: 7365 722d 3161 6461 206c 6f76 656c 6163 ser-1ada lovelac
The first 4 bytes, 1400 0000, encode the length 20 in little-endian order, in
which the least significant byte of a number comes first. The 4 bytes of the checksum
follow. Within the payload, the sequence number 1 occupies the first 8 bytes, and is
followed by the kind, 01, and the length of the key, 0600. The key and the value
can be read in the right-hand column. At byte 28, which is 0x1c, the second record
begins with the length 1d00 0000, or 29.
Listing 2.1 The imports of
kv/src/record.rs. kv/src/record.rs
use crc32fast::hash;
Listing 2.2 The types for a change, a record, and the result of decoding. kv/src/record.rs
/// A change to the store.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
Put { key: String, value: Vec<u8> },
Delete { key: String },
}
/// A change with its position in the log. Sequence numbers start at 1 and
/// grow by 1 with each record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub sequence: u64,
pub change: Change,
}
/// The result of reading one record from the front of a slice of bytes.
#[derive(Debug, PartialEq, Eq)]
pub enum Decoded {
/// A whole record, and the number of bytes it occupies.
Record { record: Record, length: usize },
/// The bytes end before the record does.
Incomplete,
/// The checksum does not match the payload, or the payload is malformed.
Corrupt,
}
/// The bytes of a frame: the length and the checksum.
pub const FRAME: usize = 8;
/// The largest payload a record may have: a key, a value of 1 MiB, and the
/// fixed fields.
pub const MAX_PAYLOAD: usize = (1 << 20) + 1024;
const PUT: u8 = 1;
const DELETE: u8 = 2;
Decoded has 3 variants because a reader must tell 3 situations apart: a complete
record, data that ends before the record is complete, and data that is complete and
invalid. The distinction between the last 2 is of no consequence to recovery, which
treats them alike, and is valuable when a log is being examined after a fault, since
the one indicates an interrupted write and the other a damaged disk.
Listing 2.3 Encoding a record. kv/src/record.rs
impl Record {
/// Returns the record as the bytes that are appended to the log.
pub fn encode(&self) -> Vec<u8> {
let (kind, key, value) = match &self.change {
Change::Put { key, value } => (PUT, key, value.as_slice()),
Change::Delete { key } => (DELETE, key, &[][..]),
};
let mut payload = Vec::with_capacity(11 + key.len() + value.len());
payload.extend_from_slice(&self.sequence.to_le_bytes());
payload.push(kind);
payload.extend_from_slice(&(key.len() as u16).to_le_bytes());
payload.extend_from_slice(key.as_bytes());
payload.extend_from_slice(value);
let mut bytes = Vec::with_capacity(FRAME + payload.len());
bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
bytes.extend_from_slice(&hash(&payload).to_le_bytes());
bytes.extend_from_slice(&payload);
bytes
}
}
Decoding carries out the same steps in order and returns the variant that corresponds to the first step that fails.
Listing 2.4 Decoding a record. kv/src/record.rs
impl Record {
/// Reads the record at the front of `bytes`.
pub fn decode(bytes: &[u8]) -> Decoded {
let Some((length, rest)) = bytes.split_first_chunk::<4>() else {
return Decoded::Incomplete;
};
let Some((checksum, rest)) = rest.split_first_chunk::<4>() else {
return Decoded::Incomplete;
};
let length = u32::from_le_bytes(*length) as usize;
if length > MAX_PAYLOAD {
return Decoded::Corrupt;
}
let Some(payload) = rest.get(..length) else {
return Decoded::Incomplete;
};
if hash(payload) != u32::from_le_bytes(*checksum) {
return Decoded::Corrupt;
}
match parse(payload) {
Some(record) => Decoded::Record {
record,
length: FRAME + length,
},
None => Decoded::Corrupt,
}
}
}
/// Splits a payload into its fields. It returns `None` for a payload that is
/// too short for its fields or has an unknown kind.
fn parse(payload: &[u8]) -> Option<Record> {
let (sequence, rest) = payload.split_first_chunk::<8>()?;
let (&kind, rest) = rest.split_first()?;
let (key_length, rest) = rest.split_first_chunk::<2>()?;
let (key, value) = rest.split_at_checked(u16::from_le_bytes(*key_length) as usize)?;
let key = String::from_utf8(key.to_vec()).ok()?;
let change = match (kind, value) {
(PUT, value) => Change::Put {
key,
value: value.to_vec(),
},
(DELETE, []) => Change::Delete { key },
(_unknown, _value) => return None,
};
Some(Record {
sequence: u64::from_le_bytes(*sequence),
change,
})
}
split_first_chunk returns the first bytes of a slice as an array of fixed size,
together with the remainder, or None if the slice is too short. Every field is
extracted by this means. A payload shorter than its fields therefore yields None,
and at no point does the code use a value read from the disk as an index without
first having validated it. A decoder of data from disk must be written on the
assumption that the data may be anything at all, since after a fault it may be.
What a durable write costs
Invariant 1 requires that the disk has confirmed every record. Writing to a file does not, by itself, satisfy this requirement, and the difference between the 2 is the most consequential fact about storage that a programmer has to know.
The system call write copies data into a buffer belonging to the kernel and
returns. At some later time, the kernel transfers the buffer to the disk. With the
call fsync, a program instructs the kernel to transfer all the buffered data of a
file at once, and the call does not return until the disk has reported success. In
Rust, File::sync_data makes this call.
The program synccost appends 500 records to a log, first without fsync and then
with an fsync after every record.
Listing 2.5 The imports of
synccost/src/main.rs. synccost/src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use kv::{Change, Log};
Listing 2.6 Measuring the cost of an append with and without
fsync. synccost/src/main.rs
const RECORDS: u32 = 500;
/// Appends `RECORDS` records of 100 bytes and returns the time it took.
fn measure(directory: &Path, sync: bool) -> Result<Duration, Box<dyn Error>> {
let mut log = Log::open(directory, sync)?.0;
let started = Instant::now();
for number in 0..RECORDS {
log.append(Change::Put {
key: format!("key-{number:06}"),
value: vec![b'v'; 100],
})?;
}
Ok(started.elapsed())
}
fn main() -> Result<(), Box<dyn Error>> {
let root = env::args()
.nth(1)
.ok_or("usage: synccost <directory>")?;
for (name, sync) in [("without fsync", false), ("with fsync", true)] {
let directory = Path::new(&root).join(name.replace(' ', "-"));
let elapsed = measure(&directory, sync)?;
let each = elapsed.as_secs_f64() * 1000.0 / f64::from(RECORDS);
println!(
"{name:13}: {RECORDS} records in {:8.1} ms, {each:.3} ms per record, {:6.0} records per second",
elapsed.as_secs_f64() * 1000.0,
1000.0 / each
);
fs::remove_dir_all(&directory)?;
}
Ok(())
}
$ cargo run --release -p synccost -- /var/tmp/synccost
without fsync: 500 records in 1.4 ms, 0.003 ms per record, 366991 records per second
with fsync : 500 records in 2697.5 ms, 5.395 ms per record, 185 records per second
On an NVMe disk, a durable append took 5.4 ms, about 1900 times as long as an append
that merely copies data into the kernel’s buffer. This is the figure from which
chapter 1 derived its 185 records per second. Because the cost is so high, storage
systems commonly make the behaviour configurable, and kvd has an option --no-sync
under which the node responds after write alone. In that mode the node keeps every
acknowledged write if its process crashes, and may lose the most recent writes if the
machine loses power. Whether that is an acceptable bargain depends on the data, and it
is a decision that ought to be made knowingly, since the default behaviour of a great
many programs makes it silently.
Design overview
Low-level design: the storage node
Crates
| Crate | Kind | Contents |
|---|---|---|
kv | library | record (the format of a record), log (the log file), store (the map and the conditions on writes) |
kvd | library and program | api (errors and conditional headers, shared with later chapters) and the HTTP server |
kvctl | program | a client that generates load and verifies acknowledged writes |
crashsim | program | a simulation that damages a log at every position |
synccost | program | the measurement of fsync |
Types
| Type | Fields | Purpose |
|---|---|---|
Change | Put { key, value } or Delete { key } | the modification that a write requests |
Record | sequence, change | a change and its position in the log |
Log | file, sync, next, failed | appends records and reads them back |
Entry | value, version | the current state of a key |
Condition | Always, Absent, Version(n) | the state of the key that a write requires |
Store | entries: BTreeMap<String, Entry>, log | applies changes and answers reads |
Operations
| Operation | Steps | Cost |
|---|---|---|
Store::put | check the condition, Log::append, apply to the map | 1 fsync |
Store::delete | check that the key exists, check the condition, Log::append, apply | 1 fsync |
Store::get | look the key up in the map | O(log n), with no access to the disk |
Store::list | iterate over the map from a starting key | O(log n + size of the page), with no access to the disk |
Store::open | Log::open, then apply every record | proportional to the size of the log |
Failure policy
| Event | Response |
|---|---|
| The log ends with an incomplete or damaged record | The file is cut off after the last valid record. |
| A write to the log fails | The node returns 503 and rejects every later write until it is restarted. |
The manifest of the workspace declares the edition of Rust, the minimum version of the compiler, and the dependencies that its crates share.
Listing 2.7 The complete
Cargo.toml. Cargo.toml
[workspace]
resolver = "3"
members = ["kv", "kvd", "kvctl", "crashsim", "synccost", "ring", "ringsim", "repl", "replsim", "kvnode", "raft", "raftsim", "kvraft", "probe", "kvrouter", "kvgroup", "kvbench",
"ratelimit", "idgen", "shortener", "cachesim", "queuesim", "locksim", "jobsim", "notifysim", "feedsim", "objectsim",
"streamsim", "chatsim", "paysim", "bookingsim", "nearbysim", "typeahead",
"crawlsim", "metricsim", "syncsim", "editsim", "matchsim", "clicksim",
]
[workspace.package]
edition = "2024"
rust-version = "1.99"
license = "MIT"
publish = false
[workspace.dependencies]
kv = { path = "kv" }
ring = { path = "ring" }
repl = { path = "repl" }
raft = { path = "raft" }
kvd = { path = "kvd" }
axum = "0.8"
clap = { version = "4", features = ["derive"] }
crc32fast = "1"
parking_lot = "0.12"
reqwest = { version = "0.13", default-features = false, features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
prost = "0.14"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync", "time"] }
tonic = "0.14"
tonic-prost = "0.14"
tonic-prost-build = "0.14"
ureq = "3"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
| Dependency | Purpose |
|---|---|
axum, tokio | the HTTP server and the asynchronous runtime |
clap | parsing the options of the command line |
crc32fast | the checksum of a record |
parking_lot | a mutex whose lock method returns the guard directly |
serde | JSON encoding of the list response and of error bodies |
thiserror | deriving Display and From for the store’s error type |
ureq | the HTTP client used by kvctl |
xxhash-rust | the hash function of the ring described in chapter 1 |
The log
Log owns the log file. It offers 2 operations: opening, which includes recovery, and
appending a record.
Listing 2.8 The imports of
kv/src/log.rs. kv/src/log.rs
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::Path;
use crate::record::{Change, Decoded, Record};
Listing 2.9 The log, and the result of opening it. kv/src/log.rs
/// The name of the log inside the data directory.
pub const FILE_NAME: &str = "kv.log";
/// An open log, positioned at its end.
pub struct Log {
file: File,
/// Whether `append` waits for the disk before it returns.
sync: bool,
/// The sequence number of the next record.
next: u64,
/// Set by a failed write. A failed log accepts no more records.
failed: bool,
}
/// What `Log::open` found in the file.
pub struct Recovery {
/// The complete records, in the order they were written.
pub records: Vec<Record>,
/// The bytes after the last complete record, which were removed.
pub discarded: u64,
}
Recovery
Opening a log reads the entire file and decodes records from the beginning until one
fails to decode. Since Record::decode already distinguishes every possible outcome,
the loop is short. Recovery treats an incomplete record and a corrupt one alike: in
either case the record before it becomes the last record of the log.
Listing 2.10 Decoding records until one is incomplete or corrupt. kv/src/log.rs
/// Reads records from the front of `bytes` until one is incomplete or
/// corrupt. It returns the records and the number of bytes they occupy.
fn scan(bytes: &[u8]) -> (Vec<Record>, usize) {
let mut records = Vec::new();
let mut valid = 0;
while let Decoded::Record { record, length } = Record::decode(&bytes[valid..]) {
records.push(record);
valid += length;
}
(records, valid)
}
Listing 2.11 Opening the log and cutting off a damaged end. kv/src/log.rs
impl Log {
/// Opens the log in `directory`, creating both if they do not exist.
/// Anything after the last complete record is cut off.
pub fn open(directory: &Path, sync: bool) -> io::Result<(Log, Recovery)> {
fs::create_dir_all(directory)?;
let mut file = OpenOptions::new()
.read(true)
.append(true)
.create(true)
.open(directory.join(FILE_NAME))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let (records, valid) = scan(&bytes);
let discarded = (bytes.len() - valid) as u64;
if discarded > 0 {
file.set_len(valid as u64)?;
}
if sync {
// The file's length is part of the file. Its name is part of the directory.
file.sync_all()?;
File::open(directory)?.sync_all()?;
}
let next = records
.last()
.map_or(1, |record| record.sequence + 1);
Ok((
Log {
file,
sync,
next,
failed: false,
},
Recovery { records, discarded },
))
}
}
The call to set_len removes the damaged end of the file before any new record is
appended, and the correctness of the whole log depends on this step. If the damaged
bytes were left where they are, the next record would be written after them, a later
recovery would stop at the damage, and the new record would be lost although the node
had acknowledged it. A recovery procedure that reads correctly and fails to repair
has merely postponed the loss.
Both calls to sync_all are necessary, one to make the new length of the file
durable and the other for its directory. Synchronising a file makes its contents
durable, but the existence of the file is recorded in the directory that contains it,
and that is a separate object. A newly created file whose directory has not been
synchronised may be absent after a power failure, contents and all. The log therefore
synchronises its directory when it is opened.
Appending
Listing 2.12 Appending a record. kv/src/log.rs
impl Log {
/// Appends a change and returns the record that holds it. When the log
/// was opened with `sync`, the record is on the disk when this returns.
pub fn append(&mut self, change: Change) -> io::Result<Record> {
if self.failed {
return Err(io::Error::other(
"the log failed on an earlier write and is closed",
));
}
let record = Record {
sequence: self.next,
change,
};
if let Err(error) = self.write(&record.encode()) {
self.failed = true;
return Err(error);
}
self.next += 1;
Ok(record)
}
fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
self.file.write_all(bytes)?;
if self.sync {
self.file.sync_data()?;
}
Ok(())
}
/// The sequence number of the last record, or 0 for an empty log.
pub fn last_sequence(&self) -> u64 {
self.next - 1
}
}
The field failed implements a policy known as fail-stop, under which a component
that meets an error it cannot handle safely ceases to operate. If write_all or
sync_data returns an error, the state of the file is unknown: the record may be
absent, partly written, or complete. A log that went on accepting records could append
a valid record after a damaged one, where recovery would never reach it.
It might be thought that the node could call fsync again. This is unsafe as
well, and for a reason that has surprised the authors of several well-known
databases. After a failed fsync, the kernel may already have discarded the data it
was unable to write, and a second call may then report success although nothing has
reached the disk. A success after a failure therefore proves nothing. The log
accordingly rejects all further appends. From that moment its node answers 503 to
writes, continues to serve reads from memory, and has to be restarted or replaced by
an operator. In the design of chapter 1, the followers of all the affected shards
elect new leaders at this point, and the fault of 1 disk is absorbed by the cluster.
The store
Store contains the map and the log, and every modification of either passes through
it.
Listing 2.13 The imports of
kv/src/store.rs. kv/src/store.rs
use std::collections::BTreeMap;
use std::io;
use std::ops::Bound;
use std::path::Path;
use thiserror::Error;
use crate::log::Log;
use crate::record::{Change, Record};
Listing 2.14 Opening the store by applying the records of the log. kv/src/store.rs
impl Store {
/// Opens the store in `directory` and applies every record of its log.
/// It returns the store, the number of records applied, and the number
/// of bytes that were cut from the end of the log.
pub fn open(directory: &Path, sync: bool) -> io::Result<(Store, usize, u64)> {
let (log, recovery) = Log::open(directory, sync)?;
let applied = recovery.records.len();
let mut entries = BTreeMap::new();
for record in recovery.records {
apply(&mut entries, record);
}
Ok((Store { entries, log }, applied, recovery.discarded))
}
}
/// Makes the change of one record in the map. Recovery and the write path
/// both go through this function, so they cannot disagree.
pub fn apply(entries: &mut BTreeMap<String, Entry>, record: Record) {
match record.change {
Change::Put { key, value } => {
entries.insert(
key,
Entry {
value,
version: record.sequence,
},
);
}
Change::Delete { key } => {
entries.remove(&key);
}
}
}
Recovery and the path of a write share a single function, apply, for modifying the
map. Invariant 3 is a consequence of this arrangement. What a record does to the map
is defined in exactly 1 place, which is used both when a record is first written and
whenever a log is read back, so that the 2 cannot come to differ.
Listing 2.15 Conditional writes: check, append, apply. kv/src/store.rs
impl Store {
/// Sets the value of a key. The change is in the log before it is in
/// the map.
pub fn put(
&mut self,
key: String,
value: Vec<u8>,
condition: Condition,
) -> Result<Written, StoreError> {
let current = self.version(&key);
check(condition, current)?;
let record = self
.log
.append(Change::Put { key, value })?;
let version = record.sequence;
apply(&mut self.entries, record);
Ok(Written {
version,
created: current.is_none(),
})
}
/// Removes a key.
pub fn delete(&mut self, key: &str, condition: Condition) -> Result<(), StoreError> {
let current = self.version(key);
if current.is_none() {
return Err(StoreError::NotFound);
}
check(condition, current)?;
let record = self.log.append(Change::Delete {
key: key.to_owned(),
})?;
apply(&mut self.entries, record);
Ok(())
}
fn version(&self, key: &str) -> Option<u64> {
self.entries
.get(key)
.map(|entry| entry.version)
}
}
pub fn check(condition: Condition, current: Option<u64>) -> Result<(), StoreError> {
let holds = match condition {
Condition::Always => true,
Condition::Absent => current.is_none(),
Condition::Version(version) => current == Some(version),
};
holds
.then_some(())
.ok_or(StoreError::ConditionFailed { current })
}
put and delete perform the same 3 steps in the same order. The condition is
evaluated first, against the map, so that a write which is refused produces no
record. append then returns when the record is durable, or returns an error, in
which case the ? operator leaves the function before apply is reached. The map is
modified last. The order of these steps is the order of the invariants, and the code
has been arranged so that it cannot be otherwise.
The version of an entry is the sequence number of its record. The store keeps no separate counter of versions that could come to disagree with the log after a crash.
Reads use the map alone.
Listing 2.16 Reading a key, and listing a page of keys. kv/src/store.rs
impl Store {
pub fn get(&self, key: &str) -> Option<&Entry> {
self.entries.get(key)
}
/// Returns up to `limit` keys that start with `prefix` and sort after
/// `after`, in order, and whether more keys follow them.
pub fn list(&self, prefix: &str, after: Option<&str>, limit: usize) -> (Vec<Summary>, bool) {
page(&self.entries, prefix, after, limit)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// The sequence number of the last record in the log.
pub fn last_sequence(&self) -> u64 {
self.log.last_sequence()
}
}
/// One page of a listing over a map of entries.
pub fn page(
entries: &BTreeMap<String, Entry>,
prefix: &str,
after: Option<&str>,
limit: usize,
) -> (Vec<Summary>, bool) {
let start = match after {
Some(key) => Bound::Excluded(key),
None => Bound::Included(prefix),
};
let mut matching = entries
.range::<str, _>((start, Bound::Unbounded))
.take_while(|entry| entry.0.starts_with(prefix))
.map(|(key, entry)| Summary {
key: key.clone(),
version: entry.version,
size: entry.value.len(),
});
let page: Vec<Summary> = matching
.by_ref()
.take(limit)
.collect();
let more = matching.next().is_some();
(page, more)
}
A BTreeMap keeps its keys in sorted order, so that a listing is an iteration from
some starting position, which is implemented in a function named page. Its call to
range begins at the prefix or immediately after the cursor, and take_while ends
the iteration at the first key that lacks the prefix. By looking for 1 item beyond the
page, page learns whether a further page exists without having to count the
remaining keys.
The HTTP server
kvd presents the store through the API of chapter 1.
Listing 2.17 The imports of
kvd/src/main.rs. kvd/src/main.rs
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::Parser;
use kv::{Condition, Store, StoreError, Summary};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, version_header, write_condition};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio::task;
Listing 2.18 The options of the command line, and the shared state. kvd/src/main.rs
#[derive(Parser)]
struct Options {
/// The address to accept connections on.
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
/// The directory that holds the log.
#[arg(long, default_value = "data")]
data: PathBuf,
/// Respond to a write without waiting for the disk.
#[arg(long)]
no_sync: bool,
}
/// The store, shared by every request.
type Shared = Arc<Mutex<Store>>;
Errors
Each kind of failure of a request is a variant of ApiError, in the module
kvd::api, and a single function converts a variant into a status code and a response
body in the format of RFC 9457. Handlers return Result<_, ApiError> and use ?. The
conversion from StoreError is the 1 place that maps a failed condition to 412 and a
failure of the log to 503.
Listing 2.19 The imports of
kvd/src/api.rs. kvd/src/api.rs
use axum::Json;
use axum::http::header::{CONTENT_TYPE, IF_MATCH, IF_NONE_MATCH};
use axum::http::{HeaderMap, HeaderName, StatusCode};
use axum::response::{IntoResponse, Response};
use kv::{Condition, StoreError};
use serde::Serialize;
Listing 2.20 The errors of the API and their responses. kvd/src/api.rs
/// The ways a request can fail. Each one is a status code and a sentence.
pub enum ApiError {
InvalidKey,
InvalidHeader(HeaderName),
NotFound,
ConditionFailed(Option<u64>),
Unavailable(String),
/// The request reached a node that does not serve it. The text names the reason.
Misdirected(String),
/// The shard has no leader at the moment. Nothing was changed.
NoLeader,
}
/// The body of an error response, in the format of RFC 9457.
#[derive(Serialize)]
struct Problem {
#[serde(rename = "type")]
kind: &'static str,
title: &'static str,
status: u16,
#[serde(skip_serializing_if = "String::is_empty")]
detail: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, kind, title, detail) = match self {
ApiError::InvalidKey => (
StatusCode::BAD_REQUEST,
"/problems/invalid-key",
"The key is not valid",
format!("a key is 1 to {MAX_KEY} characters from A-Z, a-z, 0-9, '.', '_' and '-'"),
),
ApiError::InvalidHeader(name) => (
StatusCode::BAD_REQUEST,
"/problems/invalid-header",
"A request header is not valid",
format!("{name} must be a version in double quotes, or * where that is allowed"),
),
ApiError::NotFound => (
StatusCode::NOT_FOUND,
"/problems/key-not-found",
"The key does not exist",
String::new(),
),
ApiError::ConditionFailed(current) => (
StatusCode::PRECONDITION_FAILED,
"/problems/condition-failed",
"The condition on the key's version does not hold",
current.map_or("the key does not exist".into(), |version| {
format!("the key is at version {version}")
}),
),
ApiError::Misdirected(detail) => (
StatusCode::MISDIRECTED_REQUEST,
"/problems/not-leader",
"This node is not the leader",
detail,
),
ApiError::NoLeader => (
StatusCode::SERVICE_UNAVAILABLE,
"/problems/no-leader",
"The shard has no leader at the moment",
String::new(),
),
ApiError::Unavailable(detail) => (
StatusCode::SERVICE_UNAVAILABLE,
"/problems/store-unavailable",
"The node cannot serve the request",
detail,
),
};
let problem = Problem {
kind,
title,
status: status.as_u16(),
detail,
};
(
status,
[(CONTENT_TYPE, "application/problem+json")],
Json(problem),
)
.into_response()
}
}
impl From<StoreError> for ApiError {
fn from(error: StoreError) -> Self {
match error {
StoreError::NotFound => ApiError::NotFound,
StoreError::ConditionFailed { current } => ApiError::ConditionFailed(current),
StoreError::Log(error) => ApiError::Unavailable(error.to_string()),
}
}
}
Conditions
Listing 2.21 Parsing the
If-Match and If-None-Match headers. kvd/src/api.rs
/// A version as it appears in `ETag`, `If-Match` and `If-None-Match`.
pub fn etag(version: u64) -> String {
format!("\"{version}\"")
}
pub fn valid_key(key: &str) -> Result<(), ApiError> {
let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
let valid = (1..=MAX_KEY).contains(&key.len()) && key.chars().all(allowed);
valid
.then_some(())
.ok_or(ApiError::InvalidKey)
}
/// Reads a header that holds a quoted version or `*`.
pub fn version_header(
headers: &HeaderMap,
name: HeaderName,
) -> Result<Option<Condition>, ApiError> {
let Some(value) = headers.get(&name) else {
return Ok(None);
};
let Ok(text) = value.to_str() else {
return Err(ApiError::InvalidHeader(name));
};
if text == "*" {
return Ok(Some(Condition::Absent));
}
let version = text
.strip_prefix("\"")
.and_then(|rest| rest.strip_suffix("\""))
.and_then(|digits| digits.parse().ok());
version
.map(Condition::Version)
.map(Some)
.ok_or(ApiError::InvalidHeader(name))
}
/// The condition a write carries: `If-Match: "7"` requires version 7, and
/// `If-None-Match: *` requires that the key does not exist.
pub fn write_condition(headers: &HeaderMap) -> Result<Condition, ApiError> {
match (
version_header(headers, IF_MATCH)?,
version_header(headers, IF_NONE_MATCH)?,
) {
(Some(Condition::Version(version)), None) => Ok(Condition::Version(version)),
(None, Some(Condition::Absent)) => Ok(Condition::Absent),
(None, None) => Ok(Condition::Always),
(Some(..), ..) => Err(ApiError::InvalidHeader(IF_MATCH)),
(None, Some(..)) => Err(ApiError::InvalidHeader(IF_NONE_MATCH)),
}
}
write_condition accepts the combinations of headers that are meaningful for a write
and answers 400 to all others, such as If-Match: *. The server thus rejects a
condition that it cannot interpret. The alternative, to ignore it, would mean
performing an unconditional write on behalf of a client that had asked for a
conditional one, which is exactly the lost update that the condition was sent to
prevent.
Blocking calls in an asynchronous server
The server uses an asynchronous runtime, in which a small pool of threads serves all
the connections and switches among them whenever one has to wait for the network. To
block such a thread, as an fsync of 5 ms does, would delay every connection that the
thread serves. spawn_blocking runs a closure on a separate pool of threads intended
for operations that block, and the handler awaits the result without occupying a
thread of the runtime.
Listing 2.22 Running a write on a thread that is permitted to block. kvd/src/main.rs
/// Runs a write on a thread that may block. A write waits for the disk, and
/// a thread that waits must not be one of the threads that serve requests.
async fn write<T, F>(store: Shared, work: F) -> Result<T, ApiError>
where
F: FnOnce(&mut Store) -> Result<T, StoreError> + Send + 'static,
T: Send + 'static,
{
let result = task::spawn_blocking(move || work(&mut store.lock()))
.await
.map_err(|error| ApiError::Unavailable(error.to_string()))?;
Ok(result?)
}
Listing 2.23 The handlers for
PUT and DELETE. kvd/src/main.rs
async fn put_key(
State(store): State<Shared>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
let written = write(store, move |store| store.put(key, body.to_vec(), condition)).await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(store): State<Shared>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
write(store, move |store| store.delete(&key, condition)).await?;
Ok(StatusCode::NO_CONTENT)
}
PUT answers 201 Created when it creates a key and 204 No Content when it
replaces a value. Both responses carry the new version in the ETag header.
Reads
Listing 2.24 The handler for
GET on a single key. kvd/src/main.rs
async fn get_key(
State(store): State<Shared>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let cached = version_header(&headers, IF_NONE_MATCH)?;
let entry = store
.lock()
.get(&key)
.cloned()
.ok_or(ApiError::NotFound)?;
let tag = [(ETAG, etag(entry.version))];
if cached == Some(Condition::Version(entry.version)) {
return Ok((StatusCode::NOT_MODIFIED, tag).into_response());
}
Ok((
tag,
[(CONTENT_TYPE, "application/octet-stream")],
entry.value,
)
.into_response())
}
A read holds the lock of the store only while an entry is being copied, and performs no access to the disk. It can therefore run directly on a thread of the runtime.
Listing 2.25 The handlers for listing keys and for the health check. kvd/src/main.rs
#[derive(Deserialize)]
struct ListQuery {
#[serde(default)]
prefix: String,
after: Option<String>,
limit: Option<usize>,
}
#[derive(Serialize)]
struct Item {
key: String,
version: u64,
size: usize,
}
#[derive(Serialize)]
struct Page {
keys: Vec<Item>,
/// The value of `after` that returns the next page, when there is one.
next_after: Option<String>,
}
async fn list_keys(State(store): State<Shared>, Query(query): Query<ListQuery>) -> Json<Page> {
let limit = query
.limit
.unwrap_or(100)
.clamp(1, 1000);
let (page, more) = store
.lock()
.list(&query.prefix, query.after.as_deref(), limit);
let next_after = page
.last()
.filter(|_last| more)
.map(|last| last.key.clone());
let keys = page
.into_iter()
.map(|Summary { key, version, size }| Item { key, version, size })
.collect();
Json(Page { keys, next_after })
}
#[derive(Serialize)]
struct Health {
status: &'static str,
keys: usize,
last_sequence: u64,
}
async fn health(State(store): State<Shared>) -> Json<Health> {
let store = store.lock();
Json(Health {
status: "ok",
keys: store.len(),
last_sequence: store.last_sequence(),
})
}
Listing 2.26 Opening the store and starting the server. kvd/src/main.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let (store, applied, discarded) = Store::open(&options.data, !options.no_sync)?;
println!(
"recovered {} keys from {applied} records, cut {discarded} bytes from the end of the log",
store.len()
);
let routes = Router::new()
.route("/v1/keys", get(list_keys))
.route(
"/v1/keys/{key}",
get(get_key)
.put(put_key)
.delete(delete_key),
)
.route("/healthz", get(health))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(Arc::new(Mutex::new(store)));
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Running the node
$ cd code
$ cargo build --release
$ ./target/release/kvd --listen 127.0.0.1:7001 --data /var/tmp/kvd
recovered 0 keys from 0 records, cut 0 bytes from the end of the log
listening on 127.0.0.1:7001
All the responses shown in chapter 1 came from this program. A further 3 complete the set: a key containing a character outside the permitted set, a method that a resource does not support, and a health check.
$ curl -i -X PUT 'http://127.0.0.1:7001/v1/keys/bad%20key' --data-binary 'x'
HTTP/1.1 400 Bad Request
content-type: application/problem+json
content-length: 153
{"type":"/problems/invalid-key","title":"The key is not valid","status":400,"detail":"a key is 1 to 256 characters from A-Z, a-z, 0-9, '.', '_' and '-'"}
$ curl -i -X POST http://127.0.0.1:7001/v1/keys/user-1 --data-binary 'x'
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD,PUT,DELETE
content-length: 0
Both the 405 and its allow header are produced by axum, whose router knows
which methods are registered for each path.
$ curl -i http://127.0.0.1:7001/healthz
HTTP/1.1 200 OK
content-type: application/json
content-length: 42
{"status":"ok","keys":2,"last_sequence":5}
Simulating crashes
The log is intended to recover exactly its complete records, wherever a crash occurs.
A claim of this kind is not established by a few examples, and crashsim verifies it
exhaustively, for a log of 5 records, by damaging copies of the log in every possible
position.
Listing 2.27 The imports of
crashsim/src/main.rs. crashsim/src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::path::Path;
use std::process;
use kv::log::FILE_NAME;
use kv::{Change, Log, Record};
Listing 2.28 Building the test log. crashsim/src/main.rs
/// The changes of the test log.
fn changes() -> Vec<Change> {
let put = |key: &str, value: &str| Change::Put {
key: key.into(),
value: value.into(),
};
vec![
put("user-1", "ada"),
put("user-2", "grace"),
put("user-1", "ada lovelace"),
Change::Delete {
key: "user-2".into(),
},
put("user-3", "barbara"),
]
}
/// Writes the changes to a log in `directory`. It returns the bytes of the
/// log and the offset at which each record ends.
fn build(directory: &Path) -> Result<(Vec<u8>, Vec<usize>), Box<dyn Error>> {
let mut log = Log::open(directory, false)?.0;
let mut ends = Vec::new();
for change in changes() {
let record = log.append(change)?;
ends.push(ends.last().copied().unwrap_or(0) + record.encode().len());
}
Ok((fs::read(directory.join(FILE_NAME))?, ends))
}
Listing 2.29 Opening a damaged copy, and the expected result of recovery. crashsim/src/main.rs
/// Replaces the log in `directory` with `bytes` and opens it.
fn recover(directory: &Path, bytes: &[u8]) -> Result<Vec<Record>, Box<dyn Error>> {
fs::write(directory.join(FILE_NAME), bytes)?;
let recovery = Log::open(directory, false)?.1;
Ok(recovery.records)
}
/// Whether `records` are exactly the first `count` changes, in order.
fn is_prefix(records: &[Record], count: usize) -> bool {
let expected = changes();
records.len() == count
&& records
.iter()
.zip(&expected)
.enumerate()
.all(|(index, (record, change))| {
record.sequence == index as u64 + 1 && &record.change == change
})
}
A crash during a write leaves a file that is shorter than was intended. cuts creates
every such file, 1 for each possible length, and checks the result of recovering each.
Listing 2.30 Cutting the log short at every byte. crashsim/src/main.rs
/// Cuts the log at every length and checks that recovery returns the records
/// that end at or before the cut.
fn cuts(directory: &Path, log: &[u8], ends: &[usize]) -> Result<(), Box<dyn Error>> {
for length in 0..=log.len() {
let complete = ends
.iter()
.filter(|&&end| end <= length)
.count();
let records = recover(directory, &log[..length])?;
if !is_prefix(&records, complete) {
return Err(format!(
"cut at {length}: expected {complete} records, recovered {}",
records.len()
)
.into());
}
}
println!(
"cut at every length from 0 to {}: each recovery returned the complete records",
log.len()
);
let mut first = 0;
for (count, &end) in ends.iter().enumerate() {
println!(" lengths {first:3} to {:3}: {count} records", end - 1);
first = end;
}
println!(" length {first:3} : {} records", ends.len());
Ok(())
}
Storage devices are also capable of returning data that differs from what was
written. flips inverts 1 bit in each byte of the log, in a separate run for every
byte. With a bit inverted, the affected record fails its checksum, and recovery is
expected to return all the records before it.
Listing 2.31 Inverting 1 bit in every byte. crashsim/src/main.rs
/// Flips 1 bit in each byte in turn and checks that recovery returns the
/// records before the damaged one.
fn flips(directory: &Path, log: &[u8], ends: &[usize]) -> Result<(), Box<dyn Error>> {
for position in 0..log.len() {
let mut damaged = log.to_vec();
damaged[position] ^= 0x10;
let before = ends
.iter()
.filter(|&&end| end <= position)
.count();
let records = recover(directory, &damaged)?;
if !is_prefix(&records, before) {
return Err(format!(
"flip at {position}: expected {before} records, recovered {}",
records.len()
)
.into());
}
}
println!(
"1 bit flipped in each of {} bytes: each recovery returned the records before the damage",
log.len()
);
Ok(())
}
Listing 2.32 Running the simulation. crashsim/src/main.rs
fn run(directory: &Path) -> Result<(), Box<dyn Error>> {
let (log, ends) = build(directory)?;
let boundaries: Vec<String> = ends
.iter()
.map(usize::to_string)
.collect();
println!(
"a log of {} records and {} bytes, with records ending at {}",
ends.len(),
log.len(),
boundaries.join(", ")
);
cuts(directory, &log, &ends)?;
flips(directory, &log, &ends)?;
let records = recover(directory, &log[..ends[1] + 5])?;
let mut reopened = Log::open(directory, false)?.0;
let next = reopened.append(Change::Delete {
key: "user-1".into(),
})?;
println!(
"after a cut at {}: {} records recovered, and the next record has sequence {}",
ends[1] + 5,
records.len(),
next.sequence
);
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let directory = env::temp_dir().join(format!("crashsim-{}", process::id()));
let result = run(&directory);
fs::remove_dir_all(&directory)?;
result
}
$ cargo run -p crashsim
a log of 5 records and 152 bytes, with records ending at 28, 58, 95, 120, 152
cut at every length from 0 to 152: each recovery returned the complete records
lengths 0 to 27: 0 records
lengths 28 to 57: 1 records
lengths 58 to 94: 2 records
lengths 95 to 119: 3 records
lengths 120 to 151: 4 records
length 152 : 5 records
1 bit flipped in each of 152 bytes: each recovery returned the records before the damage
after a cut at 63: 2 records recovered, and the next record has sequence 3
| Output | What it shows |
|---|---|
| lengths 28 to 57: 1 record | A record is recovered only if every one of its bytes is present. With 29 of the 30 bytes of record 2 on disk, record 2 is not recovered. |
| each recovery returned complete records | No cut causes recovery to return a partial record, a wrong record, or records out of order. |
| 1 bit flipped in each of 152 bytes | Corruption at any position in a record, the length field included, is detected, and the preceding records are returned. |
| after a cut at 63, the next record has sequence 3 | The damaged end was removed, and the log continues after the last valid record with no gap in its sequence numbers. |
The test with inverted bits also discloses a limitation. When a bit is inverted in record 2, recovery stops there and discards records 3 to 5, although they are intact. For damage at the end of the file, which is what an interrupted write produces, this behaviour is correct. Damage in the middle of the file is a different matter: it means that the disk has lost data which was acknowledged, and a single node has no means of repairing such a loss. In the design of chapter 1, the record is recovered from the other replicas, and this is the first of several points at which a limitation of 1 node is the reason for something in the larger design.
Killing the server under load
The simulation exercises the log in isolation. The next test covers a complete node:
the HTTP server, the store, the log and the disk. The command kvctl load writes keys
in sequence and records each key in a local file once the server has answered with
success.
Listing 2.33 The imports of
kvctl/src/main.rs. kvctl/src/main.rs
use std::error::Error;
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use ureq::Agent;
Listing 2.34 Writing keys, and recording each acknowledged key. kvctl/src/main.rs
/// Writes `count` keys. A key is recorded in `acked` only after the server
/// has answered with a success status.
fn load(agent: &Agent, server: &str, count: u32, acked: &PathBuf) -> Result<(), Box<dyn Error>> {
let mut file = BufWriter::new(File::create(acked)?);
let mut written = 0;
for number in 1..=count {
let key = format!("key-{number:06}");
if let Err(error) = agent
.put(format!("{server}/v1/keys/{key}"))
.send(format!("value of {key}"))
{
println!("write {number} failed: {error}");
break;
}
writeln!(file, "{key}")?;
written += 1;
}
file.flush()?;
println!("{written} writes acknowledged");
Ok(())
}
Listing 2.35 Reading back every acknowledged key. kvctl/src/main.rs
/// Reads every acknowledged key and compares the value with the one that
/// was written.
fn verify(agent: &Agent, server: &str, acked: &PathBuf) -> Result<(), Box<dyn Error>> {
let keys = fs::read_to_string(acked)?;
let (mut found, mut missing) = (0, 0);
for key in keys.lines() {
let value = match agent
.get(format!("{server}/v1/keys/{key}"))
.call()
{
Ok(mut response) => response
.body_mut()
.read_to_string()?,
Err(ureq::Error::StatusCode(404)) => String::new(),
Err(error) => return Err(error.into()),
};
if value == format!("value of {key}") {
found += 1;
} else {
missing += 1;
}
}
println!(
"{} acknowledged, {found} found, {missing} missing",
found + missing
);
Ok(())
}
While the load is running, the server is terminated with signal 9, SIGKILL, which
stops a process at once and allows it to complete nothing that is in progress.
$ ./target/release/kvd --data /var/tmp/kvd &
recovered 0 keys from 0 records, cut 0 bytes from the end of the log
listening on 127.0.0.1:7001
$ ./target/release/kvctl load --server http://127.0.0.1:7001 --count 5000 --acked acked.txt &
$ sleep 3; pkill -9 -x kvd
write 495 failed: io: Peer disconnected
494 writes acknowledged
$ ./target/release/kvd --data /var/tmp/kvd &
recovered 495 keys from 495 records, cut 0 bytes from the end of the log
listening on 127.0.0.1:7001
$ ./target/release/kvctl verify --server http://127.0.0.1:7001 --acked acked.txt
494 acknowledged, 494 found, 0 missing
| Output | What it shows |
|---|---|
| 494 writes acknowledged, 494 found, 0 missing | Every acknowledged write was recovered after the process was killed. |
| recovered 495 keys | The log contains 1 more write than the client had recorded. |
| cut 0 bytes | The process was not in the middle of appending a record when it was killed. |
Write 495 is an instance of the third case in figure 2.2. Its record was
stored durably, and the process was killed before any response was sent. From the
client’s side the write failed, although the store contains it, and a client that
needs a definite outcome sends its PUT again.
The limits of this test
The same test, run with --no-sync, also recovers every acknowledged write.
$ ./target/release/kvd --data /var/tmp/kvd --no-sync &
$ ./target/release/kvctl load --server http://127.0.0.1:7001 --count 200000 --acked acked.txt &
$ sleep 1.5; pkill -9 -x kvd
write 8595 failed: io: Connection reset by peer (os error 104)
8594 writes acknowledged
$ ./target/release/kvd --data /var/tmp/kvd --no-sync &
recovered 8594 keys from 8594 records, cut 0 bytes from the end of the log
$ ./target/release/kvctl verify --server http://127.0.0.1:7001 --acked acked.txt
8594 acknowledged, 8594 found, 0 missing
It would be a serious error to conclude from this that fsync is unnecessary. When a
process is killed, its own memory is lost, but the data it had passed to write is
in the kernel’s buffers. The kernel continues to run, and writes that data to the disk
shortly afterwards. Only a failure that stops the kernel, such as a loss of power,
loses data that was written without fsync. A test that kills a process therefore
succeeds with and without fsync, and provides no evidence whatever about durability
under a loss of power. Many systems have been declared durable on the strength of
exactly this test.
Running the test on 2 machines
The server and the client are separate programs that communicate over HTTP, and can
therefore run on different machines without any change. In the following session, the
server runs on a machine named node-b at the address 10.0.0.2, and the client on
node-a. A binary can be copied between machines of the same architecture and
operating system, and is otherwise built on each.
node-a$ scp target/release/kvd node-b:/tmp/kvd
node-b$ /tmp/kvd --listen 0.0.0.0:7001 --data /tmp/kvd-data &
recovered 0 keys from 0 records, cut 0 bytes from the end of the log
listening on 0.0.0.0:7001
node-a$ ./target/release/kvctl load --server http://10.0.0.2:7001 --count 5000 --acked acked.txt &
node-b$ pkill -9 -x kvd
write 1108 failed: io: Connection reset by peer (os error 104)
1107 writes acknowledged
node-b$ /tmp/kvd --listen 0.0.0.0:7001 --data /tmp/kvd-data &
recovered 1107 keys from 1107 records, cut 0 bytes from the end of the log
node-a$ ./target/release/kvctl verify --server http://10.0.0.2:7001 --acked acked.txt
1107 acknowledged, 1107 found, 0 missing
node-a$ curl http://10.0.0.2:7001/healthz
{"status":"ok","keys":1107,"last_sequence":1107}
The client’s file of acknowledged keys is held on a machine that no fault affected, and is on that account an independent record of what the server acknowledged. Tests of faults follow this structure quite generally. A component outside the system under test records what was acknowledged, a fault is injected, and the record is compared with the state of the system after it has recovered. Chapters 3 and 4 test replication and elections in the same way.
The test can be run in several configurations.
| Configuration | Server | Client |
|---|---|---|
| 1 machine | kvd --listen 127.0.0.1:7001 | kvctl ... --server http://127.0.0.1:7001 |
| 2 machines on a local network | kvd --listen 0.0.0.0:7001 on machine B | kvctl ... --server http://<address of B>:7001 on machine A |
| 2 cloud instances | as above, with port 7001 permitted between the instances | as above, using the private address of the server |
The limits of a single node
| Limitation | Consequence | What chapter 1 provides against it |
|---|---|---|
| The log is on a single disk. | The loss of the disk loses all the data. | 3 replicas of every shard |
| The node is a single process. | Every request fails while the process is not running. | the election of a new leader |
Every write performs its own fsync. | The node stores 185 records per second on the disk measured. | group commit, and the distribution of writes over 24 nodes |
| The log grows without limit. | The time to restart and the disk space used increase continually. | periodic snapshots of the map, after which older records are deleted |
| All entries are held in memory. | The data cannot exceed the memory of 1 machine. | sharding |
Within these limits, the node retains every acknowledged write when its process is killed, when its log is cut short at any byte, and when a bit is corrupted in its last record. That is the assumption on which chapter 1 was built, and chapter 3 now builds replication upon it.
The complete programs
Listing 2.36 The complete
kv/src/lib.rs. kv/src/lib.rs
//! A key-value store for one node that keeps every acknowledged write across
//! a crash. A write is appended to a log on disk before it is applied in
//! memory, and the log is read back when the store is opened.
pub mod log;
pub mod record;
pub mod store;
pub use log::{Log, Recovery};
pub use record::{Change, Decoded, Record};
pub use store::{Condition, Entry, Store, StoreError, Summary, Write, Written, apply, check, page};
Listing 2.37 The complete
kv/src/record.rs. kv/src/record.rs
//! The format of one record of the log.
//!
//! A record is a frame of 8 bytes followed by a payload:
//!
//! ```text
//! length: u32 | checksum: u32 | sequence: u64 | kind: u8 | key length: u16 | key | value
//! ```
//!
//! `length` counts the bytes of the payload and `checksum` is the CRC-32 of
//! the payload. All integers are little-endian.
use crc32fast::hash;
/// A change to the store.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
Put { key: String, value: Vec<u8> },
Delete { key: String },
}
/// A change with its position in the log. Sequence numbers start at 1 and
/// grow by 1 with each record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub sequence: u64,
pub change: Change,
}
/// The result of reading one record from the front of a slice of bytes.
#[derive(Debug, PartialEq, Eq)]
pub enum Decoded {
/// A whole record, and the number of bytes it occupies.
Record { record: Record, length: usize },
/// The bytes end before the record does.
Incomplete,
/// The checksum does not match the payload, or the payload is malformed.
Corrupt,
}
/// The bytes of a frame: the length and the checksum.
pub const FRAME: usize = 8;
/// The largest payload a record may have: a key, a value of 1 MiB, and the
/// fixed fields.
pub const MAX_PAYLOAD: usize = (1 << 20) + 1024;
const PUT: u8 = 1;
const DELETE: u8 = 2;
impl Record {
/// Returns the record as the bytes that are appended to the log.
pub fn encode(&self) -> Vec<u8> {
let (kind, key, value) = match &self.change {
Change::Put { key, value } => (PUT, key, value.as_slice()),
Change::Delete { key } => (DELETE, key, &[][..]),
};
let mut payload = Vec::with_capacity(11 + key.len() + value.len());
payload.extend_from_slice(&self.sequence.to_le_bytes());
payload.push(kind);
payload.extend_from_slice(&(key.len() as u16).to_le_bytes());
payload.extend_from_slice(key.as_bytes());
payload.extend_from_slice(value);
let mut bytes = Vec::with_capacity(FRAME + payload.len());
bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
bytes.extend_from_slice(&hash(&payload).to_le_bytes());
bytes.extend_from_slice(&payload);
bytes
}
}
impl Record {
/// Reads the record at the front of `bytes`.
pub fn decode(bytes: &[u8]) -> Decoded {
let Some((length, rest)) = bytes.split_first_chunk::<4>() else {
return Decoded::Incomplete;
};
let Some((checksum, rest)) = rest.split_first_chunk::<4>() else {
return Decoded::Incomplete;
};
let length = u32::from_le_bytes(*length) as usize;
if length > MAX_PAYLOAD {
return Decoded::Corrupt;
}
let Some(payload) = rest.get(..length) else {
return Decoded::Incomplete;
};
if hash(payload) != u32::from_le_bytes(*checksum) {
return Decoded::Corrupt;
}
match parse(payload) {
Some(record) => Decoded::Record {
record,
length: FRAME + length,
},
None => Decoded::Corrupt,
}
}
}
/// Splits a payload into its fields. It returns `None` for a payload that is
/// too short for its fields or has an unknown kind.
fn parse(payload: &[u8]) -> Option<Record> {
let (sequence, rest) = payload.split_first_chunk::<8>()?;
let (&kind, rest) = rest.split_first()?;
let (key_length, rest) = rest.split_first_chunk::<2>()?;
let (key, value) = rest.split_at_checked(u16::from_le_bytes(*key_length) as usize)?;
let key = String::from_utf8(key.to_vec()).ok()?;
let change = match (kind, value) {
(PUT, value) => Change::Put {
key,
value: value.to_vec(),
},
(DELETE, []) => Change::Delete { key },
(_unknown, _value) => return None,
};
Some(Record {
sequence: u64::from_le_bytes(*sequence),
change,
})
}
Listing 2.38 The complete
kv/src/log.rs. kv/src/log.rs
//! The write-ahead log: a file that only grows, to which every change is
//! appended before it is applied.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::Path;
use crate::record::{Change, Decoded, Record};
/// The name of the log inside the data directory.
pub const FILE_NAME: &str = "kv.log";
/// An open log, positioned at its end.
pub struct Log {
file: File,
/// Whether `append` waits for the disk before it returns.
sync: bool,
/// The sequence number of the next record.
next: u64,
/// Set by a failed write. A failed log accepts no more records.
failed: bool,
}
/// What `Log::open` found in the file.
pub struct Recovery {
/// The complete records, in the order they were written.
pub records: Vec<Record>,
/// The bytes after the last complete record, which were removed.
pub discarded: u64,
}
/// Reads records from the front of `bytes` until one is incomplete or
/// corrupt. It returns the records and the number of bytes they occupy.
fn scan(bytes: &[u8]) -> (Vec<Record>, usize) {
let mut records = Vec::new();
let mut valid = 0;
while let Decoded::Record { record, length } = Record::decode(&bytes[valid..]) {
records.push(record);
valid += length;
}
(records, valid)
}
impl Log {
/// Opens the log in `directory`, creating both if they do not exist.
/// Anything after the last complete record is cut off.
pub fn open(directory: &Path, sync: bool) -> io::Result<(Log, Recovery)> {
fs::create_dir_all(directory)?;
let mut file = OpenOptions::new()
.read(true)
.append(true)
.create(true)
.open(directory.join(FILE_NAME))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let (records, valid) = scan(&bytes);
let discarded = (bytes.len() - valid) as u64;
if discarded > 0 {
file.set_len(valid as u64)?;
}
if sync {
// The file's length is part of the file. Its name is part of the directory.
file.sync_all()?;
File::open(directory)?.sync_all()?;
}
let next = records
.last()
.map_or(1, |record| record.sequence + 1);
Ok((
Log {
file,
sync,
next,
failed: false,
},
Recovery { records, discarded },
))
}
}
impl Log {
/// Appends a change and returns the record that holds it. When the log
/// was opened with `sync`, the record is on the disk when this returns.
pub fn append(&mut self, change: Change) -> io::Result<Record> {
if self.failed {
return Err(io::Error::other(
"the log failed on an earlier write and is closed",
));
}
let record = Record {
sequence: self.next,
change,
};
if let Err(error) = self.write(&record.encode()) {
self.failed = true;
return Err(error);
}
self.next += 1;
Ok(record)
}
fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
self.file.write_all(bytes)?;
if self.sync {
self.file.sync_data()?;
}
Ok(())
}
/// The sequence number of the last record, or 0 for an empty log.
pub fn last_sequence(&self) -> u64 {
self.next - 1
}
}
impl Log {
/// Appends records that another node numbered, in one write and with one
/// wait for the disk. The first record must follow the last record of
/// this log.
pub fn append_records(&mut self, records: &[Record]) -> io::Result<()> {
if self.failed {
return Err(io::Error::other(
"the log failed on an earlier write and is closed",
));
}
let misplaced = (self.next..)
.zip(records)
.find(|(expected, record)| record.sequence != *expected);
if let Some((expected, record)) = misplaced {
return Err(io::Error::other(format!(
"record {} cannot follow record {}",
record.sequence,
expected - 1
)));
}
let bytes: Vec<u8> = records
.iter()
.flat_map(Record::encode)
.collect();
if let Err(error) = self.write(&bytes) {
self.failed = true;
return Err(error);
}
self.next += records.len() as u64;
Ok(())
}
}
impl Log {
/// Removes the records after record `keep`, which ends at byte `length`
/// of the file. A follower uses this to discard records that were never
/// committed and that differ from the leader's.
pub fn truncate(&mut self, keep: u64, length: u64) -> io::Result<()> {
self.file.set_len(length)?;
if self.sync {
self.file.sync_all()?;
}
self.next = keep + 1;
Ok(())
}
}
Listing 2.39 The complete
kv/src/store.rs. kv/src/store.rs
//! The store: the current value of every key in memory, and the log that
//! rebuilds it.
use std::collections::BTreeMap;
use std::io;
use std::ops::Bound;
use std::path::Path;
use thiserror::Error;
use crate::log::Log;
use crate::record::{Change, Record};
/// The value of a key and the sequence number of the record that wrote it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub value: Vec<u8>,
pub version: u64,
}
/// What a write requires of the key's current state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Condition {
/// The write is unconditional.
Always,
/// The key must not exist.
Absent,
/// The key must exist at this version.
Version(u64),
}
/// The result of a `put`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Written {
pub version: u64,
/// Whether the key did not exist before.
pub created: bool,
}
/// One line of a listing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Summary {
pub key: String,
pub version: u64,
pub size: usize,
}
#[derive(Debug, Error)]
pub enum StoreError {
#[error("the key does not exist")]
NotFound,
#[error("the condition on the key's version does not hold")]
ConditionFailed { current: Option<u64> },
#[error("the log could not be written: {0}")]
Log(#[from] io::Error),
}
pub struct Store {
entries: BTreeMap<String, Entry>,
log: Log,
}
impl Store {
/// Opens the store in `directory` and applies every record of its log.
/// It returns the store, the number of records applied, and the number
/// of bytes that were cut from the end of the log.
pub fn open(directory: &Path, sync: bool) -> io::Result<(Store, usize, u64)> {
let (log, recovery) = Log::open(directory, sync)?;
let applied = recovery.records.len();
let mut entries = BTreeMap::new();
for record in recovery.records {
apply(&mut entries, record);
}
Ok((Store { entries, log }, applied, recovery.discarded))
}
}
/// Makes the change of one record in the map. Recovery and the write path
/// both go through this function, so they cannot disagree.
pub fn apply(entries: &mut BTreeMap<String, Entry>, record: Record) {
match record.change {
Change::Put { key, value } => {
entries.insert(
key,
Entry {
value,
version: record.sequence,
},
);
}
Change::Delete { key } => {
entries.remove(&key);
}
}
}
impl Store {
/// Sets the value of a key. The change is in the log before it is in
/// the map.
pub fn put(
&mut self,
key: String,
value: Vec<u8>,
condition: Condition,
) -> Result<Written, StoreError> {
let current = self.version(&key);
check(condition, current)?;
let record = self
.log
.append(Change::Put { key, value })?;
let version = record.sequence;
apply(&mut self.entries, record);
Ok(Written {
version,
created: current.is_none(),
})
}
/// Removes a key.
pub fn delete(&mut self, key: &str, condition: Condition) -> Result<(), StoreError> {
let current = self.version(key);
if current.is_none() {
return Err(StoreError::NotFound);
}
check(condition, current)?;
let record = self.log.append(Change::Delete {
key: key.to_owned(),
})?;
apply(&mut self.entries, record);
Ok(())
}
fn version(&self, key: &str) -> Option<u64> {
self.entries
.get(key)
.map(|entry| entry.version)
}
}
pub fn check(condition: Condition, current: Option<u64>) -> Result<(), StoreError> {
let holds = match condition {
Condition::Always => true,
Condition::Absent => current.is_none(),
Condition::Version(version) => current == Some(version),
};
holds
.then_some(())
.ok_or(StoreError::ConditionFailed { current })
}
/// One write of a batch: the change, and the condition it carries.
pub struct Write {
pub change: Change,
pub condition: Condition,
}
impl Store {
/// Performs several writes with a single wait for the disk. Each write
/// is checked as if the writes before it in the batch had already
/// happened. The result of each is returned in the same order.
pub fn commit(&mut self, writes: Vec<Write>) -> Vec<Result<Written, StoreError>> {
// The versions that keys will have once the accepted writes of this
// batch are applied. `None` is a key that a write of the batch deletes.
let mut pending: BTreeMap<String, Option<u64>> = BTreeMap::new();
let mut records = Vec::new();
let mut results = Vec::new();
let first = self.log.last_sequence() + 1;
for write in writes {
let key = match &write.change {
Change::Put { key, .. } | Change::Delete { key } => key.clone(),
};
let current = match pending.get(&key) {
Some(version) => *version,
None => self.version(&key),
};
let deletes = matches!(write.change, Change::Delete { .. });
let checked = if deletes && current.is_none() {
Err(StoreError::NotFound)
} else {
check(write.condition, current)
};
if let Err(error) = checked {
results.push(Err(error));
continue;
}
let sequence = first + records.len() as u64;
let version = if deletes { None } else { Some(sequence) };
pending.insert(key, version);
results.push(Ok(Written {
version: sequence,
created: current.is_none(),
}));
records.push(Record {
sequence,
change: write.change,
});
}
// One write and one wait for the disk, for the whole batch.
if let Err(error) = self.log.append_records(&records) {
let failure = error.to_string();
return results
.into_iter()
.map(|result| result.and(Err(StoreError::Log(io::Error::other(failure.clone())))))
.collect();
}
for record in records {
apply(&mut self.entries, record);
}
results
}
}
impl Store {
pub fn get(&self, key: &str) -> Option<&Entry> {
self.entries.get(key)
}
/// Returns up to `limit` keys that start with `prefix` and sort after
/// `after`, in order, and whether more keys follow them.
pub fn list(&self, prefix: &str, after: Option<&str>, limit: usize) -> (Vec<Summary>, bool) {
page(&self.entries, prefix, after, limit)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// The sequence number of the last record in the log.
pub fn last_sequence(&self) -> u64 {
self.log.last_sequence()
}
}
/// One page of a listing over a map of entries.
pub fn page(
entries: &BTreeMap<String, Entry>,
prefix: &str,
after: Option<&str>,
limit: usize,
) -> (Vec<Summary>, bool) {
let start = match after {
Some(key) => Bound::Excluded(key),
None => Bound::Included(prefix),
};
let mut matching = entries
.range::<str, _>((start, Bound::Unbounded))
.take_while(|entry| entry.0.starts_with(prefix))
.map(|(key, entry)| Summary {
key: key.clone(),
version: entry.version,
size: entry.value.len(),
});
let page: Vec<Summary> = matching
.by_ref()
.take(limit)
.collect();
let more = matching.next().is_some();
(page, more)
}
Listing 2.40 The complete
kvd/src/lib.rs. kvd/src/lib.rs
//! The HTTP API of a storage node.
pub mod api;
Listing 2.41 The complete
kvd/src/api.rs. kvd/src/api.rs
//! The parts of the HTTP API that do not depend on how a node stores its
//! data: the errors and their responses, and the reading of keys and
//! conditional headers.
use axum::Json;
use axum::http::header::{CONTENT_TYPE, IF_MATCH, IF_NONE_MATCH};
use axum::http::{HeaderMap, HeaderName, StatusCode};
use axum::response::{IntoResponse, Response};
use kv::{Condition, StoreError};
use serde::Serialize;
/// The largest value a key may hold.
pub const MAX_VALUE: usize = 1 << 20;
pub const MAX_KEY: usize = 256;
/// The ways a request can fail. Each one is a status code and a sentence.
pub enum ApiError {
InvalidKey,
InvalidHeader(HeaderName),
NotFound,
ConditionFailed(Option<u64>),
Unavailable(String),
/// The request reached a node that does not serve it. The text names the reason.
Misdirected(String),
/// The shard has no leader at the moment. Nothing was changed.
NoLeader,
}
/// The body of an error response, in the format of RFC 9457.
#[derive(Serialize)]
struct Problem {
#[serde(rename = "type")]
kind: &'static str,
title: &'static str,
status: u16,
#[serde(skip_serializing_if = "String::is_empty")]
detail: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, kind, title, detail) = match self {
ApiError::InvalidKey => (
StatusCode::BAD_REQUEST,
"/problems/invalid-key",
"The key is not valid",
format!("a key is 1 to {MAX_KEY} characters from A-Z, a-z, 0-9, '.', '_' and '-'"),
),
ApiError::InvalidHeader(name) => (
StatusCode::BAD_REQUEST,
"/problems/invalid-header",
"A request header is not valid",
format!("{name} must be a version in double quotes, or * where that is allowed"),
),
ApiError::NotFound => (
StatusCode::NOT_FOUND,
"/problems/key-not-found",
"The key does not exist",
String::new(),
),
ApiError::ConditionFailed(current) => (
StatusCode::PRECONDITION_FAILED,
"/problems/condition-failed",
"The condition on the key's version does not hold",
current.map_or("the key does not exist".into(), |version| {
format!("the key is at version {version}")
}),
),
ApiError::Misdirected(detail) => (
StatusCode::MISDIRECTED_REQUEST,
"/problems/not-leader",
"This node is not the leader",
detail,
),
ApiError::NoLeader => (
StatusCode::SERVICE_UNAVAILABLE,
"/problems/no-leader",
"The shard has no leader at the moment",
String::new(),
),
ApiError::Unavailable(detail) => (
StatusCode::SERVICE_UNAVAILABLE,
"/problems/store-unavailable",
"The node cannot serve the request",
detail,
),
};
let problem = Problem {
kind,
title,
status: status.as_u16(),
detail,
};
(
status,
[(CONTENT_TYPE, "application/problem+json")],
Json(problem),
)
.into_response()
}
}
impl From<StoreError> for ApiError {
fn from(error: StoreError) -> Self {
match error {
StoreError::NotFound => ApiError::NotFound,
StoreError::ConditionFailed { current } => ApiError::ConditionFailed(current),
StoreError::Log(error) => ApiError::Unavailable(error.to_string()),
}
}
}
/// A version as it appears in `ETag`, `If-Match` and `If-None-Match`.
pub fn etag(version: u64) -> String {
format!("\"{version}\"")
}
pub fn valid_key(key: &str) -> Result<(), ApiError> {
let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
let valid = (1..=MAX_KEY).contains(&key.len()) && key.chars().all(allowed);
valid
.then_some(())
.ok_or(ApiError::InvalidKey)
}
/// Reads a header that holds a quoted version or `*`.
pub fn version_header(
headers: &HeaderMap,
name: HeaderName,
) -> Result<Option<Condition>, ApiError> {
let Some(value) = headers.get(&name) else {
return Ok(None);
};
let Ok(text) = value.to_str() else {
return Err(ApiError::InvalidHeader(name));
};
if text == "*" {
return Ok(Some(Condition::Absent));
}
let version = text
.strip_prefix("\"")
.and_then(|rest| rest.strip_suffix("\""))
.and_then(|digits| digits.parse().ok());
version
.map(Condition::Version)
.map(Some)
.ok_or(ApiError::InvalidHeader(name))
}
/// The condition a write carries: `If-Match: "7"` requires version 7, and
/// `If-None-Match: *` requires that the key does not exist.
pub fn write_condition(headers: &HeaderMap) -> Result<Condition, ApiError> {
match (
version_header(headers, IF_MATCH)?,
version_header(headers, IF_NONE_MATCH)?,
) {
(Some(Condition::Version(version)), None) => Ok(Condition::Version(version)),
(None, Some(Condition::Absent)) => Ok(Condition::Absent),
(None, None) => Ok(Condition::Always),
(Some(..), ..) => Err(ApiError::InvalidHeader(IF_MATCH)),
(None, Some(..)) => Err(ApiError::InvalidHeader(IF_NONE_MATCH)),
}
}
Listing 2.42 The complete
kvd/src/main.rs. kvd/src/main.rs
//! Command kvd serves a key-value store over HTTP. Every write is in the log
//! on disk before the response is sent.
//!
//! Run it from code/ with:
//!
//! cargo run -p kvd -- --listen 0.0.0.0:7001 --data /var/tmp/kvd
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::Parser;
use kv::{Condition, Store, StoreError, Summary};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, version_header, write_condition};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio::task;
#[derive(Parser)]
struct Options {
/// The address to accept connections on.
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
/// The directory that holds the log.
#[arg(long, default_value = "data")]
data: PathBuf,
/// Respond to a write without waiting for the disk.
#[arg(long)]
no_sync: bool,
}
/// The store, shared by every request.
type Shared = Arc<Mutex<Store>>;
/// Runs a write on a thread that may block. A write waits for the disk, and
/// a thread that waits must not be one of the threads that serve requests.
async fn write<T, F>(store: Shared, work: F) -> Result<T, ApiError>
where
F: FnOnce(&mut Store) -> Result<T, StoreError> + Send + 'static,
T: Send + 'static,
{
let result = task::spawn_blocking(move || work(&mut store.lock()))
.await
.map_err(|error| ApiError::Unavailable(error.to_string()))?;
Ok(result?)
}
async fn put_key(
State(store): State<Shared>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
let written = write(store, move |store| store.put(key, body.to_vec(), condition)).await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(store): State<Shared>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
write(store, move |store| store.delete(&key, condition)).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_key(
State(store): State<Shared>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let cached = version_header(&headers, IF_NONE_MATCH)?;
let entry = store
.lock()
.get(&key)
.cloned()
.ok_or(ApiError::NotFound)?;
let tag = [(ETAG, etag(entry.version))];
if cached == Some(Condition::Version(entry.version)) {
return Ok((StatusCode::NOT_MODIFIED, tag).into_response());
}
Ok((
tag,
[(CONTENT_TYPE, "application/octet-stream")],
entry.value,
)
.into_response())
}
#[derive(Deserialize)]
struct ListQuery {
#[serde(default)]
prefix: String,
after: Option<String>,
limit: Option<usize>,
}
#[derive(Serialize)]
struct Item {
key: String,
version: u64,
size: usize,
}
#[derive(Serialize)]
struct Page {
keys: Vec<Item>,
/// The value of `after` that returns the next page, when there is one.
next_after: Option<String>,
}
async fn list_keys(State(store): State<Shared>, Query(query): Query<ListQuery>) -> Json<Page> {
let limit = query
.limit
.unwrap_or(100)
.clamp(1, 1000);
let (page, more) = store
.lock()
.list(&query.prefix, query.after.as_deref(), limit);
let next_after = page
.last()
.filter(|_last| more)
.map(|last| last.key.clone());
let keys = page
.into_iter()
.map(|Summary { key, version, size }| Item { key, version, size })
.collect();
Json(Page { keys, next_after })
}
#[derive(Serialize)]
struct Health {
status: &'static str,
keys: usize,
last_sequence: u64,
}
async fn health(State(store): State<Shared>) -> Json<Health> {
let store = store.lock();
Json(Health {
status: "ok",
keys: store.len(),
last_sequence: store.last_sequence(),
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let (store, applied, discarded) = Store::open(&options.data, !options.no_sync)?;
println!(
"recovered {} keys from {applied} records, cut {discarded} bytes from the end of the log",
store.len()
);
let routes = Router::new()
.route("/v1/keys", get(list_keys))
.route(
"/v1/keys/{key}",
get(get_key)
.put(put_key)
.delete(delete_key),
)
.route("/healthz", get(health))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(Arc::new(Mutex::new(store)));
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Listing 2.43 The complete
kvctl/src/main.rs. kvctl/src/main.rs
//! Command kvctl tests a kvd server from another process or another machine.
//! `load` writes keys and records each write the server acknowledged, and
//! `verify` reads every recorded key back.
//!
//! Run it from code/ with:
//!
//! cargo run -p kvctl -- load --server http://127.0.0.1:7001 --count 1000 --acked acked.txt
//! cargo run -p kvctl -- verify --server http://127.0.0.1:7001 --acked acked.txt
use std::error::Error;
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use ureq::Agent;
#[derive(Parser)]
struct Options {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Write keys until the count is reached or a write fails.
Load {
#[arg(long)]
server: String,
#[arg(long, default_value_t = 1000)]
count: u32,
/// The file that receives the key of every acknowledged write.
#[arg(long)]
acked: PathBuf,
},
/// Read back every key in the file of acknowledged writes.
Verify {
#[arg(long)]
server: String,
#[arg(long)]
acked: PathBuf,
},
}
/// Writes `count` keys. A key is recorded in `acked` only after the server
/// has answered with a success status.
fn load(agent: &Agent, server: &str, count: u32, acked: &PathBuf) -> Result<(), Box<dyn Error>> {
let mut file = BufWriter::new(File::create(acked)?);
let mut written = 0;
for number in 1..=count {
let key = format!("key-{number:06}");
if let Err(error) = agent
.put(format!("{server}/v1/keys/{key}"))
.send(format!("value of {key}"))
{
println!("write {number} failed: {error}");
break;
}
writeln!(file, "{key}")?;
written += 1;
}
file.flush()?;
println!("{written} writes acknowledged");
Ok(())
}
/// Reads every acknowledged key and compares the value with the one that
/// was written.
fn verify(agent: &Agent, server: &str, acked: &PathBuf) -> Result<(), Box<dyn Error>> {
let keys = fs::read_to_string(acked)?;
let (mut found, mut missing) = (0, 0);
for key in keys.lines() {
let value = match agent
.get(format!("{server}/v1/keys/{key}"))
.call()
{
Ok(mut response) => response
.body_mut()
.read_to_string()?,
Err(ureq::Error::StatusCode(404)) => String::new(),
Err(error) => return Err(error.into()),
};
if value == format!("value of {key}") {
found += 1;
} else {
missing += 1;
}
}
println!(
"{} acknowledged, {found} found, {missing} missing",
found + missing
);
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let agent = Agent::new_with_defaults();
match Options::parse().command {
Command::Load {
server,
count,
acked,
} => load(&agent, &server, count, &acked),
Command::Verify { server, acked } => verify(&agent, &server, &acked),
}
}
Listing 2.44 The complete
crashsim/src/main.rs. crashsim/src/main.rs
//! Command crashsim checks what the log recovers after a crash in the middle
//! of a write. It writes a log of 5 records, then damages a copy of it in
//! every way a crash or a bad disk can: it cuts the copy at every length,
//! and it flips 1 bit in every byte. After each, it opens the copy and
//! compares the records that come back with the records that were complete.
//!
//! Run it from code/ with:
//!
//! cargo run -p crashsim
use std::env;
use std::error::Error;
use std::fs;
use std::path::Path;
use std::process;
use kv::log::FILE_NAME;
use kv::{Change, Log, Record};
/// The changes of the test log.
fn changes() -> Vec<Change> {
let put = |key: &str, value: &str| Change::Put {
key: key.into(),
value: value.into(),
};
vec![
put("user-1", "ada"),
put("user-2", "grace"),
put("user-1", "ada lovelace"),
Change::Delete {
key: "user-2".into(),
},
put("user-3", "barbara"),
]
}
/// Writes the changes to a log in `directory`. It returns the bytes of the
/// log and the offset at which each record ends.
fn build(directory: &Path) -> Result<(Vec<u8>, Vec<usize>), Box<dyn Error>> {
let mut log = Log::open(directory, false)?.0;
let mut ends = Vec::new();
for change in changes() {
let record = log.append(change)?;
ends.push(ends.last().copied().unwrap_or(0) + record.encode().len());
}
Ok((fs::read(directory.join(FILE_NAME))?, ends))
}
/// Replaces the log in `directory` with `bytes` and opens it.
fn recover(directory: &Path, bytes: &[u8]) -> Result<Vec<Record>, Box<dyn Error>> {
fs::write(directory.join(FILE_NAME), bytes)?;
let recovery = Log::open(directory, false)?.1;
Ok(recovery.records)
}
/// Whether `records` are exactly the first `count` changes, in order.
fn is_prefix(records: &[Record], count: usize) -> bool {
let expected = changes();
records.len() == count
&& records
.iter()
.zip(&expected)
.enumerate()
.all(|(index, (record, change))| {
record.sequence == index as u64 + 1 && &record.change == change
})
}
/// Cuts the log at every length and checks that recovery returns the records
/// that end at or before the cut.
fn cuts(directory: &Path, log: &[u8], ends: &[usize]) -> Result<(), Box<dyn Error>> {
for length in 0..=log.len() {
let complete = ends
.iter()
.filter(|&&end| end <= length)
.count();
let records = recover(directory, &log[..length])?;
if !is_prefix(&records, complete) {
return Err(format!(
"cut at {length}: expected {complete} records, recovered {}",
records.len()
)
.into());
}
}
println!(
"cut at every length from 0 to {}: each recovery returned the complete records",
log.len()
);
let mut first = 0;
for (count, &end) in ends.iter().enumerate() {
println!(" lengths {first:3} to {:3}: {count} records", end - 1);
first = end;
}
println!(" length {first:3} : {} records", ends.len());
Ok(())
}
/// Flips 1 bit in each byte in turn and checks that recovery returns the
/// records before the damaged one.
fn flips(directory: &Path, log: &[u8], ends: &[usize]) -> Result<(), Box<dyn Error>> {
for position in 0..log.len() {
let mut damaged = log.to_vec();
damaged[position] ^= 0x10;
let before = ends
.iter()
.filter(|&&end| end <= position)
.count();
let records = recover(directory, &damaged)?;
if !is_prefix(&records, before) {
return Err(format!(
"flip at {position}: expected {before} records, recovered {}",
records.len()
)
.into());
}
}
println!(
"1 bit flipped in each of {} bytes: each recovery returned the records before the damage",
log.len()
);
Ok(())
}
fn run(directory: &Path) -> Result<(), Box<dyn Error>> {
let (log, ends) = build(directory)?;
let boundaries: Vec<String> = ends
.iter()
.map(usize::to_string)
.collect();
println!(
"a log of {} records and {} bytes, with records ending at {}",
ends.len(),
log.len(),
boundaries.join(", ")
);
cuts(directory, &log, &ends)?;
flips(directory, &log, &ends)?;
let records = recover(directory, &log[..ends[1] + 5])?;
let mut reopened = Log::open(directory, false)?.0;
let next = reopened.append(Change::Delete {
key: "user-1".into(),
})?;
println!(
"after a cut at {}: {} records recovered, and the next record has sequence {}",
ends[1] + 5,
records.len(),
next.sequence
);
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let directory = env::temp_dir().join(format!("crashsim-{}", process::id()));
let result = run(&directory);
fs::remove_dir_all(&directory)?;
result
}
Listing 2.45 The complete
synccost/src/main.rs. synccost/src/main.rs
//! Command synccost measures what it costs to wait for the disk. It appends
//! the same records to 2 logs, one that calls fsync after each record and
//! one that does not, and prints the time per record.
//!
//! Run it from code/ with:
//!
//! cargo run --release -p synccost -- /var/tmp/synccost
use std::env;
use std::error::Error;
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use kv::{Change, Log};
const RECORDS: u32 = 500;
/// Appends `RECORDS` records of 100 bytes and returns the time it took.
fn measure(directory: &Path, sync: bool) -> Result<Duration, Box<dyn Error>> {
let mut log = Log::open(directory, sync)?.0;
let started = Instant::now();
for number in 0..RECORDS {
log.append(Change::Put {
key: format!("key-{number:06}"),
value: vec![b'v'; 100],
})?;
}
Ok(started.elapsed())
}
fn main() -> Result<(), Box<dyn Error>> {
let root = env::args()
.nth(1)
.ok_or("usage: synccost <directory>")?;
for (name, sync) in [("without fsync", false), ("with fsync", true)] {
let directory = Path::new(&root).join(name.replace(' ', "-"));
let elapsed = measure(&directory, sync)?;
let each = elapsed.as_secs_f64() * 1000.0 / f64::from(RECORDS);
println!(
"{name:13}: {RECORDS} records in {:8.1} ms, {each:.3} ms per record, {:6.0} records per second",
elapsed.as_secs_f64() * 1000.0,
1000.0 / each
);
fs::remove_dir_all(&directory)?;
}
Ok(())
}
Further reading
- T. Pillai and others, “All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications”, OSDI 2014.
- A. Rebello and others, “Can Applications Recover from fsync Failures?”, USENIX ATC 2020.
- C. Mohan and others, “ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging”, ACM Transactions on Database Systems, 1992.
- A. Petrov, Database Internals, O’Reilly, 2019.
Summary
- A storage node keeps its entries in memory for reads and a log on disk for durability, and every write is appended to the log and confirmed by the disk before it is applied in memory.
- With a log that is only appended to, a crash can damage nothing but the last record, whereas an update in place can destroy data that was already stored.
- Every record carries its length and a CRC-32 checksum, by which a reader distinguishes a complete record from one that is incomplete or corrupt.
- Recovery decodes records until one fails, keeps all that precede it, and cuts the file off before anything new is appended.
writehands data to the kernel, andfsyncwaits until the disk has stored it. A durable append took 5.4 ms, which is 185 records per second.- The directory entry of a new file becomes durable only when the directory is synchronised.
- After a failed write or
fsync, the log accepts no further records, since a repeatedfsynccan report success for data that is not on the disk. - A write can be durable without having been acknowledged, when the node crashes between the disk’s confirmation and the response, and the client then repeats its request.
- Killing a process does not test durability under a loss of power, because the kernel keeps what the process had written.
- A test of faults records what was acknowledged outside the system under test, and compares that record with the state of the system afterwards.
Key-Value Store: Replication (Low-Level Design)
The storage node of chapter 2 keeps every acknowledged write when its process crashes. Against the loss of the machine itself, or of its disk, it offers nothing: a log on 1 disk is gone when that disk is gone. Non-functional requirement 0 of chapter 1 demands that an acknowledged write survive the loss of any 1 machine, and it follows that the record of every write has to be stored on at least 2 machines before the client is answered.
To copy a file from 1 machine to another is easy. What makes replication a subject is that the copying takes place over a network which loses messages, delays them and delivers their answers late or not at all, between machines that stop and restart at arbitrary moments, and that through all of this the copies must remain identical, in content and in order, record for record. A follower that has missed a message must not apply the next one as though nothing were missing. A leader that has lost an answer must not conclude that the follower lacks the record, nor that it has it. And a machine that was the leader an hour ago and has since been replaced must be prevented from writing as though it still were.
This chapter implements the replication that chapter 1 specified: a leader that
orders the writes of a shard, and 2 followers that store the same records in the same
order. It turns out that a single small rule on the follower’s side handles every one
of the situations listed above. We define the messages and the state of a replica,
derive the rules for followers and leaders, and implement them as a library that
performs no network operations whatever, so that a simulation can subject it to every
possible combination of lost messages. We then build kvnode, a storage node that
exchanges the messages over gRPC, and run 3 nodes while stopping followers, removing
the majority, and replacing a lost leader.
This chapter covers
- the
Appendmessage and its answer - the state of a replica: its log, its committed sequence and its term
- what a follower does with an incoming message
- the rule by which a leader decides that a record is committed
- a simulation of 4096 combinations of lost messages
kvnode: Protocol Buffers, a gRPC service, and the tasks of a leader- the path of a write and the path of a read in a leader
- experiments with 3 nodes: a stopped follower, a lost majority, a lost leader
- replacing a leader by hand, and how terms protect against a former leader
- the limitations that remain
Overview
Every shard has 3 replicas. All writes go to the leader, and the other 2 replicas are followers. Figure 3.1 shows the nodes of this chapter and the route of a write among them.
The implementation maintains 4 invariants.
- The log of every follower is a prefix of its leader’s log: the same records in the same order, and possibly fewer of them.
- A record is acknowledged to its client only after at least 2 of the 3 logs hold it.
- A replica applies a record to its entries only after the record is known to be held in at least 2 of the 3 logs.
- A replica rejects every message from a leader whose term is lower than its own.
The second invariant is non-functional requirement 0 in the form of a rule. The third ensures that a read returns only data which would survive the loss of a machine. The fourth prevents a former leader from modifying a shard after it has been replaced.
In this chapter the leader of a shard is chosen by an operator, who starts 1 node in that role. Automatic election, as chapter 1 described it, is built upon the mechanism of this chapter and is the subject of chapter 4.
The messages
The protocol of chapter 1 defines a single call, Append, which a leader sends to
each of its followers.
Listing 3.1 The replication service in the schema. proto/store.proto
// Sent by the leader of a shard to each follower.
service Replication {
// Appends records to the follower's log. A call with no records is a
// heartbeat: it tells the follower that the leader is alive.
rpc Append(AppendRequest) returns (AppendResponse);
}
message AppendRequest {
// The shard, named by the last position it owns.
fixed64 shard = 1;
// The term of the sender. A follower rejects a leader of an older term.
uint64 term = 2;
// The sequence of the record that precedes `records` in the leader's log.
uint64 previous_sequence = 3;
repeated Record records = 4;
// Every record up to this sequence is on a majority of the replicas.
uint64 committed_sequence = 5;
// Added for leader election. The term of the record at previous_sequence,
// and the id of the sender.
uint64 previous_term = 6;
uint32 leader = 7;
}
message AppendResponse {
uint64 term = 1;
// False when the follower's log does not end at `previous_sequence`.
bool appended = 2;
// The sequence of the last record in the follower's log.
uint64 last_sequence = 3;
}
The library uses types of its own for these messages, so that it has no dependence on gRPC, and the conversion between the 2 representations is performed in the node.
Listing 3.2 The imports of
repl/src/lib.rs. repl/src/lib.rs
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use kv::{Change, Condition, Entry, Log, Record, StoreError, Summary, apply, check, page};
Listing 3.3 The message and its answer, as the library uses them. repl/src/lib.rs
/// The message a leader sends to a follower.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Append {
/// The term of the sender.
pub term: u64,
/// The sequence of the record that precedes `records` in the leader's log.
pub previous_sequence: u64,
pub records: Vec<Record>,
/// Every record up to this sequence is on a majority of the replicas.
pub committed_sequence: u64,
}
/// The follower's answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Appended {
pub term: u64,
/// False when the follower's log does not end at `previous_sequence`,
/// or when the sender's term is older than the follower's.
pub appended: bool,
/// The sequence of the last record in the follower's log.
pub last_sequence: u64,
}
| Field | Purpose |
|---|---|
term | identifies the leader’s period of leadership, and a follower refuses a message whose term is lower than its own |
previous_sequence | the position in the leader’s log immediately before records, and the follower appends only if its own log ends there |
records | the records the follower lacks, in order, and empty in a heartbeat |
committed_sequence | the last record that the leader knows to be on a majority, up to which the follower may apply |
appended | whether the follower appended the records |
last_sequence | the end of the follower’s log after it has processed the message, whether it appended or refused |
The last field is the one on which the whole protocol turns. A follower never merely says yes or no. In every answer it reports where its log now ends, and this single number is enough for a leader to correct any mistaken belief it may hold about that follower.
The state of a replica
A replica consists of a log, of the entries that result from applying a part of that log, and of 3 numbers: the sequence of its last record, the point up to which records have been applied, and its term.
A record is committed when a majority of the replicas has stored it. A committed record survives the loss of any 1 machine, since any 2 of the 3 replicas include at least 1 that holds it. A replica keeps uncommitted records in its log without applying them. The entries therefore reflect committed records only, and no read can return a value that might yet be lost.
Figure 3.3 shows where each part of this state resides and which operation carries data from 1 part to another.
In the code, records is a vector that holds record n at index n - 1, so that its
length is the sequence of its last record. commit proceeds from committed to the
sequence it is given and passes each record to apply, the function of chapter 2
that inserts or removes an entry. Since commit is the only caller of apply, there
is no path by which an uncommitted record can reach the entries.
Listing 3.4 The replica, and the application of committed records. repl/src/lib.rs
pub struct Replica {
log: Log,
/// Every record of the log, in order. Record `n` is at index `n - 1`.
records: Vec<Record>,
/// The result of applying the records up to `committed`.
entries: BTreeMap<String, Entry>,
/// The sequence of the last record that is applied to `entries`.
committed: u64,
term: u64,
}
impl Replica {
/// Opens the replica in `directory`. Nothing is applied yet: a replica
/// that has restarted does not know which of its records are committed.
pub fn open(directory: &Path, sync: bool, term: u64) -> io::Result<Replica> {
let (log, recovery) = Log::open(directory, sync)?;
Ok(Replica {
log,
records: recovery.records,
entries: BTreeMap::new(),
committed: 0,
term,
})
}
pub fn term(&self) -> u64 {
self.term
}
pub fn last_sequence(&self) -> u64 {
self.records.len() as u64
}
pub fn committed(&self) -> u64 {
self.committed
}
/// One page of the committed keys.
pub fn list(&self, prefix: &str, after: Option<&str>, limit: usize) -> (Vec<Summary>, bool) {
page(&self.entries, prefix, after, limit)
}
/// Every record of the log, in order.
pub fn records(&self) -> &[Record] {
&self.records
}
/// The committed value of a key.
pub fn get(&self, key: &str) -> Option<&Entry> {
self.entries.get(key)
}
/// Applies the records up to `sequence` that are not applied yet.
pub fn commit(&mut self, sequence: u64) {
let until = sequence.min(self.last_sequence());
while self.committed < until {
apply(
&mut self.entries,
self.records[self.committed as usize].clone(),
);
self.committed += 1;
}
}
}
Replica::open sets committed to 0 and leaves the entries empty, although the log
may contain records. This may seem wasteful, and it is in fact necessary. After a
restart, a replica cannot tell from its own log which of its records had reached a
majority, since its last few records may exist on its own disk and nowhere else. A
follower learns the committed sequence from its leader, and a leader learns it from
the answers of its followers.
The follower
A follower applies the same procedure to every message it receives.
| Condition | Action | Answer |
|---|---|---|
| The term of the message is lower than the follower’s own. | none | appended = false, with the follower’s term |
previous_sequence differs from the end of the follower’s log. | adopt the term | appended = false, with the end of the follower’s log |
previous_sequence equals the end of the follower’s log. | adopt the term, append the records with 1 wait for the disk, apply records up to committed_sequence | appended = true, with the new end of the log |
handle_append is a direct transcription of that figure. Each check that fails
returns by way of a closure named refuse, which builds an answer from the follower’s
present term and the end of its log. When both checks pass, append_records writes
the whole batch with a single wait for the disk, and commit then applies whatever
the leader has declared committed. Since commit stops at the end of the log, a
committed sequence that lies beyond what this follower holds does no harm.
Listing 3.5 The follower's handling of a message. repl/src/lib.rs
impl Replica {
/// Processes a message from a leader.
pub fn handle_append(&mut self, request: Append) -> io::Result<Appended> {
let refuse = |replica: &Replica| Appended {
term: replica.term,
appended: false,
last_sequence: replica.last_sequence(),
};
if request.term < self.term {
return Ok(refuse(self));
}
self.term = request.term;
if request.previous_sequence != self.last_sequence() {
return Ok(refuse(self));
}
if !request.records.is_empty() {
self.log
.append_records(&request.records)?;
self.records.extend(request.records);
}
self.commit(request.committed_sequence);
Ok(Appended {
term: self.term,
appended: true,
last_sequence: self.last_sequence(),
})
}
}
The second row of the table is what maintains invariant 1, and it deserves to be looked at closely, since the whole reliability of replication derives from it. A follower appends records only at the exact position where the leader’s message begins. Its log can therefore never acquire a gap, a duplicate or a record out of place, whatever has happened to earlier messages. When the positions differ, the follower attempts no repair of any kind. It reports where its log ends, and the leader sends a message that begins there.
This 1 exchange covers every circumstance in which a follower is behind or the leader’s information is out of date: a follower that was stopped and has restarted, a message that was lost, an answer that was lost, and a new follower with an empty log. None of these cases has code of its own. They are all the same case, in which the leader’s belief about the end of the follower’s log is wrong and the follower’s answer puts it right.
The records of a message are appended with Log::append_records, which writes all of
them and waits for the disk once.
Listing 3.6 The imports of
kv/src/log.rs. kv/src/log.rs
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::Path;
use crate::record::{Change, Decoded, Record};
Listing 3.7 Appending a batch of records that another node has numbered. kv/src/log.rs
impl Log {
/// Appends records that another node numbered, in one write and with one
/// wait for the disk. The first record must follow the last record of
/// this log.
pub fn append_records(&mut self, records: &[Record]) -> io::Result<()> {
if self.failed {
return Err(io::Error::other(
"the log failed on an earlier write and is closed",
));
}
let misplaced = (self.next..)
.zip(records)
.find(|(expected, record)| record.sequence != *expected);
if let Some((expected, record)) = misplaced {
return Err(io::Error::other(format!(
"record {} cannot follow record {}",
record.sequence,
expected - 1
)));
}
let bytes: Vec<u8> = records
.iter()
.flat_map(Record::encode)
.collect();
if let Err(error) = self.write(&bytes) {
self.failed = true;
return Err(error);
}
self.next += records.len() as u64;
Ok(())
}
}
The leader
A leader has 3 responsibilities: to accept writes, to send each follower whatever records it lacks, and to determine which records are committed.
Proposing a record
propose checks the condition of a write against the entries and appends the record
to the leader’s own log. The record is not yet applied, since at this point only 1
replica holds it.
A leader accepts a new write only when is_settled is true, which is to say when
every record in its log is committed. The reason lies in conditional writes. A
condition is checked against the entries, and the entries do not include uncommitted
records. If the leader were to accept a second write while an earlier record was
still uncommitted, the condition of the second would be evaluated against a state
that lacked its predecessor, and a conditional write could succeed when it ought to
have failed. Requiring a settled log guarantees that every condition is evaluated
against all earlier writes. The price is that the writes of a shard are processed
strictly in sequence. Production systems evaluate conditions against a speculative
state and process writes in batches, which is considerably more intricate and is not
attempted here, although chapter 6 recovers much of the lost throughput by another
means.
Within propose, the present version of the key is read from the entries, a Delete
of a key that does not exist is rejected, and check evaluates the condition. Only
then is the record appended. On return, the record is in the leader’s log and in
records, and the entries are unchanged.
Listing 3.8 Checking the condition of a write and appending its record. repl/src/lib.rs
impl Replica {
/// Whether every record of the log is committed. A leader accepts a new
/// write only in this state, so that the condition of the write is
/// checked against the result of all earlier writes.
pub fn is_settled(&self) -> bool {
self.committed == self.last_sequence()
}
/// Checks the condition of a write and appends its record to the
/// leader's log. The record is not applied: it is not committed yet.
pub fn propose(&mut self, change: Change, condition: Condition) -> Result<u64, StoreError> {
let key = match &change {
Change::Put { key, .. } | Change::Delete { key } => key,
};
let current = self
.entries
.get(key)
.map(|entry| entry.version);
if matches!(change, Change::Delete { .. }) && current.is_none() {
return Err(StoreError::NotFound);
}
check(condition, current)?;
let record = self.log.append(change)?;
let sequence = record.sequence;
self.records.push(record);
Ok(sequence)
}
}
Building a message for a follower
For every follower, the leader remembers the last sequence that the follower
reported. append_for takes that number and returns a message containing every later
record. For a follower that is up to date, the slice of records is empty and the
message is a heartbeat. A reported sequence that lies beyond the end of the leader’s
own log is limited to that end, a situation that can arise when a leader has itself
been replaced and restarted with a shorter log.
Listing 3.9 The message that brings 1 follower up to date. repl/src/lib.rs
impl Replica {
/// The message that brings a follower whose log ends at `follower_last`
/// up to the end of this log. With nothing to send it is a heartbeat.
pub fn append_for(&self, follower_last: u64) -> Append {
let from = follower_last.min(self.last_sequence()) as usize;
Append {
term: self.term,
previous_sequence: from as u64,
records: self.records[from..].to_vec(),
committed_sequence: self.committed,
}
}
}
The commit rule
The leader knows where its own log ends and what last sequence each follower has reported. If these numbers are sorted, the one in the middle is the highest sequence that a majority has stored.
majority_sequence implements the rule for any number of replicas, by taking the
element at index n / 2 of the values sorted in descending order. For 3 replicas
that is the second value, and for 5 the third.
Listing 3.10 The commit rule. repl/src/lib.rs
/// The highest sequence that is stored on a majority, given the last
/// sequence of every replica, the leader included.
pub fn majority_sequence(mut last_sequences: Vec<u64>) -> u64 {
last_sequences.sort_unstable_by(|a, b| b.cmp(a));
last_sequences
.get(last_sequences.len() / 2)
.copied()
.unwrap_or(0)
}
Simulating lost messages
Because the library performs no network operations, a test can carry the messages between replicas itself and decide the fate of every exchange. This is the reason for writing the rules as a library in the first place. Faults in a real network occur rarely and unrepeatably, and a protocol that has only been tested on a real network has scarcely been tested against faults at all.
replsim creates a leader and 2 followers in a single process. For each exchange it
chooses 1 of 3 outcomes: the message is delivered and answered, the message is lost,
or the message is processed and its answer is lost.
Listing 3.11 The imports of
replsim/src/main.rs. replsim/src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use kv::{Change, Condition};
use repl::{Replica, majority_sequence};
Listing 3.12 A cluster in 1 process, with control over every exchange. replsim/src/main.rs
/// What happens to one exchange between the leader and a follower.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fate {
Delivered,
/// The follower does not receive the message.
RequestLost,
/// The follower processes the message, and its answer does not arrive.
AnswerLost,
}
/// A leader, 2 followers, and what the leader knows about each follower.
struct Cluster {
leader: Replica,
followers: Vec<Replica>,
/// The last sequence each follower has reported.
reported: Vec<u64>,
}
impl Cluster {
fn open(root: &Path) -> Result<Cluster, Box<dyn Error>> {
let open = |name: &str| Replica::open(&root.join(name), false, 1);
Ok(Cluster {
leader: open("leader")?,
followers: vec![open("follower-1")?, open("follower-2")?],
reported: vec![0, 0],
})
}
/// One exchange with a follower: the leader sends what the follower
/// lacks, as far as it knows, and commits what a majority now holds.
fn exchange(&mut self, follower: usize, fate: Fate) -> Result<(), Box<dyn Error>> {
let message = self
.leader
.append_for(self.reported[follower]);
if fate == Fate::RequestLost {
return Ok(());
}
let answer = self.followers[follower].handle_append(message)?;
if fate == Fate::AnswerLost {
return Ok(());
}
self.reported[follower] = answer.last_sequence;
let stored = vec![
self.leader.last_sequence(),
self.reported[0],
self.reported[1],
];
self.leader
.commit(majority_sequence(stored));
Ok(())
}
/// A write: the leader appends the record and makes one exchange with
/// each follower. It returns whether the write was acknowledged, or
/// `None` when the leader could not accept it.
fn write(
&mut self,
key: &str,
value: &str,
fates: [Fate; 2],
) -> Result<Option<bool>, Box<dyn Error>> {
if !self.leader.is_settled() {
return Ok(None);
}
let change = Change::Put {
key: key.into(),
value: value.into(),
};
let sequence = self
.leader
.propose(change, Condition::Always)?;
self.exchange(0, fates[0])?;
self.exchange(1, fates[1])?;
Ok(Some(self.leader.committed() >= sequence))
}
/// The last sequence of the leader and of each follower.
fn lasts(&self) -> [u64; 3] {
[
self.leader.last_sequence(),
self.followers[0].last_sequence(),
self.followers[1].last_sequence(),
]
}
}
The first part of the simulation runs the cases discussed above.
Listing 3.13 The cases of the chapter, run in order. replsim/src/main.rs
const BOTH: [Fate; 2] = [Fate::Delivered, Fate::Delivered];
fn cases(root: &Path) -> Result<(), Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
let show = |cluster: &Cluster| {
format!(
"logs end at {:?}, committed {}",
cluster.lasts(),
cluster.leader.committed()
)
};
for key in ["a", "b", "c"] {
cluster.write(key, "1", BOTH)?;
}
println!("3 writes, every message delivered: {}", show(&cluster));
for key in ["d", "e", "f"] {
cluster.write(key, "1", [Fate::RequestLost, Fate::Delivered])?;
}
println!(
"3 writes while follower 1 is unreachable: {}",
show(&cluster)
);
cluster.exchange(0, Fate::Delivered)?;
println!("follower 1 returns, 1 exchange: {}", show(&cluster));
for key in ["g", "h", "i"] {
cluster.write(key, "1", [Fate::AnswerLost, Fate::Delivered])?;
}
println!(
"3 writes while the answers of follower 1 are lost: {}",
show(&cluster)
);
println!(
" the leader believes that the log of follower 1 ends at {}",
cluster.reported[0]
);
cluster.exchange(0, Fate::Delivered)?;
println!(
" next exchange: the leader sends records 7 to 9, follower 1 refuses and reports {}",
cluster.reported[0]
);
cluster.exchange(0, Fate::Delivered)?;
println!(" the exchange after it: {}", show(&cluster));
let acknowledged =
cluster.write("j", "1", [Fate::RequestLost, Fate::RequestLost])? == Some(true);
println!(
"1 write while both followers are unreachable: acknowledged {acknowledged}, {}",
show(&cluster)
);
println!(
" the leader reads j as {:?}",
cluster
.leader
.get("j")
.map(|entry| entry.version)
);
let accepted = cluster
.write("k", "1", [Fate::RequestLost, Fate::RequestLost])?
.is_some();
println!(" the leader accepts another write: {accepted}");
cluster.exchange(1, Fate::Delivered)?;
println!("follower 2 returns: {}", show(&cluster));
println!(
" the leader reads j as {:?}",
cluster
.leader
.get("j")
.map(|entry| entry.version)
);
let mut newer = Replica::open(&root.join("newer"), false, 2)?;
let answer = newer.handle_append(cluster.leader.append_for(0))?;
println!(
"a replica at term 2 receives a message of term 1: appended {}, its term {}",
answer.appended, answer.term
);
Ok(())
}
The second part runs 3 writes under every combination of outcomes. Each write involves
2 exchanges, and each exchange is assigned 2 bits of a pattern, which gives 4096
patterns. After each pattern, replsim verifies invariants 1 and 2, then delivers all
messages and verifies that the 3 logs become equal.
Listing 3.14 Every combination of lost messages over 3 writes. replsim/src/main.rs
const WRITES: usize = 3;
/// The fate of exchange number `index` under a pattern: 2 bits for each.
fn fate(pattern: u32, index: usize) -> Fate {
match (pattern >> (2 * index)) & 3 {
0 | 1 => Fate::Delivered,
2 => Fate::RequestLost,
3.. => Fate::AnswerLost,
}
}
/// Runs 3 writes under one pattern of losses and checks the replicas. It
/// returns the number of writes that were acknowledged.
fn run(root: &Path, pattern: u32) -> Result<usize, Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
let mut acknowledged = Vec::new();
for number in 0..WRITES {
let fates = [fate(pattern, 2 * number), fate(pattern, 2 * number + 1)];
if cluster.write(&format!("key-{number}"), "1", fates)? == Some(true) {
acknowledged.push(cluster.leader.last_sequence());
}
}
for &sequence in &acknowledged {
let holders = cluster
.lasts()
.iter()
.filter(|&&last| last >= sequence)
.count();
if holders < 2 {
return Err(format!(
"pattern {pattern}: record {sequence} was acknowledged and is in {holders} log"
)
.into());
}
}
for follower in &cluster.followers {
if !cluster
.leader
.records()
.starts_with(follower.records())
{
return Err(format!(
"pattern {pattern}: a follower's log is not the beginning of the leader's"
)
.into());
}
}
for _ in 0..2 {
cluster.exchange(0, Fate::Delivered)?;
cluster.exchange(1, Fate::Delivered)?;
}
let [leader, first, second] = cluster.lasts();
if first != leader || second != leader || !cluster.leader.is_settled() {
return Err(format!(
"pattern {pattern}: the logs differ after every message was delivered"
)
.into());
}
Ok(acknowledged.len())
}
fn exhaustive(root: &Path) -> Result<(), Box<dyn Error>> {
let patterns = 1u32 << (4 * WRITES);
let mut by_acknowledged = [0; WRITES + 1];
for pattern in 0..patterns {
let directory = root.join(format!("pattern-{pattern}"));
by_acknowledged[run(&directory, pattern)?] += 1;
fs::remove_dir_all(&directory)?;
}
println!("{patterns} patterns of lost messages over {WRITES} writes:");
println!(" every acknowledged record was in at least 2 of the 3 logs");
println!(" every follower's log was the beginning of the leader's log");
println!(" after the losses stopped, the 3 logs were equal and every record was committed");
for (count, patterns) in by_acknowledged.iter().enumerate() {
println!(" {count} of {WRITES} writes acknowledged: {patterns} patterns");
}
Ok(())
}
Listing 3.15 Running the simulation. replsim/src/main.rs
fn directory() -> PathBuf {
env::temp_dir().join(format!("replsim-{}", process::id()))
}
fn main() -> Result<(), Box<dyn Error>> {
let root = directory();
let result = cases(&root.join("cases")).and_then(|()| exhaustive(&root));
fs::remove_dir_all(&root)?;
result
}
$ cargo run --release -p replsim
3 writes, every message delivered: logs end at [3, 3, 3], committed 3
3 writes while follower 1 is unreachable: logs end at [6, 3, 6], committed 6
follower 1 returns, 1 exchange: logs end at [6, 6, 6], committed 6
3 writes while the answers of follower 1 are lost: logs end at [9, 7, 9], committed 9
the leader believes that the log of follower 1 ends at 6
next exchange: the leader sends records 7 to 9, follower 1 refuses and reports 7
the exchange after it: logs end at [9, 9, 9], committed 9
1 write while both followers are unreachable: acknowledged false, logs end at [10, 9, 9], committed 9
the leader reads j as None
the leader accepts another write: false
follower 2 returns: logs end at [10, 9, 10], committed 10
the leader reads j as Some(10)
a replica at term 2 receives a message of term 1: appended false, its term 2
4096 patterns of lost messages over 3 writes:
every acknowledged record was in at least 2 of the 3 logs
every follower's log was the beginning of the leader's log
after the losses stopped, the 3 logs were equal and every record was committed
0 of 3 writes acknowledged: 1024 patterns
1 of 3 writes acknowledged: 1024 patterns
2 of 3 writes acknowledged: 704 patterns
3 of 3 writes acknowledged: 1344 patterns
| Output | What it shows |
|---|---|
| 3 writes while follower 1 is unreachable: logs end at [6, 3, 6], committed 6 | Writes are committed with 1 follower, as invariant 2 allows. |
| follower 1 returns, 1 exchange: logs end at [6, 6, 6] | A single message brings the follower up to date, because the leader knew where its log ended. |
| answers of follower 1 are lost: the leader believes its log ends at 6 | The follower stored record 7 and the leader was not told. |
| leader sends records 7 to 9, follower 1 refuses and reports 7 | The follower detects that the message does not begin at the end of its log, and the leader continues from the position reported. |
| 1 write while both followers are unreachable: acknowledged false, committed 9 | A record in 1 log is not acknowledged and is not visible to reads. |
| leader accepts another write: false | The leader accepts no write while a record is uncommitted. |
| follower 2 returns: committed 10, leader reads j as Some(10) | The uncommitted record is committed as soon as a second replica stores it, although its client had received an error. |
| a replica at term 2 receives a message of term 1: appended false | Invariant 4 holds. |
| 4096 patterns: every acknowledged record was in at least 2 of 3 logs | Invariant 2 holds for every combination of lost messages and lost answers. |
The seventh row shows something that every client of a replicated store has to take into account. A write that was answered with an error may nevertheless take effect later. The record was in the leader’s log, and it was committed when a follower became reachable again. For a client, therefore, an error from a leader signifies an unknown outcome, exactly as a lost connection does, and is to be resolved by reading the key or by repeating an idempotent request. An error does not mean that nothing happened.
Design overview
Low-level design: replication
Crates
| Crate | Kind | Contents |
|---|---|---|
repl | library | the messages, Replica, the follower’s rule, the leader’s operations, the commit rule |
replsim | program | the simulation of lost messages |
kvnode | program | a storage node that replicates over gRPC: rpc (the service), node (the leader’s tasks, writes and reads), main (the HTTP API) |
State of a node
| Field | Type | Purpose |
|---|---|---|
replica | Mutex<Replica> | the log, the entries, the committed sequence and the term |
peers | Vec<Peer> | for a leader, the address of each follower, the last sequence it reported, and the time of its last answer |
turn | tokio::sync::Mutex<()> | makes writes take turns |
committed | watch::Sender<u64> | informs waiting writes when the committed sequence advances |
appended | Notify | wakes the follower tasks when the leader has appended a record |
deposed | AtomicBool | set when a follower answers with a higher term |
Timing
| Constant | Value | Meaning |
|---|---|---|
HEARTBEAT | 100 ms | the interval between messages to a follower when there are no new records |
ANSWER | 500 ms | the time the leader waits for the answer to 1 message |
COMMIT | 1 s | the time a write waits for a majority, and also the period within which a majority must have answered for the leader to serve reads |
Responses of a node
| Situation | Status |
|---|---|
| A write or a read is sent to a follower | 421 Misdirected Request |
| A write is sent to a leader that has learned of a higher term | 421 Misdirected Request |
| A record does not reach a majority within 1 second | 503 Service Unavailable |
| A read is sent to a leader that no majority has answered within 1 second | 503 Service Unavailable |
The node
kvnode combines a replica with a gRPC service for replication and the HTTP API of
chapter 1.
Listing 3.16 The imports of
kvnode/src/main.rs. kvnode/src/main.rs
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::{Parser, ValueEnum};
use kv::{Change, Condition};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, version_header, write_condition};
use repl::Replica;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tonic::transport::Server;
use crate::node::Node;
use crate::rpc::{Follower, ReplicationServer};
Listing 3.17 The options of the command line. kvnode/src/main.rs
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Role {
Leader,
Follower,
}
#[derive(Parser)]
struct Options {
#[arg(long, value_enum)]
role: Role,
/// The address of the HTTP API.
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
/// The address on which a follower accepts records from the leader.
#[arg(long, default_value = "127.0.0.1:7101")]
replication: SocketAddr,
/// The replication address of a follower, such as http://127.0.0.1:7102.
/// Given once for each follower, to the leader.
#[arg(long = "follower")]
followers: Vec<String>,
/// The term this node starts in. A new leader is given a higher term
/// than the leader it replaces.
#[arg(long, default_value_t = 1)]
term: u64,
#[arg(long, default_value = "data")]
data: PathBuf,
/// Respond without waiting for the disk.
#[arg(long)]
no_sync: bool,
}
The gRPC service
The Rust types, the client code and the server code for Append are generated from
the schema when the program is built. Building requires protoc, the compiler for
Protocol Buffers, which is available as the package protobuf-compiler on Debian and
Ubuntu.
Listing 3.18 The complete
kvnode/build.rs. kvnode/build.rs
//! Generates the Rust types and the gRPC client and server from the schema.
//! It needs the Protocol Buffers compiler, `protoc`, on the PATH.
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::compile_protos("../proto/store.proto")?;
Ok(())
}
Listing 3.19 The imports of
kvnode/src/rpc.rs. kvnode/src/rpc.rs
use std::sync::Arc;
use kv::{Change, Record};
use repl::{Append, Appended};
use tokio::task;
use tonic::{Request, Response, Status};
use crate::node::Node;
Listing 3.20 Including the generated code. kvnode/src/rpc.rs
/// The types that `build.rs` generates from `proto/store.proto`. The schema
/// also defines the ring, which this program does not use.
#[allow(dead_code)]
pub mod proto {
tonic::include_proto!("store.v1");
}
pub use proto::replication_client::ReplicationClient;
pub use proto::replication_server::{Replication, ReplicationServer};
The generated types are converted to the types of kv and repl at the boundary of
the service. A Record message that has no change field is rejected with the gRPC
status InvalidArgument.
Listing 3.21 Conversion between the generated types and the library's types. kvnode/src/rpc.rs
impl From<&Record> for proto::Record {
fn from(record: &Record) -> Self {
let change = match record.change.clone() {
Change::Put { key, value } => proto::record::Change::Put(proto::Put { key, value }),
Change::Delete { key } => proto::record::Change::Delete(proto::Delete { key }),
};
proto::Record {
sequence: record.sequence,
change: Some(change),
}
}
}
impl TryFrom<proto::Record> for Record {
type Error = Status;
fn try_from(record: proto::Record) -> Result<Self, Status> {
let change = match record.change {
Some(proto::record::Change::Put(put)) => Change::Put {
key: put.key,
value: put.value,
},
Some(proto::record::Change::Delete(delete)) => Change::Delete { key: delete.key },
None => return Err(Status::invalid_argument("a record without a change")),
};
Ok(Record {
sequence: record.sequence,
change,
})
}
}
impl From<Append> for proto::AppendRequest {
fn from(message: Append) -> Self {
proto::AppendRequest {
shard: 0,
term: message.term,
previous_sequence: message.previous_sequence,
records: message
.records
.iter()
.map(proto::Record::from)
.collect(),
committed_sequence: message.committed_sequence,
// The fields that leader election added are not used by this program.
previous_term: 0,
leader: 0,
}
}
}
impl From<proto::AppendResponse> for Appended {
fn from(answer: proto::AppendResponse) -> Self {
Appended {
term: answer.term,
appended: answer.appended,
last_sequence: answer.last_sequence,
}
}
}
Followers run the server side of the service. The handler passes each message to
Replica::handle_append. Since that method waits for the disk, the call is wrapped in
block_in_place, which informs the asynchronous runtime that the current thread is
about to block.
Listing 3.22 The server side of
Append. kvnode/src/rpc.rs
/// The server side of `Append`, which a follower runs.
pub struct Follower {
pub node: Arc<Node>,
}
#[tonic::async_trait]
impl Replication for Follower {
async fn append(
&self,
request: Request<proto::AppendRequest>,
) -> Result<Response<proto::AppendResponse>, Status> {
let request = request.into_inner();
let records = request
.records
.into_iter()
.map(Record::try_from)
.collect::<Result<Vec<_>, _>>()?;
let message = Append {
term: request.term,
previous_sequence: request.previous_sequence,
records,
committed_sequence: request.committed_sequence,
};
// Appending waits for the disk, so it runs where blocking is allowed.
let answer = task::block_in_place(|| {
self.node
.replica
.lock()
.handle_append(message)
})
.map_err(|error| Status::unavailable(error.to_string()))?;
Ok(Response::new(proto::AppendResponse {
term: answer.term,
appended: answer.appended,
last_sequence: answer.last_sequence,
}))
}
}
The leader’s tasks
Figure 3.7 shows the parts of a leader and the order in which a write passes through them. Each box corresponds to a function or a field in the listings that follow.
Listing 3.23 The imports of
kvnode/src/node.rs. kvnode/src/node.rs
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use kv::{Change, Condition, Entry, Summary, Written};
use kvd::api::ApiError;
use parking_lot::Mutex;
use repl::{Appended, Replica, majority_sequence};
use tokio::sync::{Notify, watch};
use tokio::task;
use tokio::time::{sleep, timeout};
use tonic::transport::{Channel, Endpoint};
use crate::rpc::{ReplicationClient, proto};
Listing 3.24 The state of a node. kvnode/src/node.rs
/// How often a leader sends a message to a follower that has nothing new to receive.
const HEARTBEAT: Duration = Duration::from_millis(100);
/// How long a leader waits for the answer to one message.
const ANSWER: Duration = Duration::from_millis(500);
/// How long a write waits for its record to reach a majority.
const COMMIT: Duration = Duration::from_secs(1);
/// What the leader knows about one follower.
pub struct Peer {
pub address: String,
/// The last sequence the follower has reported.
reported: AtomicU64,
/// When the follower last answered.
heard: Mutex<Option<Instant>>,
}
pub struct Node {
pub replica: Mutex<Replica>,
pub leader: bool,
pub peers: Vec<Peer>,
/// The sequence the log ended at when the node started. A leader serves
/// reads once this much is committed.
ready: u64,
/// Held by the write that is in progress, so that writes take turns.
turn: tokio::sync::Mutex<()>,
/// The committed sequence, for writes that wait for it to advance.
committed: watch::Sender<u64>,
/// Signalled when the leader has appended a record.
appended: Notify,
/// Set when a follower answers with a newer term: another leader exists.
deposed: AtomicBool,
}
impl Node {
pub fn new(replica: Replica, leader: bool, followers: Vec<String>) -> Arc<Node> {
let peer = |address| Peer {
address,
reported: AtomicU64::new(0),
heard: Mutex::new(None),
};
Arc::new(Node {
ready: replica.last_sequence(),
replica: Mutex::new(replica),
leader,
peers: followers
.into_iter()
.map(peer)
.collect(),
turn: tokio::sync::Mutex::new(()),
committed: watch::Sender::new(0),
appended: Notify::new(),
deposed: AtomicBool::new(false),
})
}
}
A leader starts 1 task for each follower. The task repeats a single exchange: it builds a message that begins where the follower’s log is believed to end, sends it, and processes the answer. When the follower is up to date, the task waits until the leader appends a record or until the heartbeat interval has elapsed. When the answer shows that records are still missing, the task continues at once.
follow runs for the lifetime of the node, and its loop has 3 phases. It first locks
the replica for no longer than it takes to build a message. It then releases the lock and
waits for the answer, for at most 500 ms. Finally it passes the answer to record. If
record reports that the follower is still behind, the loop continues immediately.
Otherwise the task sleeps until appended is signalled or the heartbeat interval
elapses, whichever occurs first.
Listing 3.25 The task that keeps a follower up to date. kvnode/src/node.rs
impl Node {
/// Starts one task for each follower. A task sends the follower what it
/// lacks, again and again, for as long as the node runs.
pub fn lead(self: &Arc<Node>) -> Result<(), tonic::transport::Error> {
for index in 0..self.peers.len() {
let channel = Endpoint::from_shared(self.peers[index].address.clone())?
.connect_timeout(ANSWER)
.connect_lazy();
tokio::spawn(Arc::clone(self).follow(index, ReplicationClient::new(channel)));
}
Ok(())
}
async fn follow(self: Arc<Node>, index: usize, mut client: ReplicationClient<Channel>) {
let peer = &self.peers[index];
loop {
let message = self.replica.lock().append_for(
peer.reported
.load(Ordering::Relaxed),
);
let request = proto::AppendRequest::from(message);
let answer = timeout(ANSWER, client.append(request)).await;
// An answer that did not come counts as a peer that is up to date:
// the next round is a heartbeat, which finds out more.
let behind = answer
.ok()
.and_then(Result::ok)
.is_some_and(|answer| self.record(peer, answer.into_inner().into()));
if !behind {
// Wait for a new record, or send a heartbeat when none comes.
tokio::select! {
() = self.appended.notified() => {}
() = sleep(HEARTBEAT) => {}
}
}
}
}
}
A point of design here has a large effect on how the node behaves under faults. No task holds the lock of the replica while it waits for the network. It takes the lock to build a message and again to process an answer, and releases it in between. An unreachable follower therefore delays its own task and nothing else, neither the task of the other follower nor any request from a client. Had the lock been held across the network call, a single slow follower would have stopped the whole shard, which would have defeated the purpose of waiting only for a majority.
record is called with every answer. It first compares the term of the answer with
the leader’s own. A higher term shows that a newer leader exists, and the node ceases
to accept writes. Otherwise it stores the sequence reported, applies the commit rule
to the 3 last sequences, and publishes the new committed sequence through a watch
channel, where the waiting writes observe it.
Listing 3.26 Processing an answer, and committing. kvnode/src/node.rs
impl Node {
/// Takes note of a follower's answer and commits what a majority now
/// holds. It returns whether the follower still lacks records.
fn record(&self, peer: &Peer, answer: Appended) -> bool {
let mut replica = self.replica.lock();
if answer.term > replica.term() {
self.deposed
.store(true, Ordering::Relaxed);
return false;
}
peer.reported
.store(answer.last_sequence, Ordering::Relaxed);
*peer.heard.lock() = Some(Instant::now());
let mut stored: Vec<u64> = self
.peers
.iter()
.map(|peer| {
peer.reported
.load(Ordering::Relaxed)
})
.collect();
stored.push(replica.last_sequence());
replica.commit(majority_sequence(stored));
self.committed
.send_replace(replica.committed());
answer.last_sequence < replica.last_sequence()
}
}
Writes
Listing 3.27 The write path of the leader. kvnode/src/node.rs
impl Node {
/// A write on the leader: wait for a turn, append the record, and wait
/// until a majority of the replicas hold it.
pub async fn write(&self, change: Change, condition: Condition) -> Result<Written, ApiError> {
self.must_lead()?;
let turn = self.turn.lock().await;
let last = self.replica.lock().last_sequence();
self.wait_for(last).await?;
let proposed = task::block_in_place(|| {
let mut replica = self.replica.lock();
let created = match &change {
Change::Put { key, .. } | Change::Delete { key } => replica.get(key).is_none(),
};
replica
.propose(change, condition)
.map(|version| Written { version, created })
})?;
self.appended.notify_waiters();
self.wait_for(proposed.version)
.await?;
drop(turn);
Ok(proposed)
}
/// Waits until the record with this sequence is committed.
async fn wait_for(&self, sequence: u64) -> Result<(), ApiError> {
let mut committed = self.committed.subscribe();
let reached = committed.wait_for(|&committed| committed >= sequence);
let in_time = timeout(COMMIT, reached)
.await
.is_ok_and(|reached| reached.is_ok());
if in_time {
Ok(())
} else {
Err(ApiError::Unavailable(format!(
"record {sequence} is not on a majority of the replicas"
)))
}
}
fn must_lead(&self) -> Result<(), ApiError> {
if !self.leader {
return Err(ApiError::Misdirected("this node is a follower".into()));
}
if self.deposed.load(Ordering::Relaxed) {
return Err(ApiError::Misdirected(
"a leader of a newer term exists".into(),
));
}
Ok(())
}
}
The handlers send no messages themselves. A handler appends a record, wakes both
follower tasks, and waits for the committed sequence to reach the sequence of its
record. A watch channel holds the most recent committed sequence, and wait_for
returns as soon as that value satisfies its condition, including the case in which
it already does.
If no majority stores the record within 1 second, the handler answers 503. The
record remains in the leader’s log. The follower tasks go on sending it, and it is
committed when a follower becomes reachable, as the simulation showed.
Reads
Listing 3.28 The read path of the leader. kvnode/src/node.rs
impl Node {
/// A leader serves reads while a majority of the replicas, itself
/// included, has answered within the last second, and once everything
/// its log held at start is committed.
fn must_serve_reads(&self) -> Result<(), ApiError> {
self.must_lead()?;
let recently = |peer: &&Peer| {
peer.heard
.lock()
.is_some_and(|at| at.elapsed() < COMMIT)
};
let reachable = 1 + self
.peers
.iter()
.filter(recently)
.count();
if 2 * reachable <= self.peers.len() + 1 {
return Err(ApiError::Unavailable(
"this leader cannot reach a majority of the replicas".into(),
));
}
if self.replica.lock().committed() < self.ready {
return Err(ApiError::Unavailable(
"the records from before the restart are not committed yet".into(),
));
}
Ok(())
}
pub fn get(&self, key: &str) -> Result<Entry, ApiError> {
self.must_serve_reads()?;
self.replica
.lock()
.get(key)
.cloned()
.ok_or(ApiError::NotFound)
}
pub fn list(
&self,
prefix: &str,
after: Option<&str>,
limit: usize,
) -> Result<(Vec<Summary>, bool), ApiError> {
self.must_serve_reads()?;
Ok(self
.replica
.lock()
.list(prefix, after, limit))
}
/// The last sequence each follower has reported.
pub fn reported(&self) -> Vec<u64> {
self.peers
.iter()
.map(|peer| {
peer.reported
.load(Ordering::Relaxed)
})
.collect()
}
}
A leader that has been separated from its followers by a network partition cannot
know whether another leader has been appointed in the meantime, and its entries may
therefore be out of date. must_serve_reads accordingly requires that a majority of
the replicas, counting the leader itself, has answered within the last second. The
check limits to 1 second the time for which a former leader could return outdated
data, on the condition that no new leader is appointed sooner than that after the old
one becomes unreachable. Stricter implementations confirm their leadership with a
majority for every read, at the cost of a network round trip for each.
A second check concerns a leader that has been restarted. Its log may contain
committed records that have not yet been applied, since committed begins at 0. Such
a leader serves reads once every record that its log held at startup has been
committed again.
The HTTP API
The handlers reuse the error type and the header parsing of chapter 2, from
kvd::api. They differ from the handlers of chapter 2 in passing writes to
Node::write and reads to Node::get.
Listing 3.29 The handlers for
PUT, DELETE and GET. kvnode/src/main.rs
async fn put_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
let written = node
.write(
Change::Put {
key,
value: body.to_vec(),
},
condition,
)
.await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
node.write(Change::Delete { key }, condition)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let cached = version_header(&headers, IF_NONE_MATCH)?;
let entry = node.get(&key)?;
let tag = [(ETAG, etag(entry.version))];
if cached == Some(Condition::Version(entry.version)) {
return Ok((StatusCode::NOT_MODIFIED, tag).into_response());
}
Ok((
tag,
[(CONTENT_TYPE, "application/octet-stream")],
entry.value,
)
.into_response())
}
Listing 3.30 Listing keys, and the health of the node. kvnode/src/main.rs
#[derive(Deserialize)]
struct ListQuery {
#[serde(default)]
prefix: String,
after: Option<String>,
limit: Option<usize>,
}
#[derive(Serialize)]
struct Item {
key: String,
version: u64,
size: usize,
}
#[derive(Serialize)]
struct Page {
keys: Vec<Item>,
next_after: Option<String>,
}
async fn list_keys(
State(node): State<Arc<Node>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Page>, ApiError> {
let limit = query
.limit
.unwrap_or(100)
.clamp(1, 1000);
let (page, more) = node.list(&query.prefix, query.after.as_deref(), limit)?;
let next_after = if more {
page.last()
.map(|last| last.key.clone())
} else {
None
};
let keys = page
.into_iter()
.map(|item| Item {
key: item.key,
version: item.version,
size: item.size,
})
.collect();
Ok(Json(Page { keys, next_after }))
}
#[derive(Serialize)]
struct Health {
role: &'static str,
term: u64,
last_sequence: u64,
committed: u64,
/// The last sequence each follower has reported to this leader.
followers: Vec<u64>,
}
async fn health(State(node): State<Arc<Node>>) -> Json<Health> {
let replica = node.replica.lock();
Json(Health {
role: if node.leader { "leader" } else { "follower" },
term: replica.term(),
last_sequence: replica.last_sequence(),
committed: replica.committed(),
followers: node.reported(),
})
}
Listing 3.31 Starting a leader or a follower. kvnode/src/main.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let replica = Replica::open(&options.data, !options.no_sync, options.term)?;
println!(
"term {}, log ends at {}",
replica.term(),
replica.last_sequence()
);
let node = Node::new(replica, options.role == Role::Leader, options.followers);
match options.role {
Role::Leader => node.lead()?,
Role::Follower => {
let service = ReplicationServer::new(Follower {
node: Arc::clone(&node),
});
tokio::spawn(
Server::builder()
.add_service(service)
.serve(options.replication),
);
println!("accepting records on {}", options.replication);
}
}
let routes = Router::new()
.route("/v1/keys", get(list_keys))
.route(
"/v1/keys/{key}",
get(get_key)
.put(put_key)
.delete(delete_key),
)
.route("/healthz", get(health))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(node);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Running 3 nodes
The followers are started first, each with an address for HTTP and another for replication. The leader is then started with the replication addresses of both followers.
$ ./target/release/kvnode --role follower --listen 127.0.0.1:7002 --replication 127.0.0.1:7102 --data /var/tmp/n2 &
term 1, log ends at 0
accepting records on 127.0.0.1:7102
listening on 127.0.0.1:7002
$ ./target/release/kvnode --role follower --listen 127.0.0.1:7003 --replication 127.0.0.1:7103 --data /var/tmp/n3 &
$ ./target/release/kvnode --role leader --listen 127.0.0.1:7001 --data /var/tmp/n1 \
--follower http://127.0.0.1:7102 --follower http://127.0.0.1:7103 &
term 1, log ends at 0
listening on 127.0.0.1:7001
A write to the leader is stored by all 3 nodes, and a request to a follower is refused with the status that chapter 1 specifies for a request sent to the wrong node.
$ curl -i -X PUT http://127.0.0.1:7001/v1/keys/user-1 --data-binary 'ada'
HTTP/1.1 201 Created
etag: "1"
content-length: 0
$ curl -i -X PUT http://127.0.0.1:7002/v1/keys/user-1 --data-binary 'x'
HTTP/1.1 421 Misdirected Request
content-type: application/problem+json
content-length: 117
{"type":"/problems/not-leader","title":"This node is not the leader","status":421,"detail":"this node is a follower"}
$ for port in 7001 7002 7003; do curl http://127.0.0.1:$port/healthz; echo; done
{"role":"leader","term":1,"last_sequence":1,"committed":1,"followers":[1,1]}
{"role":"follower","term":1,"last_sequence":1,"committed":1,"followers":[]}
{"role":"follower","term":1,"last_sequence":1,"committed":1,"followers":[]}
$ cmp /var/tmp/n1/kv.log /var/tmp/n2/kv.log && cmp /var/tmp/n1/kv.log /var/tmp/n3/kv.log && echo identical
identical
The health response of the leader lists the last sequence reported by each follower. The 3 log files are identical byte for byte, which is invariant 1 in the strongest form it could take.
Experiments
All the nodes in these experiments run on 1 machine and share its disk. On separate
machines, each node has a disk of its own and the nodes do not compete for it.
Nothing else changes: each node is started with the addresses of its peers in place
of 127.0.0.1.
The cost of replication
$ time ./target/release/kvctl load --server http://127.0.0.1:7001 --count 300 --acked acked.txt
300 writes acknowledged
3.89 s, 77 writes per second
The replicated shard completes 77 writes per second, against 185 for the single node of chapter 2 on the same disk. Each write now waits for 2 disks in succession, the leader’s and then a follower’s, and for a network round trip. Chapter 1 allowed for this in its capacity estimate by counting 3 stored records for every write.
A follower stops
A follower is killed while a load is running.
$ ./target/release/kvctl load --server http://127.0.0.1:7001 --count 400 --acked acked.txt &
$ kill -9 <pid of the node on port 7002>
$ curl http://127.0.0.1:7001/healthz
{"role":"leader","term":1,"last_sequence":435,"committed":434,"followers":[417,434]}
400 writes acknowledged
$ curl http://127.0.0.1:7001/healthz
{"role":"leader","term":1,"last_sequence":700,"committed":700,"followers":[417,700]}
$ ./target/release/kvnode --role follower --listen 127.0.0.1:7002 --replication 127.0.0.1:7102 --data /var/tmp/n2 &
term 1, log ends at 417
$ curl http://127.0.0.1:7001/healthz
{"role":"leader","term":1,"last_sequence":700,"committed":700,"followers":[700,700]}
$ cmp /var/tmp/n1/kv.log /var/tmp/n2/kv.log && echo identical
identical
| Output | What it shows |
|---|---|
| 400 writes acknowledged | Writes continued without interruption after the follower stopped. The leader and the remaining follower form a majority. |
"followers":[417,700] | The leader retains the last position that the stopped follower reported. |
| term 1, log ends at 417 | After its restart, the follower’s log contains the records it had stored before it was killed. |
"followers":[700,700], identical | The follower task sent the 283 missing records, and the logs are equal again. |
Apart from restarting the process, no action by an operator was required.
The majority is lost
Both followers are killed.
$ kill -9 <pid of the node on port 7002> <pid of the node on port 7003>
$ curl -o /dev/null -w '%{http_code} in %{time_total} s\n' -X PUT http://127.0.0.1:7001/v1/keys/alone --data-binary 'x'
503 in 1.011346 s
$ curl -X PUT http://127.0.0.1:7001/v1/keys/alone2 --data-binary 'x'
{"type":"/problems/store-unavailable","title":"The node cannot serve the request","status":503,"detail":"record 701 is not on a majority of the replicas"}
$ curl http://127.0.0.1:7001/v1/keys/key-000001
{"type":"/problems/store-unavailable","title":"The node cannot serve the request","status":503,"detail":"this leader cannot reach a majority of the replicas"}
$ curl http://127.0.0.1:7001/healthz
{"role":"leader","term":1,"last_sequence":701,"committed":700,"followers":[700,700]}
$ ./target/release/kvnode --role follower --listen 127.0.0.1:7003 --replication 127.0.0.1:7103 --data /var/tmp/n3 &
$ curl http://127.0.0.1:7001/healthz
{"role":"leader","term":1,"last_sequence":701,"committed":701,"followers":[700,701]}
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7001/v1/keys/alone
200
$ curl -o /dev/null -w '%{http_code}\n' -X PUT http://127.0.0.1:7001/v1/keys/after --data-binary 'x'
201
| Output | What it shows |
|---|---|
| 503 in 1.01 s | The leader appended record 701, could not store it on a second replica, and reported failure after the commit timeout. |
| record 701 is not on a majority | The second write was not accepted, because record 701 was still uncommitted. |
| this leader cannot reach a majority | Reads are refused as well, since the leader can no longer confirm that it is the current leader. |
"last_sequence":701,"committed":700 | The record is in the leader’s log and has not been applied. |
after 1 follower returns: "committed":701, GET alone returns 200 | The record was committed without any new request from the client. |
This is exactly the behaviour that chapter 1 specified for the minority side of a partition. The leader remains consistent and becomes unavailable, and it would be easy, and wrong, to regard its refusal to serve as a defect. A leader that went on answering in this condition would be answering from data that it cannot know to be current.
The leader is lost
In this experiment a leader is killed under load and its data directory is moved away, which simulates the loss of a machine together with its disk.
$ ./target/release/kvctl load --server http://127.0.0.1:7001 --count 2000 --acked acked.txt &
$ kill -9 <pid of the leader>
write 229 failed: io: Peer disconnected
228 writes acknowledged
$ mv /var/tmp/n1 /var/tmp/n1.lost
$ for port in 7002 7003; do curl http://127.0.0.1:$port/healthz; echo; done
{"role":"follower","term":1,"last_sequence":228,"committed":227,"followers":[]}
{"role":"follower","term":1,"last_sequence":228,"committed":227,"followers":[]}
$ kill -9 <pid of the node on port 7003>
$ ./target/release/kvnode --role follower --listen 127.0.0.1:7001 --replication 127.0.0.1:7101 --data /var/tmp/n1 &
$ ./target/release/kvnode --role leader --term 2 --listen 127.0.0.1:7003 --data /var/tmp/n3 \
--follower http://127.0.0.1:7102 --follower http://127.0.0.1:7101 &
term 2, log ends at 228
listening on 127.0.0.1:7003
$ for port in 7003 7002 7001; do curl http://127.0.0.1:$port/healthz; echo; done
{"role":"leader","term":2,"last_sequence":228,"committed":228,"followers":[228,228]}
{"role":"follower","term":2,"last_sequence":228,"committed":228,"followers":[]}
{"role":"follower","term":2,"last_sequence":228,"committed":228,"followers":[]}
$ ./target/release/kvctl verify --server http://127.0.0.1:7003 --acked acked.txt
228 acknowledged, 228 found, 0 missing
| Output | What it shows |
|---|---|
| 228 writes acknowledged | the client’s record of acknowledged writes, kept outside the cluster |
both followers: "last_sequence":228,"committed":227 | Both followers hold all 228 records. They had not yet been told that record 228 was committed, because the leader stopped before its next message. |
| term 2, log ends at 228 | The promoted node starts as leader with a higher term. |
all 3 nodes: term 2, "committed":228 | The remaining follower adopted the new term, and the empty replacement node received the complete log. |
| 228 acknowledged, 228 found, 0 missing | Non-functional requirement 0 is met: every acknowledged write survived the loss of the leader and its disk. |
The second row illustrates why the committed sequence is not stored on disk. What a follower knows to be committed always lags behind its leader, and after a leader has been lost, the committed sequence is established afresh from the logs: a new leader commits a record as soon as a majority has reported it.
For the promotion, the follower with the longer log must be chosen. By invariant 2, every acknowledged record is on at least 1 of the 2 followers, though not necessarily on both. If their logs differ in length, the longer contains every acknowledged record, and the shorter may lack some of the most recent. To promote the shorter would be to discard acknowledged writes, and this choice, made here by a person reading 2 numbers, is the one that chapter 4 has to teach the nodes to make for themselves.
The former leader returns
Suppose that a former leader was not lost after all and begins to send messages again, as it might when a network partition ends. A leader with term 1 is started against the 2 followers.
$ ./target/release/kvnode --role leader --term 1 --listen 127.0.0.1:7009 --data /var/tmp/n9 \
--follower http://127.0.0.1:7102 --follower http://127.0.0.1:7101 &
$ curl -i -X PUT http://127.0.0.1:7009/v1/keys/stale --data-binary 'x'
HTTP/1.1 421 Misdirected Request
content-type: application/problem+json
content-length: 125
{"type":"/problems/not-leader","title":"This node is not the leader","status":421,"detail":"a leader of a newer term exists"}
$ curl http://127.0.0.1:7002/healthz
{"role":"follower","term":2,"last_sequence":228,"committed":228,"followers":[]}
$ curl -o /dev/null -w '%{http_code}\n' -X PUT http://127.0.0.1:7003/v1/keys/fresh --data-binary 'x'
201
Both followers are at term 2 and refuse messages of term 1. Each of their answers
carries the higher term, so that the old leader records that it has been replaced and
rejects writes with 421. The shard meanwhile continues to accept writes through its
leader of term 2. Terms are what make a change of leader safe, since a leader that
has been replaced cannot add a record to any follower.
Limitations
| Limitation | Consequence |
|---|---|
| The leader is appointed by an operator. | After the loss of a leader, the shard is unavailable for writes until a person or a script promotes a follower. |
| A former leader must not rejoin with its old log. | Its log may contain records that were appended and never committed, at positions where the new leader has written different records. The implementation compares positions only, so a former leader has to rejoin with an empty data directory. |
| Writes are processed 1 after another. | The write rate of a shard is limited to 1 write for each round trip. Group commit, which the capacity estimate of chapter 1 assumed, is not implemented. |
| Every replica holds all its records in memory. | The memory used by a node grows with the length of its log. |
| A leader serves reads for up to 1 second after losing its majority. | Non-functional requirement 2, consistency, depends on no new leader being appointed within that second. |
The first 2 limitations are removed in chapter 4, and the third in chapter 6.
The complete programs
The log and the store are listed in chapter 2. Those listings include
Log::append_records as well as apply, check and page, which this chapter uses.
Listing 3.32 The complete
repl/src/lib.rs. repl/src/lib.rs
//! Replication of a log from a leader to its followers.
//!
//! A `Replica` is one copy of a shard: a log, and the entries that result
//! from the records that are known to be on a majority of the copies. The
//! same type serves as leader and as follower. This module does no I/O on
//! the network. It builds the messages and processes them, and the caller
//! carries them between replicas.
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use kv::{Change, Condition, Entry, Log, Record, StoreError, Summary, apply, check, page};
/// The message a leader sends to a follower.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Append {
/// The term of the sender.
pub term: u64,
/// The sequence of the record that precedes `records` in the leader's log.
pub previous_sequence: u64,
pub records: Vec<Record>,
/// Every record up to this sequence is on a majority of the replicas.
pub committed_sequence: u64,
}
/// The follower's answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Appended {
pub term: u64,
/// False when the follower's log does not end at `previous_sequence`,
/// or when the sender's term is older than the follower's.
pub appended: bool,
/// The sequence of the last record in the follower's log.
pub last_sequence: u64,
}
pub struct Replica {
log: Log,
/// Every record of the log, in order. Record `n` is at index `n - 1`.
records: Vec<Record>,
/// The result of applying the records up to `committed`.
entries: BTreeMap<String, Entry>,
/// The sequence of the last record that is applied to `entries`.
committed: u64,
term: u64,
}
impl Replica {
/// Opens the replica in `directory`. Nothing is applied yet: a replica
/// that has restarted does not know which of its records are committed.
pub fn open(directory: &Path, sync: bool, term: u64) -> io::Result<Replica> {
let (log, recovery) = Log::open(directory, sync)?;
Ok(Replica {
log,
records: recovery.records,
entries: BTreeMap::new(),
committed: 0,
term,
})
}
pub fn term(&self) -> u64 {
self.term
}
pub fn last_sequence(&self) -> u64 {
self.records.len() as u64
}
pub fn committed(&self) -> u64 {
self.committed
}
/// One page of the committed keys.
pub fn list(&self, prefix: &str, after: Option<&str>, limit: usize) -> (Vec<Summary>, bool) {
page(&self.entries, prefix, after, limit)
}
/// Every record of the log, in order.
pub fn records(&self) -> &[Record] {
&self.records
}
/// The committed value of a key.
pub fn get(&self, key: &str) -> Option<&Entry> {
self.entries.get(key)
}
/// Applies the records up to `sequence` that are not applied yet.
pub fn commit(&mut self, sequence: u64) {
let until = sequence.min(self.last_sequence());
while self.committed < until {
apply(
&mut self.entries,
self.records[self.committed as usize].clone(),
);
self.committed += 1;
}
}
}
impl Replica {
/// Whether every record of the log is committed. A leader accepts a new
/// write only in this state, so that the condition of the write is
/// checked against the result of all earlier writes.
pub fn is_settled(&self) -> bool {
self.committed == self.last_sequence()
}
/// Checks the condition of a write and appends its record to the
/// leader's log. The record is not applied: it is not committed yet.
pub fn propose(&mut self, change: Change, condition: Condition) -> Result<u64, StoreError> {
let key = match &change {
Change::Put { key, .. } | Change::Delete { key } => key,
};
let current = self
.entries
.get(key)
.map(|entry| entry.version);
if matches!(change, Change::Delete { .. }) && current.is_none() {
return Err(StoreError::NotFound);
}
check(condition, current)?;
let record = self.log.append(change)?;
let sequence = record.sequence;
self.records.push(record);
Ok(sequence)
}
}
impl Replica {
/// The message that brings a follower whose log ends at `follower_last`
/// up to the end of this log. With nothing to send it is a heartbeat.
pub fn append_for(&self, follower_last: u64) -> Append {
let from = follower_last.min(self.last_sequence()) as usize;
Append {
term: self.term,
previous_sequence: from as u64,
records: self.records[from..].to_vec(),
committed_sequence: self.committed,
}
}
}
/// The highest sequence that is stored on a majority, given the last
/// sequence of every replica, the leader included.
pub fn majority_sequence(mut last_sequences: Vec<u64>) -> u64 {
last_sequences.sort_unstable_by(|a, b| b.cmp(a));
last_sequences
.get(last_sequences.len() / 2)
.copied()
.unwrap_or(0)
}
impl Replica {
/// Processes a message from a leader.
pub fn handle_append(&mut self, request: Append) -> io::Result<Appended> {
let refuse = |replica: &Replica| Appended {
term: replica.term,
appended: false,
last_sequence: replica.last_sequence(),
};
if request.term < self.term {
return Ok(refuse(self));
}
self.term = request.term;
if request.previous_sequence != self.last_sequence() {
return Ok(refuse(self));
}
if !request.records.is_empty() {
self.log
.append_records(&request.records)?;
self.records.extend(request.records);
}
self.commit(request.committed_sequence);
Ok(Appended {
term: self.term,
appended: true,
last_sequence: self.last_sequence(),
})
}
}
Listing 3.33 The complete
replsim/src/main.rs. replsim/src/main.rs
//! Command replsim runs a leader and 2 followers in one process and carries
//! the messages between them by hand, so that it can lose any message it
//! chooses. It first walks through the cases of the chapter, and then tries
//! every combination of lost requests and lost answers over 3 writes and
//! checks what the replicas hold after each.
//!
//! Run it from code/ with:
//!
//! cargo run --release -p replsim
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use kv::{Change, Condition};
use repl::{Replica, majority_sequence};
/// What happens to one exchange between the leader and a follower.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fate {
Delivered,
/// The follower does not receive the message.
RequestLost,
/// The follower processes the message, and its answer does not arrive.
AnswerLost,
}
/// A leader, 2 followers, and what the leader knows about each follower.
struct Cluster {
leader: Replica,
followers: Vec<Replica>,
/// The last sequence each follower has reported.
reported: Vec<u64>,
}
impl Cluster {
fn open(root: &Path) -> Result<Cluster, Box<dyn Error>> {
let open = |name: &str| Replica::open(&root.join(name), false, 1);
Ok(Cluster {
leader: open("leader")?,
followers: vec![open("follower-1")?, open("follower-2")?],
reported: vec![0, 0],
})
}
/// One exchange with a follower: the leader sends what the follower
/// lacks, as far as it knows, and commits what a majority now holds.
fn exchange(&mut self, follower: usize, fate: Fate) -> Result<(), Box<dyn Error>> {
let message = self
.leader
.append_for(self.reported[follower]);
if fate == Fate::RequestLost {
return Ok(());
}
let answer = self.followers[follower].handle_append(message)?;
if fate == Fate::AnswerLost {
return Ok(());
}
self.reported[follower] = answer.last_sequence;
let stored = vec![
self.leader.last_sequence(),
self.reported[0],
self.reported[1],
];
self.leader
.commit(majority_sequence(stored));
Ok(())
}
/// A write: the leader appends the record and makes one exchange with
/// each follower. It returns whether the write was acknowledged, or
/// `None` when the leader could not accept it.
fn write(
&mut self,
key: &str,
value: &str,
fates: [Fate; 2],
) -> Result<Option<bool>, Box<dyn Error>> {
if !self.leader.is_settled() {
return Ok(None);
}
let change = Change::Put {
key: key.into(),
value: value.into(),
};
let sequence = self
.leader
.propose(change, Condition::Always)?;
self.exchange(0, fates[0])?;
self.exchange(1, fates[1])?;
Ok(Some(self.leader.committed() >= sequence))
}
/// The last sequence of the leader and of each follower.
fn lasts(&self) -> [u64; 3] {
[
self.leader.last_sequence(),
self.followers[0].last_sequence(),
self.followers[1].last_sequence(),
]
}
}
const BOTH: [Fate; 2] = [Fate::Delivered, Fate::Delivered];
fn cases(root: &Path) -> Result<(), Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
let show = |cluster: &Cluster| {
format!(
"logs end at {:?}, committed {}",
cluster.lasts(),
cluster.leader.committed()
)
};
for key in ["a", "b", "c"] {
cluster.write(key, "1", BOTH)?;
}
println!("3 writes, every message delivered: {}", show(&cluster));
for key in ["d", "e", "f"] {
cluster.write(key, "1", [Fate::RequestLost, Fate::Delivered])?;
}
println!(
"3 writes while follower 1 is unreachable: {}",
show(&cluster)
);
cluster.exchange(0, Fate::Delivered)?;
println!("follower 1 returns, 1 exchange: {}", show(&cluster));
for key in ["g", "h", "i"] {
cluster.write(key, "1", [Fate::AnswerLost, Fate::Delivered])?;
}
println!(
"3 writes while the answers of follower 1 are lost: {}",
show(&cluster)
);
println!(
" the leader believes that the log of follower 1 ends at {}",
cluster.reported[0]
);
cluster.exchange(0, Fate::Delivered)?;
println!(
" next exchange: the leader sends records 7 to 9, follower 1 refuses and reports {}",
cluster.reported[0]
);
cluster.exchange(0, Fate::Delivered)?;
println!(" the exchange after it: {}", show(&cluster));
let acknowledged =
cluster.write("j", "1", [Fate::RequestLost, Fate::RequestLost])? == Some(true);
println!(
"1 write while both followers are unreachable: acknowledged {acknowledged}, {}",
show(&cluster)
);
println!(
" the leader reads j as {:?}",
cluster
.leader
.get("j")
.map(|entry| entry.version)
);
let accepted = cluster
.write("k", "1", [Fate::RequestLost, Fate::RequestLost])?
.is_some();
println!(" the leader accepts another write: {accepted}");
cluster.exchange(1, Fate::Delivered)?;
println!("follower 2 returns: {}", show(&cluster));
println!(
" the leader reads j as {:?}",
cluster
.leader
.get("j")
.map(|entry| entry.version)
);
let mut newer = Replica::open(&root.join("newer"), false, 2)?;
let answer = newer.handle_append(cluster.leader.append_for(0))?;
println!(
"a replica at term 2 receives a message of term 1: appended {}, its term {}",
answer.appended, answer.term
);
Ok(())
}
const WRITES: usize = 3;
/// The fate of exchange number `index` under a pattern: 2 bits for each.
fn fate(pattern: u32, index: usize) -> Fate {
match (pattern >> (2 * index)) & 3 {
0 | 1 => Fate::Delivered,
2 => Fate::RequestLost,
3.. => Fate::AnswerLost,
}
}
/// Runs 3 writes under one pattern of losses and checks the replicas. It
/// returns the number of writes that were acknowledged.
fn run(root: &Path, pattern: u32) -> Result<usize, Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
let mut acknowledged = Vec::new();
for number in 0..WRITES {
let fates = [fate(pattern, 2 * number), fate(pattern, 2 * number + 1)];
if cluster.write(&format!("key-{number}"), "1", fates)? == Some(true) {
acknowledged.push(cluster.leader.last_sequence());
}
}
for &sequence in &acknowledged {
let holders = cluster
.lasts()
.iter()
.filter(|&&last| last >= sequence)
.count();
if holders < 2 {
return Err(format!(
"pattern {pattern}: record {sequence} was acknowledged and is in {holders} log"
)
.into());
}
}
for follower in &cluster.followers {
if !cluster
.leader
.records()
.starts_with(follower.records())
{
return Err(format!(
"pattern {pattern}: a follower's log is not the beginning of the leader's"
)
.into());
}
}
for _ in 0..2 {
cluster.exchange(0, Fate::Delivered)?;
cluster.exchange(1, Fate::Delivered)?;
}
let [leader, first, second] = cluster.lasts();
if first != leader || second != leader || !cluster.leader.is_settled() {
return Err(format!(
"pattern {pattern}: the logs differ after every message was delivered"
)
.into());
}
Ok(acknowledged.len())
}
fn exhaustive(root: &Path) -> Result<(), Box<dyn Error>> {
let patterns = 1u32 << (4 * WRITES);
let mut by_acknowledged = [0; WRITES + 1];
for pattern in 0..patterns {
let directory = root.join(format!("pattern-{pattern}"));
by_acknowledged[run(&directory, pattern)?] += 1;
fs::remove_dir_all(&directory)?;
}
println!("{patterns} patterns of lost messages over {WRITES} writes:");
println!(" every acknowledged record was in at least 2 of the 3 logs");
println!(" every follower's log was the beginning of the leader's log");
println!(" after the losses stopped, the 3 logs were equal and every record was committed");
for (count, patterns) in by_acknowledged.iter().enumerate() {
println!(" {count} of {WRITES} writes acknowledged: {patterns} patterns");
}
Ok(())
}
fn directory() -> PathBuf {
env::temp_dir().join(format!("replsim-{}", process::id()))
}
fn main() -> Result<(), Box<dyn Error>> {
let root = directory();
let result = cases(&root.join("cases")).and_then(|()| exhaustive(&root));
fs::remove_dir_all(&root)?;
result
}
Listing 3.34 The complete
kvnode/Cargo.toml. kvnode/Cargo.toml
[package]
name = "kvnode"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kv.workspace = true
kvd.workspace = true
repl.workspace = true
axum.workspace = true
clap.workspace = true
parking_lot.workspace = true
prost.workspace = true
serde.workspace = true
tokio.workspace = true
tonic.workspace = true
tonic-prost.workspace = true
[build-dependencies]
tonic-prost-build.workspace = true
Listing 3.35 The complete
kvnode/src/rpc.rs. kvnode/src/rpc.rs
//! The replication service: the generated gRPC types, the conversion between
//! them and the types of the `repl` crate, and the server side that a
//! follower runs.
use std::sync::Arc;
use kv::{Change, Record};
use repl::{Append, Appended};
use tokio::task;
use tonic::{Request, Response, Status};
use crate::node::Node;
/// The types that `build.rs` generates from `proto/store.proto`. The schema
/// also defines the ring, which this program does not use.
#[allow(dead_code)]
pub mod proto {
tonic::include_proto!("store.v1");
}
pub use proto::replication_client::ReplicationClient;
pub use proto::replication_server::{Replication, ReplicationServer};
impl From<&Record> for proto::Record {
fn from(record: &Record) -> Self {
let change = match record.change.clone() {
Change::Put { key, value } => proto::record::Change::Put(proto::Put { key, value }),
Change::Delete { key } => proto::record::Change::Delete(proto::Delete { key }),
};
proto::Record {
sequence: record.sequence,
change: Some(change),
}
}
}
impl TryFrom<proto::Record> for Record {
type Error = Status;
fn try_from(record: proto::Record) -> Result<Self, Status> {
let change = match record.change {
Some(proto::record::Change::Put(put)) => Change::Put {
key: put.key,
value: put.value,
},
Some(proto::record::Change::Delete(delete)) => Change::Delete { key: delete.key },
None => return Err(Status::invalid_argument("a record without a change")),
};
Ok(Record {
sequence: record.sequence,
change,
})
}
}
impl From<Append> for proto::AppendRequest {
fn from(message: Append) -> Self {
proto::AppendRequest {
shard: 0,
term: message.term,
previous_sequence: message.previous_sequence,
records: message
.records
.iter()
.map(proto::Record::from)
.collect(),
committed_sequence: message.committed_sequence,
// The fields that leader election added are not used by this program.
previous_term: 0,
leader: 0,
}
}
}
impl From<proto::AppendResponse> for Appended {
fn from(answer: proto::AppendResponse) -> Self {
Appended {
term: answer.term,
appended: answer.appended,
last_sequence: answer.last_sequence,
}
}
}
/// The server side of `Append`, which a follower runs.
pub struct Follower {
pub node: Arc<Node>,
}
#[tonic::async_trait]
impl Replication for Follower {
async fn append(
&self,
request: Request<proto::AppendRequest>,
) -> Result<Response<proto::AppendResponse>, Status> {
let request = request.into_inner();
let records = request
.records
.into_iter()
.map(Record::try_from)
.collect::<Result<Vec<_>, _>>()?;
let message = Append {
term: request.term,
previous_sequence: request.previous_sequence,
records,
committed_sequence: request.committed_sequence,
};
// Appending waits for the disk, so it runs where blocking is allowed.
let answer = task::block_in_place(|| {
self.node
.replica
.lock()
.handle_append(message)
})
.map_err(|error| Status::unavailable(error.to_string()))?;
Ok(Response::new(proto::AppendResponse {
term: answer.term,
appended: answer.appended,
last_sequence: answer.last_sequence,
}))
}
}
Listing 3.36 The complete
kvnode/src/node.rs. kvnode/src/node.rs
//! A node of a replicated shard: a replica, and for a leader the tasks that
//! keep its followers up to date.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use kv::{Change, Condition, Entry, Summary, Written};
use kvd::api::ApiError;
use parking_lot::Mutex;
use repl::{Appended, Replica, majority_sequence};
use tokio::sync::{Notify, watch};
use tokio::task;
use tokio::time::{sleep, timeout};
use tonic::transport::{Channel, Endpoint};
use crate::rpc::{ReplicationClient, proto};
/// How often a leader sends a message to a follower that has nothing new to receive.
const HEARTBEAT: Duration = Duration::from_millis(100);
/// How long a leader waits for the answer to one message.
const ANSWER: Duration = Duration::from_millis(500);
/// How long a write waits for its record to reach a majority.
const COMMIT: Duration = Duration::from_secs(1);
/// What the leader knows about one follower.
pub struct Peer {
pub address: String,
/// The last sequence the follower has reported.
reported: AtomicU64,
/// When the follower last answered.
heard: Mutex<Option<Instant>>,
}
pub struct Node {
pub replica: Mutex<Replica>,
pub leader: bool,
pub peers: Vec<Peer>,
/// The sequence the log ended at when the node started. A leader serves
/// reads once this much is committed.
ready: u64,
/// Held by the write that is in progress, so that writes take turns.
turn: tokio::sync::Mutex<()>,
/// The committed sequence, for writes that wait for it to advance.
committed: watch::Sender<u64>,
/// Signalled when the leader has appended a record.
appended: Notify,
/// Set when a follower answers with a newer term: another leader exists.
deposed: AtomicBool,
}
impl Node {
pub fn new(replica: Replica, leader: bool, followers: Vec<String>) -> Arc<Node> {
let peer = |address| Peer {
address,
reported: AtomicU64::new(0),
heard: Mutex::new(None),
};
Arc::new(Node {
ready: replica.last_sequence(),
replica: Mutex::new(replica),
leader,
peers: followers
.into_iter()
.map(peer)
.collect(),
turn: tokio::sync::Mutex::new(()),
committed: watch::Sender::new(0),
appended: Notify::new(),
deposed: AtomicBool::new(false),
})
}
}
impl Node {
/// Starts one task for each follower. A task sends the follower what it
/// lacks, again and again, for as long as the node runs.
pub fn lead(self: &Arc<Node>) -> Result<(), tonic::transport::Error> {
for index in 0..self.peers.len() {
let channel = Endpoint::from_shared(self.peers[index].address.clone())?
.connect_timeout(ANSWER)
.connect_lazy();
tokio::spawn(Arc::clone(self).follow(index, ReplicationClient::new(channel)));
}
Ok(())
}
async fn follow(self: Arc<Node>, index: usize, mut client: ReplicationClient<Channel>) {
let peer = &self.peers[index];
loop {
let message = self.replica.lock().append_for(
peer.reported
.load(Ordering::Relaxed),
);
let request = proto::AppendRequest::from(message);
let answer = timeout(ANSWER, client.append(request)).await;
// An answer that did not come counts as a peer that is up to date:
// the next round is a heartbeat, which finds out more.
let behind = answer
.ok()
.and_then(Result::ok)
.is_some_and(|answer| self.record(peer, answer.into_inner().into()));
if !behind {
// Wait for a new record, or send a heartbeat when none comes.
tokio::select! {
() = self.appended.notified() => {}
() = sleep(HEARTBEAT) => {}
}
}
}
}
}
impl Node {
/// Takes note of a follower's answer and commits what a majority now
/// holds. It returns whether the follower still lacks records.
fn record(&self, peer: &Peer, answer: Appended) -> bool {
let mut replica = self.replica.lock();
if answer.term > replica.term() {
self.deposed
.store(true, Ordering::Relaxed);
return false;
}
peer.reported
.store(answer.last_sequence, Ordering::Relaxed);
*peer.heard.lock() = Some(Instant::now());
let mut stored: Vec<u64> = self
.peers
.iter()
.map(|peer| {
peer.reported
.load(Ordering::Relaxed)
})
.collect();
stored.push(replica.last_sequence());
replica.commit(majority_sequence(stored));
self.committed
.send_replace(replica.committed());
answer.last_sequence < replica.last_sequence()
}
}
impl Node {
/// A write on the leader: wait for a turn, append the record, and wait
/// until a majority of the replicas hold it.
pub async fn write(&self, change: Change, condition: Condition) -> Result<Written, ApiError> {
self.must_lead()?;
let turn = self.turn.lock().await;
let last = self.replica.lock().last_sequence();
self.wait_for(last).await?;
let proposed = task::block_in_place(|| {
let mut replica = self.replica.lock();
let created = match &change {
Change::Put { key, .. } | Change::Delete { key } => replica.get(key).is_none(),
};
replica
.propose(change, condition)
.map(|version| Written { version, created })
})?;
self.appended.notify_waiters();
self.wait_for(proposed.version)
.await?;
drop(turn);
Ok(proposed)
}
/// Waits until the record with this sequence is committed.
async fn wait_for(&self, sequence: u64) -> Result<(), ApiError> {
let mut committed = self.committed.subscribe();
let reached = committed.wait_for(|&committed| committed >= sequence);
let in_time = timeout(COMMIT, reached)
.await
.is_ok_and(|reached| reached.is_ok());
if in_time {
Ok(())
} else {
Err(ApiError::Unavailable(format!(
"record {sequence} is not on a majority of the replicas"
)))
}
}
fn must_lead(&self) -> Result<(), ApiError> {
if !self.leader {
return Err(ApiError::Misdirected("this node is a follower".into()));
}
if self.deposed.load(Ordering::Relaxed) {
return Err(ApiError::Misdirected(
"a leader of a newer term exists".into(),
));
}
Ok(())
}
}
impl Node {
/// A leader serves reads while a majority of the replicas, itself
/// included, has answered within the last second, and once everything
/// its log held at start is committed.
fn must_serve_reads(&self) -> Result<(), ApiError> {
self.must_lead()?;
let recently = |peer: &&Peer| {
peer.heard
.lock()
.is_some_and(|at| at.elapsed() < COMMIT)
};
let reachable = 1 + self
.peers
.iter()
.filter(recently)
.count();
if 2 * reachable <= self.peers.len() + 1 {
return Err(ApiError::Unavailable(
"this leader cannot reach a majority of the replicas".into(),
));
}
if self.replica.lock().committed() < self.ready {
return Err(ApiError::Unavailable(
"the records from before the restart are not committed yet".into(),
));
}
Ok(())
}
pub fn get(&self, key: &str) -> Result<Entry, ApiError> {
self.must_serve_reads()?;
self.replica
.lock()
.get(key)
.cloned()
.ok_or(ApiError::NotFound)
}
pub fn list(
&self,
prefix: &str,
after: Option<&str>,
limit: usize,
) -> Result<(Vec<Summary>, bool), ApiError> {
self.must_serve_reads()?;
Ok(self
.replica
.lock()
.list(prefix, after, limit))
}
/// The last sequence each follower has reported.
pub fn reported(&self) -> Vec<u64> {
self.peers
.iter()
.map(|peer| {
peer.reported
.load(Ordering::Relaxed)
})
.collect()
}
}
Listing 3.37 The complete
kvnode/src/main.rs. kvnode/src/main.rs
//! Command kvnode is a storage node that replicates its log. One node of a
//! shard is started as the leader, with the addresses of its followers, and
//! the others as followers. The leader serves the HTTP API and acknowledges
//! a write when a majority of the nodes hold its record.
//!
//! Run 3 nodes from code/ with:
//!
//! cargo run -p kvnode -- --role follower --listen 127.0.0.1:7002 --replication 127.0.0.1:7102 --data /var/tmp/n2
//! cargo run -p kvnode -- --role follower --listen 127.0.0.1:7003 --replication 127.0.0.1:7103 --data /var/tmp/n3
//! cargo run -p kvnode -- --role leader --listen 127.0.0.1:7001 --data /var/tmp/n1 \
//! --follower http://127.0.0.1:7102 --follower http://127.0.0.1:7103
mod node;
mod rpc;
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::{Parser, ValueEnum};
use kv::{Change, Condition};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, version_header, write_condition};
use repl::Replica;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tonic::transport::Server;
use crate::node::Node;
use crate::rpc::{Follower, ReplicationServer};
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Role {
Leader,
Follower,
}
#[derive(Parser)]
struct Options {
#[arg(long, value_enum)]
role: Role,
/// The address of the HTTP API.
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
/// The address on which a follower accepts records from the leader.
#[arg(long, default_value = "127.0.0.1:7101")]
replication: SocketAddr,
/// The replication address of a follower, such as http://127.0.0.1:7102.
/// Given once for each follower, to the leader.
#[arg(long = "follower")]
followers: Vec<String>,
/// The term this node starts in. A new leader is given a higher term
/// than the leader it replaces.
#[arg(long, default_value_t = 1)]
term: u64,
#[arg(long, default_value = "data")]
data: PathBuf,
/// Respond without waiting for the disk.
#[arg(long)]
no_sync: bool,
}
async fn put_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
let written = node
.write(
Change::Put {
key,
value: body.to_vec(),
},
condition,
)
.await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
node.write(Change::Delete { key }, condition)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let cached = version_header(&headers, IF_NONE_MATCH)?;
let entry = node.get(&key)?;
let tag = [(ETAG, etag(entry.version))];
if cached == Some(Condition::Version(entry.version)) {
return Ok((StatusCode::NOT_MODIFIED, tag).into_response());
}
Ok((
tag,
[(CONTENT_TYPE, "application/octet-stream")],
entry.value,
)
.into_response())
}
#[derive(Deserialize)]
struct ListQuery {
#[serde(default)]
prefix: String,
after: Option<String>,
limit: Option<usize>,
}
#[derive(Serialize)]
struct Item {
key: String,
version: u64,
size: usize,
}
#[derive(Serialize)]
struct Page {
keys: Vec<Item>,
next_after: Option<String>,
}
async fn list_keys(
State(node): State<Arc<Node>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Page>, ApiError> {
let limit = query
.limit
.unwrap_or(100)
.clamp(1, 1000);
let (page, more) = node.list(&query.prefix, query.after.as_deref(), limit)?;
let next_after = if more {
page.last()
.map(|last| last.key.clone())
} else {
None
};
let keys = page
.into_iter()
.map(|item| Item {
key: item.key,
version: item.version,
size: item.size,
})
.collect();
Ok(Json(Page { keys, next_after }))
}
#[derive(Serialize)]
struct Health {
role: &'static str,
term: u64,
last_sequence: u64,
committed: u64,
/// The last sequence each follower has reported to this leader.
followers: Vec<u64>,
}
async fn health(State(node): State<Arc<Node>>) -> Json<Health> {
let replica = node.replica.lock();
Json(Health {
role: if node.leader { "leader" } else { "follower" },
term: replica.term(),
last_sequence: replica.last_sequence(),
committed: replica.committed(),
followers: node.reported(),
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let replica = Replica::open(&options.data, !options.no_sync, options.term)?;
println!(
"term {}, log ends at {}",
replica.term(),
replica.last_sequence()
);
let node = Node::new(replica, options.role == Role::Leader, options.followers);
match options.role {
Role::Leader => node.lead()?,
Role::Follower => {
let service = ReplicationServer::new(Follower {
node: Arc::clone(&node),
});
tokio::spawn(
Server::builder()
.add_service(service)
.serve(options.replication),
);
println!("accepting records on {}", options.replication);
}
}
let routes = Router::new()
.route("/v1/keys", get(list_keys))
.route(
"/v1/keys/{key}",
get(get_key)
.put(put_key)
.delete(delete_key),
)
.route("/healthz", get(health))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(node);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Further reading
- B. Oki and B. Liskov, “Viewstamped Replication: A New Primary Copy Method to Support Highly-Available Distributed Systems”, PODC 1988.
- D. Ongaro and J. Ousterhout, “In Search of an Understandable Consensus Algorithm”, USENIX ATC 2014.
- R. van Renesse and F. Schneider, “Chain Replication for Supporting High Throughput and Availability”, OSDI 2004.
Summary
- The leader of a shard sends each follower the records it lacks, in an
Appendmessage that states the position at which those records begin. - A follower appends only if its log ends at that position, and otherwise reports where its log ends, upon which the leader continues from there. This 1 rule deals with restarted followers, lost messages, lost answers and empty followers.
- A record is committed when a majority of the replicas has stored it, and the leader computes the committed sequence as the middle value of the sorted last sequences.
- A replica applies committed records only, so that no read returns data which could be lost with 1 machine.
- A write that was answered with an error may still be committed later, and a client has to treat an error as an unknown outcome.
- For 4096 combinations of lost messages and answers, the simulation verified that every acknowledged record was held in at least 2 of the 3 logs.
- With 3 nodes on 1 disk, the replicated shard completed 77 writes per second, against 185 for a single node.
- After the loss of a leader and its disk, promoting the follower with the longer log preserved all 228 acknowledged writes.
- A term identifies the current leader, and followers refuse messages with a lower term, so that a replaced leader cannot modify its shard.
Key-Value Store: Leader Election (Low-Level Design)
In chapter 3 the leader of each shard was appointed by an operator, and when a leader was lost its shard could accept no writes until somebody promoted a follower by hand. Non-functional requirement 1 of chapter 1 allows 21.6 minutes of failed requests in a month, and no procedure that depends on a person noticing a failure, understanding it and typing commands can be relied upon to stay within such a limit. The members of a shard have to choose their own leader.
Stated so, the task sounds modest, and it is in fact the hardest problem in this book. The members must agree on a leader although they can communicate only by messages that may be lost, although any of them may crash and return at any moment with whatever it had on disk, and although none of them can tell a crashed member from one that is merely slow or cut off. The agreement must be such that 2 members are never leaders at once, since each would then accept writes of which the other knew nothing. And whoever is chosen must already hold every write that has ever been acknowledged, since a leader that lacked one would proceed to overwrite it. A group of machines reaching a decision under these conditions is said to reach consensus.
The algorithm used in this chapter is Raft, published by Diego Ongaro and John
Ousterhout in 2014 with the stated aim of being understandable, which it is to a
degree that its predecessors were not. Raft combines the replication of chapter 3
with a procedure for voting. We first describe the roles of a member and the rule by
which it votes, and show why that rule guarantees that every new leader holds every
acknowledged record. We then deal with a case that chapter 3 excluded, that of a
member whose log differs from its leader’s. Raft is implemented as a library without
any network code, tested by a simulation that crashes members and loses messages at
random, and then run over gRPC as kvraft. Finally we measure for how long a shard
of 3 nodes is unavailable when its leader is killed.
This chapter covers
- the roles of a member: follower, candidate and leader
- terms, and the state that a member must keep on disk
- election timeouts and the voting rule
- term markers, which record the term of every record without changing the format of the log
- repairing a log that differs from the leader’s
- the commit rule for records of earlier terms
- a simulation of 200 random runs with crashes and lost messages
- checking that the simulation would detect a faulty implementation
kvraft: driving Raft with a clock and gRPC- a measurement of the pause that follows the loss of a leader
- the limitations that remain
Overview
Every member of a shard runs the same program and is started in the same way, and none is configured as the leader. At any moment each member is in 1 of 3 roles.
Time is divided into terms, numbered from 1. Every term begins with an election and has at most 1 leader. When no candidate obtains a majority, the term has no leader, and another election follows in the next. The implementation keeps the invariants of chapter 3 and adds 3 more.
- At most 1 member leads any given term.
- An elected leader holds every record that was acknowledged in an earlier term.
- A member removes a record from its log only if the record was not committed.
Invariant 5 holds because every member votes at most once in a term and a candidate needs the votes of a majority. Any 2 majorities have a member in common, and for 2 candidates to win the same term that member would have had to vote twice. Invariants 6 and 7 are established in the sections on voting and on the repair of logs.
The messages
Elections add a second call to the protocol of chapter 1, and 2 fields to the
existing Append call.
Listing 4.1 The election service in the schema. proto/store.proto
// Sent by a candidate to every other member of the shard.
service Election {
rpc RequestVote(VoteRequest) returns (VoteResponse);
}
message VoteRequest {
uint64 term = 1;
uint32 candidate = 2;
// The end of the candidate's log, and the term of its last record.
uint64 last_sequence = 3;
uint64 last_term = 4;
}
message VoteResponse {
uint64 term = 1;
bool granted = 2;
}
Listing 4.2 The replication service, with fields 6 and 7 added. proto/store.proto
// Sent by the leader of a shard to each follower.
service Replication {
// Appends records to the follower's log. A call with no records is a
// heartbeat: it tells the follower that the leader is alive.
rpc Append(AppendRequest) returns (AppendResponse);
}
message AppendRequest {
// The shard, named by the last position it owns.
fixed64 shard = 1;
// The term of the sender. A follower rejects a leader of an older term.
uint64 term = 2;
// The sequence of the record that precedes `records` in the leader's log.
uint64 previous_sequence = 3;
repeated Record records = 4;
// Every record up to this sequence is on a majority of the replicas.
uint64 committed_sequence = 5;
// Added for leader election. The term of the record at previous_sequence,
// and the id of the sender.
uint64 previous_term = 6;
uint32 leader = 7;
}
message AppendResponse {
uint64 term = 1;
// False when the follower's log does not end at `previous_sequence`.
bool appended = 2;
// The sequence of the last record in the follower's log.
uint64 last_sequence = 3;
}
AppendRequest has gained the fields previous_term and leader, under the unused
numbers 6 and 7. This is the evolution of a schema as chapter 1 described it. The
existing fields have kept their numbers, kvnode, the program of chapter 3, makes no
use of the new ones, and a node built from the earlier schema passes over them when it
decodes a message.
As in chapter 3, the library has message types of its own.
Listing 4.3 The imports of
raft/src/lib.rs. raft/src/lib.rs
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use kv::{Change, Condition, Entry, Log, Record, StoreError, apply, check};
Listing 4.4 The messages of the library. raft/src/lib.rs
pub type NodeId = u32;
/// Sent by a leader to a follower.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppendRequest {
pub term: u64,
pub leader: NodeId,
/// The sequence of the record that precedes `records`, and its term.
pub previous_sequence: u64,
pub previous_term: u64,
pub records: Vec<Record>,
pub committed_sequence: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppendAnswer {
pub term: u64,
pub from: NodeId,
pub appended: bool,
/// When appended: the last sequence that is known to match the leader's
/// log. When refused: the sequence the leader should continue from.
pub last_sequence: u64,
}
/// Sent by a candidate to every other member.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VoteRequest {
pub term: u64,
pub candidate: NodeId,
/// The end of the candidate's log, and the term of its last record.
pub last_sequence: u64,
pub last_term: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VoteAnswer {
pub term: u64,
pub from: NodeId,
pub granted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
Append(AppendRequest),
Appended(AppendAnswer),
Vote(VoteRequest),
Voted(VoteAnswer),
}
/// A message and the member it is addressed to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outgoing {
pub to: NodeId,
pub message: Message,
}
The state of a member
Listing 4.5 The roles, the election timeout, and the state of a member. raft/src/lib.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Follower,
Candidate,
Leader,
}
/// The ticks a follower waits for a leader before it stands for election:
/// 10 to 19, different for each member and each term, so that 2 members
/// seldom stand at the same moment. A tick is 100 ms in the node.
fn election_timeout(id: NodeId, term: u64) -> u64 {
10 + (u64::from(id) * 7 + term * 3) % 10
}
/// What the leader knows about one follower.
#[derive(Debug, Clone, Copy, Default)]
struct Progress {
/// The sequence after which the next message to the follower begins.
next: u64,
/// The last sequence the follower has confirmed to match.
matched: u64,
/// The tick at which the follower last answered.
heard: u64,
}
pub struct Member {
id: NodeId,
peers: Vec<NodeId>,
directory: PathBuf,
sync: bool,
log: Log,
/// Every record of the log. Record `n` is at index `n - 1`.
records: Vec<Record>,
/// The term in which each record was written.
terms: Vec<u64>,
entries: BTreeMap<String, Entry>,
committed: u64,
/// The current term, and the member this one voted for in it. Both are
/// stored on disk before any answer that depends on them is sent.
term: u64,
voted_for: Option<NodeId>,
role: Role,
/// The leader of the current term, when this member knows it.
leader: Option<NodeId>,
votes: usize,
progress: BTreeMap<NodeId, Progress>,
/// Ticks since the member started, and the tick of the last message
/// from a leader or the last vote granted.
now: u64,
quiet_since: u64,
/// The records this member has removed from its log because they
/// differed from a leader's.
discarded: u64,
}
A member holds 3 kinds of state, which differ in whether they have to survive a restart, and the distinction is among the most important things to understand about the algorithm.
| State | Fields | On disk | Why |
|---|---|---|---|
| The log | log, records, terms | yes | The log is the data. |
| The term and the vote | term, voted_for | yes, in the file state | A member that forgot its vote in a restart could vote a second time in the same term, and 2 leaders could then be elected in 1 term. |
| Everything else | role, leader, committed, entries, progress | no | This state is derived from the log and from messages, and is rebuilt after a restart. |
The second row is easily overlooked. It is natural to think of a vote as a passing event, of no interest once the election is over. But the whole guarantee of invariant 5 rests on each member voting once, and a member that crashed after voting and returned with no memory of having done so could give a second vote in the same term to a different candidate. A vote is therefore a promise, and it is written to disk before it is sent.
Member::open reads both files. From the log it obtains the records and, by means of
the term markers described below, the term of each. From the state file it obtains
the term and the vote. Everything else begins from fixed values: a member that has
been opened is a follower, knows of no leader, and has applied nothing.
Listing 4.6 Opening a member. raft/src/lib.rs
impl Member {
/// Opens the member in `directory`. It starts as a follower, with
/// nothing applied, in the term it had reached before it stopped.
pub fn open(
directory: &Path,
id: NodeId,
peers: Vec<NodeId>,
sync: bool,
) -> io::Result<Member> {
let (log, recovery) = Log::open(directory, sync)?;
let (term, voted_for) = read_state(directory)?;
let terms = terms_of(&recovery.records, 0);
Ok(Member {
id,
peers,
directory: directory.to_owned(),
sync,
log,
records: recovery.records,
terms,
entries: BTreeMap::new(),
committed: 0,
term,
voted_for,
role: Role::Follower,
leader: None,
votes: 0,
progress: BTreeMap::new(),
now: 0,
quiet_since: 0,
discarded: 0,
})
}
pub fn id(&self) -> NodeId {
self.id
}
pub fn role(&self) -> Role {
self.role
}
pub fn term(&self) -> u64 {
self.term
}
pub fn leader(&self) -> Option<NodeId> {
self.leader
}
pub fn last_sequence(&self) -> u64 {
self.records.len() as u64
}
pub fn committed(&self) -> u64 {
self.committed
}
pub fn records(&self) -> &[Record] {
&self.records
}
pub fn discarded(&self) -> u64 {
self.discarded
}
fn last_term(&self) -> u64 {
self.terms
.last()
.copied()
.unwrap_or(0)
}
/// The term of record `sequence`, or 0 for the position before the log.
fn term_at(&self, sequence: u64) -> u64 {
if sequence == 0 {
0
} else {
self.terms[sequence as usize - 1]
}
}
fn majority(&self) -> usize {
let members = self.peers.len() + 1;
members / 2 + 1
}
}
The state file is replaced by writing a new file and renaming it over the old one. On POSIX file systems, a rename within a directory is atomic, so that after a crash the file holds either the old state or the new and never a mixture. A member writes this file before it sends any message that depends on a new term or a new vote.
Listing 4.7 Reading and writing the term and the vote. raft/src/lib.rs
const STATE_FILE: &str = "state";
/// Reads the term and the vote. A member that has no state file has not
/// voted and is in term 0.
fn read_state(directory: &Path) -> io::Result<(u64, Option<NodeId>)> {
let text = match fs::read_to_string(directory.join(STATE_FILE)) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok((0, None)),
Err(error) => return Err(error),
};
let mut fields = text
.split_whitespace()
.map(str::parse::<u64>);
let invalid = || io::Error::new(io::ErrorKind::InvalidData, "the state file is malformed");
let term = fields
.next()
.and_then(Result::ok)
.ok_or_else(invalid)?;
let voted_for = fields
.next()
.and_then(Result::ok)
.map(|id| id as NodeId);
Ok((term, voted_for))
}
impl Member {
/// Stores the term and the vote. The new file replaces the old one in a
/// single step, so a crash leaves one of the 2 and not a mixture.
fn save_state(&self) -> io::Result<()> {
let temporary = self.directory.join("state.new");
let vote = self
.voted_for
.map_or(String::new(), |id| id.to_string());
let mut file = File::create(&temporary)?;
file.write_all(format!("{} {vote}\n", self.term).as_bytes())?;
if self.sync {
file.sync_all()?;
}
fs::rename(&temporary, self.directory.join(STATE_FILE))?;
if self.sync {
File::open(&self.directory)?.sync_all()?;
}
Ok(())
}
}
The term of a record
Raft needs to know in which term each record was written. The record format of
chapter 2 has no field for it, and the implementation records terms without altering
that format, by means of a term marker: the first record that a new leader writes
is a Put with an empty key and the leader’s term as its value. Clients cannot write
an empty key, since the API requires at least 1 character, so that a marker cannot
collide with any client’s data. The term of any record is then the term of the most
recent marker at or before it.
Listing 4.8 Term markers, and the term of each record of a log. raft/src/lib.rs
/// The key of a term marker. A client cannot write the empty key, so the
/// log can use it for a record of its own: the first record a new leader
/// appends, whose value is the leader's term.
const MARKER: &str = "";
fn marker(term: u64) -> Change {
Change::Put {
key: MARKER.to_owned(),
value: term.to_le_bytes().to_vec(),
}
}
/// The term a record starts, if it is a term marker.
fn marked_term(record: &Record) -> Option<u64> {
let Change::Put { key, value } = &record.change else {
return None;
};
if key != MARKER {
return None;
}
let term: [u8; 8] = value.as_slice().try_into().ok()?;
Some(u64::from_le_bytes(term))
}
/// The term of each record: the term of the last marker at or before it.
fn terms_of(records: &[Record], mut term: u64) -> Vec<u64> {
let mut terms = Vec::with_capacity(records.len());
for record in records {
term = marked_term(record).unwrap_or(term);
terms.push(term);
}
terms
}
The markers have a second function, which is described under the commit rule.
Elections
The election timeout
A follower expects a message from its leader at least once in every tick of 100 ms. Having received none for the whole of its election timeout, it concludes that the leader has failed and stands for election.
If all followers used the same timeout, they would all stand at the same moment, each
would vote for itself, and no candidate would obtain a majority. The timeouts are
therefore made to differ. election_timeout, in the listing above, computes a value
between 10 and 19 ticks from the id of the member and its term. Within a term, the
members of a shard obtain different values, and each member obtains a different value
in each term, so that 2 members which happen to coincide once do not coincide again.
Raft as published uses random timeouts for this purpose. Timeouts that are computed
make the simulation later in the chapter repeatable.
On reaching its timeout, a member calls stand. It increases its term, records a
vote for itself, and stores both on disk before any request leaves. Every
VoteRequest carries the end of the candidate’s log, which the voting rule below
compares with the log of each voter.
Listing 4.9 Standing for election. raft/src/lib.rs
impl Member {
/// Stands for election in the next term.
fn stand(&mut self) -> io::Result<Vec<Outgoing>> {
self.term += 1;
self.voted_for = Some(self.id);
self.save_state()?;
self.role = Role::Candidate;
self.leader = None;
self.votes = 1;
self.quiet_since = self.now;
let request = VoteRequest {
term: self.term,
candidate: self.id,
last_sequence: self.last_sequence(),
last_term: self.last_term(),
};
Ok(self
.peers
.iter()
.map(|&to| Outgoing {
to,
message: Message::Vote(request),
})
.collect())
}
}
The voting rule
A member grants its vote to a candidate when 3 conditions are satisfied.
| Condition | Purpose |
|---|---|
| The candidate’s term equals the member’s term. A request with a higher term first moves the member to that term. | A candidate of an earlier term cannot win. |
| The member has not voted for a different candidate in this term. | At most 1 leader in a term (invariant 5). |
| The candidate’s log ends at least as far as the member’s own. The terms of the last records are compared first, and the sequences if the terms are equal. | The leader holds every acknowledged record (invariant 6). |
Listing 4.10 Granting a vote, and counting the votes received. raft/src/lib.rs
impl Member {
/// A member grants its vote once in a term, and only to a candidate
/// whose log is at least as complete as its own.
fn on_vote(&mut self, request: VoteRequest) -> io::Result<VoteAnswer> {
if request.term > self.term {
self.step_down(request.term)?;
}
let complete =
(request.last_term, request.last_sequence) >= (self.last_term(), self.last_sequence());
let free = self
.voted_for
.is_none_or(|id| id == request.candidate);
let granted = request.term == self.term && complete && free;
if granted {
self.voted_for = Some(request.candidate);
self.save_state()?;
self.quiet_since = self.now;
}
Ok(VoteAnswer {
term: self.term,
from: self.id,
granted,
})
}
fn on_voted(&mut self, answer: VoteAnswer) -> io::Result<Vec<Outgoing>> {
if answer.term > self.term {
self.step_down(answer.term)?;
return Ok(Vec::new());
}
if self.role != Role::Candidate || answer.term != self.term || !answer.granted {
return Ok(Vec::new());
}
self.votes += 1;
if self.votes >= self.majority() {
self.lead()
} else {
Ok(Vec::new())
}
}
}
The third condition establishes invariant 6, by the argument that chapter 1 gave in outline. An acknowledged record is stored on a majority of the members, and a candidate needs the votes of a majority. Since 2 majorities share at least 1 member, some voter holds the record. That voter grants its vote only to a candidate whose log ends at least as far as its own, and such a candidate holds the record as well. A candidate that lacks an acknowledged record therefore cannot collect a majority. The choice that an operator made in chapter 3, by reading 2 numbers and promoting the follower with the longer log, is here made by the members themselves, each refusing its vote to anyone who knows less than it does.
In on_vote, the 3 conditions are request.term == self.term, free and
complete. Tuples in Rust are compared by their first elements, with the second
consulted only in a tie, so that (last_term, last_sequence) >= (...) is precisely
the comparison that the rule requires. A vote that is granted is written to disk
before any answer is returned. on_voted counts the votes for a candidate, and the
vote that completes a majority triggers lead.
Terms are compared before sequences because a longer log is not necessarily a more complete one. In figure 4.4, a member that had been isolated might have accumulated several uncommitted records of term 1, and its log would then be longer than that of member 2. A record of term 2 at the end of member 2’s log shows that member 2 has received records from a later leader, and its log takes precedence.
Becoming the leader
lead does 3 things. For every follower it records where to begin sending, namely at
the end of its own log, as a first guess that a refusal will correct. It then appends
the marker of its term, and finally sends its first messages.
Listing 4.11 Becoming the leader. raft/src/lib.rs
impl Member {
/// Becomes the leader of the current term. The first record of the term
/// is its marker. When a majority has stored the marker, every earlier
/// record is committed with it.
fn lead(&mut self) -> io::Result<Vec<Outgoing>> {
self.role = Role::Leader;
self.leader = Some(self.id);
let next = self.last_sequence();
self.progress = self
.peers
.iter()
.map(|&peer| {
(
peer,
Progress {
next,
matched: 0,
heard: self.now,
},
)
})
.collect();
let record = self.log.append(marker(self.term))?;
self.records.push(record);
self.terms.push(self.term);
Ok(self.replicate())
}
}
Returning to follower
Every message carries the term of its sender. On seeing, in any message, a term
higher than its own, a member calls step_down: it adopts that term, clears its
vote, since the vote belonged to an earlier term, and continues as a follower. For a
leader, this is how the arrival of a successor makes itself known.
Listing 4.12 Adopting a higher term. raft/src/lib.rs
impl Member {
/// Moves to a newer term as a follower. The vote of the old term does
/// not carry over.
fn step_down(&mut self, term: u64) -> io::Result<()> {
if term > self.term {
self.term = term;
self.voted_for = None;
self.save_state()?;
}
self.role = Role::Follower;
self.leader = None;
self.progress.clear();
Ok(())
}
}
Repairing a log that differs
In chapter 3 the log of a follower was always a prefix of its leader’s, and a former leader had to rejoin with an empty log. With elections, a member can hold records that its present leader lacks. This comes about when a leader appends a record and fails before sending it. The record exists on 1 member only and was never acknowledged, and the new leader writes a different record at the same sequence.
Append detects such a difference through the field previous_term. A follower
appends records only if its own record at previous_sequence has the term
previous_term. The check is sufficient on account of a single property: if 2 logs
contain a record with the same sequence and the same term, the 2 logs are identical
up to that record. The property holds because each term has 1 leader, a leader writes
each sequence once, and the check is applied on every append, so that agreement at 1
position has always been built upon agreement at the position before.
On the follower’s side, the handling of Append consists of 3 checks followed by the
repair itself.
The third check carries a hint for the leader. When its record at previous_sequence
has a different term, the member answers with previous_sequence - 1, and the leader
begins its next message 1 record earlier. By repeating this, the leader moves
backwards through its log until it arrives at a record on which the 2 logs agree.
Listing 4.13 The checks of
Append. raft/src/lib.rs
impl Member {
fn on_append(&mut self, request: AppendRequest) -> io::Result<AppendAnswer> {
let refuse = |member: &Member, last_sequence| AppendAnswer {
term: member.term,
from: member.id,
appended: false,
last_sequence,
};
if request.term < self.term {
return Ok(refuse(self, self.last_sequence()));
}
// A message of the current term or a newer one comes from the leader.
self.step_down(request.term)?;
self.leader = Some(request.leader);
self.quiet_since = self.now;
if request.previous_sequence > self.last_sequence() {
return Ok(refuse(self, self.last_sequence()));
}
if self.term_at(request.previous_sequence) != request.previous_term {
return Ok(refuse(self, request.previous_sequence - 1));
}
let matched = request.previous_sequence + request.records.len() as u64;
self.store(
request.previous_sequence,
request.previous_term,
request.records,
)?;
self.commit(
request
.committed_sequence
.min(matched),
);
Ok(AppendAnswer {
term: self.term,
from: self.id,
appended: true,
last_sequence: matched,
})
}
}
Once all the checks have passed, the 2 logs are known to be equal up to
previous_sequence, and store makes the member’s log equal to the leader’s for
every record that follows.
Listing 4.14 Skipping equal records, removing differing ones, and appending the rest. raft/src/lib.rs
impl Member {
/// Makes the log equal to the leader's from `previous` onward: records
/// the member already has are skipped, a record that differs is removed
/// together with everything after it, and the rest is appended.
fn store(&mut self, previous: u64, previous_term: u64, records: Vec<Record>) -> io::Result<()> {
let terms = terms_of(&records, previous_term);
let mut fresh = 0;
for (index, record) in records.iter().enumerate() {
let sequence = previous + index as u64 + 1;
if sequence > self.last_sequence() {
break;
}
if self.term_at(sequence) != terms[index]
|| &self.records[sequence as usize - 1] != record
{
self.truncate(sequence - 1)?;
break;
}
fresh = index + 1;
}
if fresh < records.len() {
self.log
.append_records(&records[fresh..])?;
self.records
.extend_from_slice(&records[fresh..]);
self.terms
.extend_from_slice(&terms[fresh..]);
}
Ok(())
}
}
For each incoming record, store computes a term from previous_term and from the
markers among the incoming records, exactly as terms_of does for a whole log. 2
records count as equal only if both their terms and their contents agree.
Listing 4.15 Removing uncommitted records. raft/src/lib.rs
impl Member {
/// Removes the records after `keep`. Only records that are not
/// committed can differ from the leader's, so none of them is applied.
fn truncate(&mut self, keep: u64) -> io::Result<()> {
if keep < self.committed {
return Err(io::Error::other(format!(
"record {} is committed and differs from the leader's",
keep + 1
)));
}
let length: usize = self.records[..keep as usize]
.iter()
.map(|record| record.encode().len())
.sum();
self.discarded += self.last_sequence() - keep;
self.log
.truncate(keep, length as u64)?;
self.records.truncate(keep as usize);
self.terms.truncate(keep as usize);
Ok(())
}
}
truncate refuses to remove a committed record and returns an error. By invariant 6
the situation cannot arise, since a leader holds every committed record and its log
cannot differ from a follower’s at a committed position. The check is there
nonetheless, and for a good reason. Should the invariant ever be violated, through a
defect in this code or a fault in a disk, the result without the check would be
acknowledged data silently destroyed, and with it the member stops. A program that
verifies what it believes to be impossible converts its own undiscovered defects from
corruption into failures that somebody will notice.
In order to truncate its file, a member needs the byte offset at which the last record to be kept ends. It computes that offset by encoding the records that are kept and summing their lengths, which avoids storing an offset for every record.
The log of chapter 2 gains a method for removing records from its end.
Listing 4.16 The imports of
kv/src/log.rs. kv/src/log.rs
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::Path;
use crate::record::{Change, Decoded, Record};
Listing 4.17 Removing records from the end of the log. kv/src/log.rs
impl Log {
/// Removes the records after record `keep`, which ends at byte `length`
/// of the file. A follower uses this to discard records that were never
/// committed and that differ from the leader's.
pub fn truncate(&mut self, keep: u64, length: u64) -> io::Result<()> {
self.file.set_len(length)?;
if self.sync {
self.file.sync_all()?;
}
self.next = keep + 1;
Ok(())
}
}
This method qualifies invariant 4 of chapter 2, which stated that the log is modified only by appending. Stated exactly, no committed record is ever modified. Records beyond the committed sequence may be removed, and invariant 7 of this chapter ensures that only such records are.
The leader’s view of a follower
For every follower, the leader keeps 2 sequences. next is where its next message
will begin, and it moves backwards on a refusal. matched is the highest sequence
that the follower has confirmed, and it only ever increases. The commit rule uses
matched, since next is a guess. on_appended updates both from an answer, applies
the commit rule, and returns a further message when the follower is still behind.
Listing 4.18 Processing the answer of a follower. raft/src/lib.rs
impl Member {
fn on_appended(&mut self, answer: AppendAnswer) -> io::Result<Vec<Outgoing>> {
if answer.term > self.term {
self.step_down(answer.term)?;
return Ok(Vec::new());
}
if self.role != Role::Leader || answer.term != self.term {
return Ok(Vec::new());
}
let now = self.now;
let Some(progress) = self.progress.get_mut(&answer.from) else {
return Ok(Vec::new());
};
progress.heard = now;
progress.next = answer.last_sequence;
if answer.appended {
progress.matched = progress
.matched
.max(answer.last_sequence);
}
let behind = !answer.appended || answer.last_sequence < self.last_sequence();
self.advance();
Ok(if behind {
self.append_for(answer.from)
.into_iter()
.collect()
} else {
Vec::new()
})
}
}
The commit rule
Listing 4.19 Applying committed records, and the leader's commit rule. raft/src/lib.rs
impl Member {
/// Applies the records up to `sequence` that are not applied yet. Term
/// markers are records of the log and are not entries.
fn commit(&mut self, sequence: u64) {
let until = sequence.min(self.last_sequence());
// A record that marks the start of a term changes no key.
let changes: Vec<Record> = (self.committed..until)
.map(|index| self.records[index as usize].clone())
.filter(|record| marked_term(record).is_none())
.collect();
for record in changes {
apply(&mut self.entries, record);
}
self.committed = self.committed.max(until);
}
/// The leader's commit rule. A record of an earlier term is not counted
/// on its own: it becomes committed together with the first record of
/// the leader's own term that a majority has stored.
fn advance(&mut self) {
let mut stored: Vec<u64> = self
.progress
.values()
.map(|progress| progress.matched)
.collect();
stored.push(self.last_sequence());
stored.sort_unstable_by(|a, b| b.cmp(a));
let on_majority = stored[self.majority() - 1];
if on_majority > self.committed && self.term_at(on_majority) == self.term {
self.commit(on_majority);
}
}
}
The commit rule of chapter 3 committed a record as soon as a majority had stored it.
advance adds a condition, that the record at the majority position must belong to
the leader’s own term, and the reason for it is the subtlest point in Raft. A record
of an earlier term can still be replaced even when a majority holds it, if a member
that lacks it comes to be elected by members that lack it too, which remains possible
for as long as no record of the current term is stored on a majority. Counting
replicas is therefore safe only for records of the current term. Records of earlier
terms are committed indirectly, at the moment when a record of the current term at a
later sequence is committed.
It is here that the markers perform their second function. A new leader appends a marker as the first record of its term, and as soon as a majority has stored it, the marker and every record before it are committed together. Without markers, the records inherited from earlier terms would remain uncommitted until the first write of a client in the new term happened to arrive.
The clock and the messages
The library reads no clock and opens no connection. Its caller invokes tick at a
fixed interval and handle for every incoming message, and sends whatever messages
either method returns. As in chapter 3, this separation is what makes the algorithm
testable, since a test can then supply the clock and the network itself.
Listing 4.20 The clock: heartbeats for a leader, the election timeout for the others. raft/src/lib.rs
impl Member {
/// Advances the member's clock by one tick. A leader sends every
/// follower a message. A follower or a candidate that has waited for
/// its election timeout stands for election.
pub fn tick(&mut self) -> io::Result<Vec<Outgoing>> {
self.now += 1;
if self.role == Role::Leader {
return Ok(self.replicate());
}
if self.now - self.quiet_since >= election_timeout(self.id, self.term) {
return self.stand();
}
Ok(Vec::new())
}
/// A message for every follower: the records it lacks, or a heartbeat.
pub fn replicate(&self) -> Vec<Outgoing> {
self.peers
.iter()
.filter_map(|&peer| self.append_for(peer))
.collect()
}
fn append_for(&self, peer: NodeId) -> Option<Outgoing> {
let from = self
.progress
.get(&peer)?
.next
.min(self.last_sequence());
let request = AppendRequest {
term: self.term,
leader: self.id,
previous_sequence: from,
previous_term: self.term_at(from),
records: self.records[from as usize..].to_vec(),
committed_sequence: self.committed,
};
Some(Outgoing {
to: peer,
message: Message::Append(request),
})
}
}
Listing 4.21 Dispatching a message. raft/src/lib.rs
impl Member {
/// Processes a message and returns the messages to send in response.
pub fn handle(&mut self, message: Message) -> io::Result<Vec<Outgoing>> {
match message {
Message::Append(request) => {
let to = request.leader;
let answer = self.on_append(request)?;
Ok(vec![Outgoing {
to,
message: Message::Appended(answer),
}])
}
Message::Vote(request) => {
let to = request.candidate;
let answer = self.on_vote(request)?;
Ok(vec![Outgoing {
to,
message: Message::Voted(answer),
}])
}
Message::Appended(answer) => self.on_appended(answer),
Message::Voted(answer) => self.on_voted(answer),
}
}
}
Listing 4.22 The operations for client requests. raft/src/lib.rs
impl Member {
/// Whether this member is the leader and every record of its log is
/// committed, so that it can accept a write.
pub fn is_settled(&self) -> bool {
self.role == Role::Leader && self.committed == self.last_sequence()
}
/// Checks the condition of a write and appends its record to the log.
/// The caller sends the messages of `replicate` afterwards.
pub fn propose(&mut self, change: Change, condition: Condition) -> Result<u64, StoreError> {
let key = match &change {
Change::Put { key, .. } | Change::Delete { key } => key,
};
let current = self
.entries
.get(key)
.map(|entry| entry.version);
if matches!(change, Change::Delete { .. }) && current.is_none() {
return Err(StoreError::NotFound);
}
check(condition, current)?;
let record = self.log.append(change)?;
let sequence = record.sequence;
self.records.push(record);
self.terms.push(self.term);
Ok(sequence)
}
/// Whether this member may answer a read: it is the leader, the marker
/// of its term is committed, and a majority of the members, itself
/// included, has answered within the last 10 ticks.
pub fn can_read(&self) -> bool {
let recent = self
.progress
.values()
.filter(|progress| self.now - progress.heard < 10)
.count();
self.role == Role::Leader
&& self.term_at(self.committed) == self.term
&& recent + 1 >= self.majority()
}
pub fn get(&self, key: &str) -> Option<&Entry> {
self.entries.get(key)
}
}
can_read permits a read when the member is the leader, when the marker of its term
is committed, and when a majority of the members has answered within the last 10
ticks. The second of these conditions ensures that the entries reflect every record
that was committed in earlier terms.
Simulating crashes and lost messages
Because the library is free of clocks and connections, a test has control of all of
them. raftsim runs 3 members in a single process. It decides when the clock of each
member ticks, which message is delivered next, which messages are lost, and when a
member crashes and restarts.
Listing 4.23 The imports of
raftsim/src/main.rs. raftsim/src/main.rs
use std::collections::BTreeMap;
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use kv::{Change, Condition};
use raft::{Member, NodeId, Outgoing, Role};
Listing 4.24 A repeatable source of pseudo-random numbers. raftsim/src/main.rs
/// A generator of pseudo-random numbers. The same seed gives the same
/// numbers, so a run that fails can be repeated exactly.
struct Random(u64);
impl Random {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
/// A number below `bound`.
fn below(&mut self, bound: usize) -> usize {
(self.next() % bound as u64) as usize
}
}
Listing 4.25 The cluster: members, messages in transit, and writes. raftsim/src/main.rs
const MEMBERS: [NodeId; 3] = [1, 2, 3];
/// A write that a leader has accepted and not yet committed.
struct Pending {
leader: NodeId,
term: u64,
sequence: u64,
key: String,
}
struct Cluster {
root: PathBuf,
/// The members that are running. A crashed member is absent.
up: BTreeMap<NodeId, Member>,
/// The messages that are on their way.
network: Vec<Outgoing>,
pending: Vec<Pending>,
/// The sequence and the key of every acknowledged write.
acknowledged: Vec<(u64, String)>,
/// The leader that was seen in each term.
leaders: BTreeMap<u64, NodeId>,
discarded: u64,
writes: u64,
}
impl Cluster {
fn open(root: &Path) -> Result<Cluster, Box<dyn Error>> {
let mut cluster = Cluster {
root: root.to_owned(),
up: BTreeMap::new(),
network: Vec::new(),
pending: Vec::new(),
acknowledged: Vec::new(),
leaders: BTreeMap::new(),
discarded: 0,
writes: 0,
};
for id in MEMBERS {
cluster.start(id)?;
}
Ok(cluster)
}
fn start(&mut self, id: NodeId) -> Result<(), Box<dyn Error>> {
let peers = MEMBERS
.iter()
.copied()
.filter(|&peer| peer != id)
.collect();
let member = Member::open(
&self
.root
.join(format!("member-{id}")),
id,
peers,
false,
)?;
self.up.insert(id, member);
Ok(())
}
/// Stops a member. Its files remain, and its memory is lost.
fn crash(&mut self, id: NodeId) {
if let Some(member) = self.up.remove(&id) {
self.discarded += member.discarded();
}
}
fn tick(&mut self, id: NodeId) -> Result<(), Box<dyn Error>> {
if let Some(member) = self.up.get_mut(&id) {
self.network.extend(member.tick()?);
}
self.check()
}
/// Delivers the message at `index`. A message for a member that is
/// down is lost.
fn deliver(&mut self, index: usize) -> Result<(), Box<dyn Error>> {
let Outgoing { to, message } = self.network.remove(index);
if let Some(member) = self.up.get_mut(&to) {
self.network
.extend(member.handle(message)?);
}
self.check()
}
/// Delivers one of the messages that are on their way, chosen at random.
fn deliver_any(&mut self, random: &mut Random) -> Result<(), Box<dyn Error>> {
if self.network.is_empty() {
return Ok(());
}
self.deliver(random.below(self.network.len()))
}
/// Loses one of the messages that are on their way.
fn lose_any(&mut self, random: &mut Random) {
if !self.network.is_empty() {
self.network
.remove(random.below(self.network.len()));
}
}
/// Starts a member that is down.
fn restart(&mut self, id: NodeId) -> Result<(), Box<dyn Error>> {
if self.up.contains_key(&id) {
return Ok(());
}
self.start(id)
}
/// Delivers messages until none is on its way.
fn deliver_all(&mut self) -> Result<(), Box<dyn Error>> {
while !self.network.is_empty() {
self.deliver(0)?;
}
Ok(())
}
/// Sends a write to a leader that can accept one, if there is one.
fn write(&mut self) -> Result<bool, Box<dyn Error>> {
let Some(leader) = self
.up
.values_mut()
.find(|member| member.is_settled())
else {
return Ok(false);
};
self.writes += 1;
let key = format!("key-{}", self.writes);
let change = Change::Put {
key: key.clone(),
value: b"1".to_vec(),
};
let sequence = leader.propose(change, Condition::Always)?;
self.pending.push(Pending {
leader: leader.id(),
term: leader.term(),
sequence,
key,
});
self.network
.extend(leader.replicate());
Ok(true)
}
fn leader(&self) -> Option<&Member> {
self.up
.values()
.filter(|member| member.role() == Role::Leader)
.max_by_key(|member| member.term())
}
}
After every step, raftsim checks 3 properties.
Listing 4.26 The properties checked after every step. raftsim/src/main.rs
impl Cluster {
/// The properties that must hold after every step.
fn check(&mut self) -> Result<(), Box<dyn Error>> {
// No 2 members lead the same term.
for member in self
.up
.values()
.filter(|member| member.role() == Role::Leader)
{
let first = *self
.leaders
.entry(member.term())
.or_insert(member.id());
if first != member.id() {
return Err(format!(
"members {first} and {} both lead term {}",
member.id(),
member.term()
)
.into());
}
}
// A write is acknowledged when the leader that accepted it, still
// leading the same term, has committed its record.
let up = &self.up;
let committed = |write: &Pending| {
up.get(&write.leader)
.is_some_and(|leader| {
leader.role() == Role::Leader
&& leader.term() == write.term
&& leader.committed() >= write.sequence
})
};
let (done, waiting): (Vec<Pending>, Vec<Pending>) = self
.pending
.drain(..)
.partition(committed);
self.acknowledged.extend(
done.into_iter()
.map(|write| (write.sequence, write.key)),
);
self.pending = waiting;
// Every member that has committed the sequence of an acknowledged
// write holds that write at that sequence.
for (sequence, key) in &self.acknowledged {
for member in self
.up
.values()
.filter(|member| member.committed() >= *sequence)
{
let holds = matches!(&member.records()[*sequence as usize - 1].change, Change::Put { key: held, .. } if held == key);
if !holds {
return Err(format!(
"member {} lost the acknowledged write of {key}",
member.id()
)
.into());
}
}
}
Ok(())
}
}
A write counts as acknowledged under exactly the condition that the node itself applies: the member that accepted it has committed its record while still leading the same term.
Listing 4.27 A scripted election, a crashed leader, and the repair of its log. raftsim/src/main.rs
fn show(cluster: &Cluster) -> String {
let describe = |id: &NodeId| match cluster.up.get(id) {
Some(member) => format!(
"{id}: {:?} term {} log {}",
member.role(),
member.term(),
member.last_sequence()
),
None => format!("{id}: down"),
};
MEMBERS
.iter()
.map(describe)
.collect::<Vec<_>>()
.join(" | ")
}
/// Ticks every running member, delivering all messages after each tick,
/// until a member leads. It returns the number of ticks.
fn elect(cluster: &mut Cluster) -> Result<u64, Box<dyn Error>> {
let mut ticks = 0;
while cluster
.up
.values()
.all(|member| !member.is_settled())
{
ticks += 1;
for id in MEMBERS {
cluster.tick(id)?;
}
cluster.deliver_all()?;
}
Ok(ticks)
}
fn cases(root: &Path) -> Result<(), Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
println!("start: {}", show(&cluster));
let ticks = elect(&mut cluster)?;
println!("after {ticks:2} ticks: {}", show(&cluster));
for _ in 0..3 {
cluster.write()?;
cluster.deliver_all()?;
}
println!(
"after 3 writes: {}, {} acknowledged",
show(&cluster),
cluster.acknowledged.len()
);
// The leader accepts a write and crashes before it sends the record.
let old = cluster
.leader()
.map(Member::id)
.ok_or("no leader")?;
cluster.write()?;
cluster.network.clear();
cluster.crash(old);
println!(
"leader {old} appends record 5 and crashes: {}",
show(&cluster)
);
let ticks = elect(&mut cluster)?;
println!("after {ticks:2} ticks: {}", show(&cluster));
cluster.write()?;
cluster.deliver_all()?;
println!(
"after 1 write: {}, {} acknowledged",
show(&cluster),
cluster.acknowledged.len()
);
cluster.start(old)?;
println!("member {old} returns: {}", show(&cluster));
for _ in 0..2 {
for id in MEMBERS {
cluster.tick(id)?;
}
cluster.deliver_all()?;
}
let discarded = cluster
.up
.get(&old)
.map_or(0, Member::discarded);
println!("after 2 ticks: {}", show(&cluster));
println!(
"member {old} discarded {discarded} record that was never committed, and holds the leader's records in its place"
);
Ok(())
}
Listing 4.28 200 random runs, and the recovery at the end of each. raftsim/src/main.rs
const RUNS: u64 = 200;
const STEPS: usize = 600;
/// One run: random ticks, deliveries, losses, crashes, restarts and writes.
fn run(root: &Path, seed: u64) -> Result<Cluster, Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
let mut random = Random(seed);
for _ in 0..STEPS {
let id = MEMBERS[random.below(MEMBERS.len())];
match random.below(100) {
0..40 => cluster.tick(id)?,
40..82 => cluster.deliver_any(&mut random)?,
82..87 => cluster.lose_any(&mut random),
87..90 => cluster.crash(id),
90..94 => cluster.restart(id)?,
94.. => cluster.write().map(drop)?,
}
}
recover(&mut cluster)?;
Ok(cluster)
}
/// Ends the faults: every member runs and every message arrives. The
/// members must then agree on a leader and on a log that holds every
/// acknowledged write.
fn recover(cluster: &mut Cluster) -> Result<(), Box<dyn Error>> {
for id in MEMBERS {
if !cluster.up.contains_key(&id) {
cluster.start(id)?;
}
}
let agreed = |cluster: &Cluster| {
cluster
.leader()
.is_some_and(|leader| {
leader.is_settled()
&& cluster
.up
.values()
.all(|member| member.records() == leader.records())
})
};
for _ in 0..200 {
if agreed(cluster) {
break;
}
for id in MEMBERS {
cluster.tick(id)?;
}
cluster.deliver_all()?;
}
if !agreed(cluster) {
return Err("the members did not agree after the faults ended".into());
}
let leader = cluster
.leader()
.ok_or("the members have no leader after the faults ended")?;
for (sequence, key) in &cluster.acknowledged {
if leader
.get(key)
.is_none_or(|entry| entry.version != *sequence)
{
return Err(
format!("the acknowledged write of {key} is missing after recovery").into(),
);
}
}
Ok(())
}
fn runs(root: &Path) -> Result<(), Box<dyn Error>> {
let (mut leaders, mut acknowledged, mut discarded, mut most_terms) = (0, 0, 0, 0);
for seed in 1..=RUNS {
let directory = root.join(format!("run-{seed}"));
let mut cluster = run(&directory, seed)?;
for id in MEMBERS {
cluster.crash(id);
}
leaders += cluster.leaders.len();
acknowledged += cluster.acknowledged.len();
discarded += cluster.discarded;
most_terms = most_terms.max(
cluster
.leaders
.keys()
.next_back()
.copied()
.unwrap_or(0),
);
fs::remove_dir_all(&directory)?;
}
println!("{RUNS} runs of {STEPS} random steps:");
println!(" {leaders} leaders were elected, and no term had 2 leaders");
println!(" {acknowledged} writes were acknowledged, and every one was present after recovery");
println!(
" {discarded} uncommitted records were discarded by members whose log differed from a leader's"
);
println!(" the highest term reached in a run was {most_terms}");
Ok(())
}
Each run takes 600 random steps. A step ticks the clock of a member (40%), delivers a
message (42%), loses a message (5%), crashes a member (3%), restarts a member (4%) or
sends a write (6%). At the end of a run, all members are started, all messages are
delivered, and raftsim verifies that the members agree on a leader and on a log, and
that the leader returns every acknowledged write.
Listing 4.29 Running the simulation. raftsim/src/main.rs
fn main() -> Result<(), Box<dyn Error>> {
let root = env::temp_dir().join(format!("raftsim-{}", process::id()));
let result = cases(&root.join("cases")).and_then(|()| runs(&root));
fs::remove_dir_all(&root)?;
result
}
$ cargo run --release -p raftsim
start: 1: Follower term 0 log 0 | 2: Follower term 0 log 0 | 3: Follower term 0 log 0
after 11 ticks: 1: Follower term 1 log 1 | 2: Follower term 1 log 1 | 3: Leader term 1 log 1
after 3 writes: 1: Follower term 1 log 4 | 2: Follower term 1 log 4 | 3: Leader term 1 log 4, 3 acknowledged
leader 3 appends record 5 and crashes: 1: Follower term 1 log 4 | 2: Follower term 1 log 4 | 3: down
after 10 ticks: 1: Leader term 2 log 5 | 2: Follower term 2 log 5 | 3: down
after 1 write: 1: Leader term 2 log 6 | 2: Follower term 2 log 6 | 3: down, 4 acknowledged
member 3 returns: 1: Leader term 2 log 6 | 2: Follower term 2 log 6 | 3: Follower term 1 log 5
after 2 ticks: 1: Leader term 2 log 6 | 2: Follower term 2 log 6 | 3: Follower term 2 log 6
member 3 discarded 1 record that was never committed, and holds the leader's records in its place
200 runs of 600 random steps:
480 leaders were elected, and no term had 2 leaders
504 writes were acknowledged, and every one was present after recovery
18 uncommitted records were discarded by members whose log differed from a leader's
the highest term reached in a run was 8
| Output | What it shows |
|---|---|
| after 11 ticks: member 3 is Leader of term 1, all logs at 1 | Member 3 has the shortest timeout in term 0. Record 1 is the term marker. |
| leader 3 appends record 5 and crashes: the others have 4 records | Record 5 exists on member 3 only and was not acknowledged. |
| after 10 ticks: member 1 is Leader of term 2, logs at 5 | Member 1 was elected by itself and member 2. Its record 5 is the marker of term 2. |
| member 3 returns: Follower, term 1, log 5 | Member 3 holds a record 5 that differs from the leader’s record 5. |
| after 2 ticks: member 3 at term 2, log 6; it discarded 1 record | The repair removed the uncommitted record and installed the leader’s records. |
| 480 leaders were elected, and no term had 2 leaders | Invariant 5 held at every step of every run. |
| 504 writes were acknowledged, and every one was present after recovery | Invariant 6 held. |
| 18 uncommitted records were discarded | The runs produced logs that differed, and the repair resolved them without the loss of an acknowledged write. |
Checking the simulation
A simulation that reports no failure is informative only if it would have reported one for an incorrect implementation, and this is a question that can be put to the test. We disabled 3 rules of Raft in turn and ran the simulation against each modified library.
| Rule disabled | Result |
|---|---|
| A member votes only for a candidate whose log is at least as complete as its own. | detected: member 2 lost acknowledged write of key-1 |
| A member votes at most once in a term. | detected: members 1 and 3 both lead term 4 |
| The leader commits by counting only for records of its own term. | not detected: all 200 runs passed |
The third result has to be stated with care. The correctness of Raft requires this condition, and the implementation contains it. With 3 members and 200 runs, however, the simulation did not produce a sequence of events in which the absence of the condition leads to an error. In the paper on Raft, such a sequence involves 5 members and several successive failures of leaders at particular moments. The simulation therefore provides evidence for the voting rules and for the repair of logs, and provides none for this condition. It would have been easy to report “200 runs passed” and to leave the reader with the impression that everything had been verified, and the difference between what a test exercises and what it merely fails to contradict is one that this book tries to keep in view.
Design overview
Low-level design: leader election
Crates
| Crate | Kind | Contents |
|---|---|---|
raft | library | Member: roles, voting, the repair of logs, the commit rule, with no network and no clock |
raftsim | program | the simulation |
kvraft | program | a node: rpc (both gRPC services), node (the clock, sending messages, writes and reads), main (the HTTP API) |
probe | program | a client that measures the longest pause between acknowledged writes |
Rules of a member
| Event | Action |
|---|---|
| Any message with a higher term | adopt the term, clear the vote, become a follower |
Append with a lower term | refuse, and report the member’s own term |
Append whose previous_sequence is beyond the end of the log | refuse, and report the end of the log |
Append whose previous_term differs from the term of the member’s record at that sequence | refuse, and report the sequence before it |
Append that matches | skip identical records, remove the first differing record and everything after it, append the rest, apply up to the committed sequence |
RequestVote | grant if the term is current, no other vote was given in this term, and the candidate’s log ends at least as far |
| No message from a leader for the election timeout | increase the term, vote for itself, send RequestVote to all members |
| Votes from a majority | become the leader, append the term marker, send Append to all members |
Timing
| Quantity | Value |
|---|---|
| Tick | 100 ms |
| Heartbeat interval of a leader | 1 tick |
| Election timeout | 10 to 19 ticks, computed from the member’s id and term |
| Time a write waits for its record to be committed | 1 s |
The node
kvraft connects the library to a clock, to a network and to an HTTP API. All
members are started with the same options, apart from their ids and addresses.
Listing 4.30 The imports of
kvraft/src/main.rs. kvraft/src/main.rs
use std::collections::BTreeMap;
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::Parser;
use kv::{Change, Condition};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, version_header, write_condition};
use raft::{Member, NodeId, Role};
use serde::Serialize;
use tokio::net::TcpListener;
use tonic::transport::Server;
use crate::node::Node;
use crate::rpc::{ElectionServer, ReplicationServer, Service};
Listing 4.31 The options of the command line. kvraft/src/main.rs
/// Reads `2=http://127.0.0.1:7102` as member 2 at that address.
fn parse_peer(text: &str) -> Result<(NodeId, String), String> {
let (id, address) = text
.split_once('=')
.ok_or("a peer is written as id=address")?;
let Ok(id) = id.parse() else {
return Err("the id of a peer is a number".to_owned());
};
Ok((id, address.to_owned()))
}
#[derive(Parser)]
struct Options {
/// The id of this member, a number that no other member has.
#[arg(long)]
id: NodeId,
/// The address of the HTTP API.
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
/// The address on which this member accepts messages from the others.
#[arg(long, default_value = "127.0.0.1:7101")]
replication: SocketAddr,
/// Another member and its replication address, as id=address.
#[arg(long = "peer", value_parser = parse_peer)]
peers: Vec<(NodeId, String)>,
#[arg(long, default_value = "data")]
data: PathBuf,
/// Respond without waiting for the disk.
#[arg(long)]
no_sync: bool,
}
Listing 4.32 The imports of
kvraft/src/rpc.rs. kvraft/src/rpc.rs
use std::sync::Arc;
use kv::{Change, Record};
use raft::{AppendAnswer, AppendRequest, Message, NodeId, VoteAnswer, VoteRequest};
use tonic::{Request, Response, Status};
use crate::node::Node;
Listing 4.33 Conversion between the generated types and the library's messages. kvraft/src/rpc.rs
fn record_to_proto(record: &Record) -> proto::Record {
let change = match record.change.clone() {
Change::Put { key, value } => proto::record::Change::Put(proto::Put { key, value }),
Change::Delete { key } => proto::record::Change::Delete(proto::Delete { key }),
};
proto::Record {
sequence: record.sequence,
change: Some(change),
}
}
fn record_from_proto(record: proto::Record) -> Result<Record, Status> {
let change = match record.change {
Some(proto::record::Change::Put(put)) => Change::Put {
key: put.key,
value: put.value,
},
Some(proto::record::Change::Delete(delete)) => Change::Delete { key: delete.key },
None => return Err(Status::invalid_argument("a record without a change")),
};
Ok(Record {
sequence: record.sequence,
change,
})
}
impl From<AppendRequest> for proto::AppendRequest {
fn from(request: AppendRequest) -> Self {
proto::AppendRequest {
shard: 0,
term: request.term,
previous_sequence: request.previous_sequence,
records: request
.records
.iter()
.map(record_to_proto)
.collect(),
committed_sequence: request.committed_sequence,
previous_term: request.previous_term,
leader: request.leader,
}
}
}
impl TryFrom<proto::AppendRequest> for AppendRequest {
type Error = Status;
fn try_from(request: proto::AppendRequest) -> Result<Self, Status> {
Ok(AppendRequest {
term: request.term,
leader: request.leader,
previous_sequence: request.previous_sequence,
previous_term: request.previous_term,
records: request
.records
.into_iter()
.map(record_from_proto)
.collect::<Result<_, _>>()?,
committed_sequence: request.committed_sequence,
})
}
}
impl From<VoteRequest> for proto::VoteRequest {
fn from(request: VoteRequest) -> Self {
proto::VoteRequest {
term: request.term,
candidate: request.candidate,
last_sequence: request.last_sequence,
last_term: request.last_term,
}
}
}
/// The answer of member `from` to an `Append` call, as a message.
pub fn appended(from: NodeId, answer: proto::AppendResponse) -> Message {
Message::Appended(AppendAnswer {
term: answer.term,
from,
appended: answer.appended,
last_sequence: answer.last_sequence,
})
}
/// The answer of member `from` to a `RequestVote` call, as a message.
pub fn voted(from: NodeId, answer: proto::VoteResponse) -> Message {
Message::Voted(VoteAnswer {
term: answer.term,
from,
granted: answer.granted,
})
}
Each gRPC call delivers 1 message to a member, and the member’s answer is returned as the response to the call.
Listing 4.34 The server side of
Append and RequestVote. kvraft/src/rpc.rs
/// The server side of both services. Each call is one message for the
/// member, and the member's answer is the response of the call.
pub struct Service {
pub node: Arc<Node>,
}
#[tonic::async_trait]
impl Replication for Service {
async fn append(
&self,
request: Request<proto::AppendRequest>,
) -> Result<Response<proto::AppendResponse>, Status> {
let request = AppendRequest::try_from(request.into_inner())?;
let answer = self
.node
.answer(Message::Append(request))
.map_err(|error| Status::unavailable(error.to_string()))?;
let Message::Appended(answer) = answer else {
return Err(Status::internal(
"the member answered with another kind of message",
));
};
Ok(Response::new(proto::AppendResponse {
term: answer.term,
appended: answer.appended,
last_sequence: answer.last_sequence,
}))
}
}
#[tonic::async_trait]
impl Election for Service {
async fn request_vote(
&self,
request: Request<proto::VoteRequest>,
) -> Result<Response<proto::VoteResponse>, Status> {
let request = request.into_inner();
let request = VoteRequest {
term: request.term,
candidate: request.candidate,
last_sequence: request.last_sequence,
last_term: request.last_term,
};
let answer = self
.node
.answer(Message::Vote(request))
.map_err(|error| Status::unavailable(error.to_string()))?;
let Message::Voted(answer) = answer else {
return Err(Status::internal(
"the member answered with another kind of message",
));
};
Ok(Response::new(proto::VoteResponse {
term: answer.term,
granted: answer.granted,
}))
}
}
Around the member, a node consists of a small loop, which is shown in figure 4.elect-node-loop.
Listing 4.35 The imports of
kvraft/src/node.rs. kvraft/src/node.rs
use std::collections::BTreeMap;
use std::io;
use std::sync::Arc;
use std::time::Duration;
use kv::{Change, Condition, Entry, Written};
use kvd::api::ApiError;
use parking_lot::Mutex;
use raft::{Member, Message, NodeId, Outgoing, Role};
use tokio::sync::watch;
use tokio::task;
use tokio::time::{interval, timeout};
use tonic::transport::{Channel, Endpoint};
use crate::rpc::{ElectionClient, ReplicationClient, appended, proto, voted};
Listing 4.36 The state of a node. kvraft/src/node.rs
/// The length of a tick of the member's clock.
pub const TICK: Duration = Duration::from_millis(100);
/// How long a member waits for the answer to one message.
const ANSWER: Duration = Duration::from_millis(500);
/// How long a write waits for its record to be committed.
const COMMIT: Duration = Duration::from_secs(1);
pub struct Node {
pub member: Mutex<Member>,
/// A connection to each of the other members.
peers: BTreeMap<NodeId, Channel>,
/// Held by the write that is in progress.
turn: tokio::sync::Mutex<()>,
/// The committed sequence, for writes that wait for it to advance.
committed: watch::Sender<u64>,
}
impl Node {
pub fn new(
member: Member,
addresses: BTreeMap<NodeId, String>,
) -> Result<Arc<Node>, tonic::transport::Error> {
let mut peers = BTreeMap::new();
for (id, address) in addresses {
peers.insert(
id,
Endpoint::from_shared(address)?
.connect_timeout(ANSWER)
.connect_lazy(),
);
}
Ok(Arc::new(Node {
member: Mutex::new(member),
peers,
turn: tokio::sync::Mutex::new(()),
committed: watch::Sender::new(0),
}))
}
}
with_member is the single point of entry to the member. It takes the lock, runs the
operation it is given, and publishes the committed sequence afterwards, so that no
operation can change that sequence without the waiting writes being informed.
Listing 4.37 Running an operation on the member. kvraft/src/node.rs
impl Node {
/// Runs one operation on the member and publishes the committed
/// sequence. The member waits for the disk, so the operation runs where
/// blocking is allowed.
fn with_member<T>(&self, operation: impl FnOnce(&mut Member) -> T) -> T {
task::block_in_place(|| {
let mut member = self.member.lock();
let result = operation(&mut member);
self.committed
.send_replace(member.committed());
result
})
}
}
dispatch starts 1 task for each message, and send performs a gRPC call with a
time limit. Each response is converted into the corresponding answer message and
handed to deliver, which passes it to the member and dispatches whatever the member
returns. A call that fails or times out produces no message at all, which Raft
tolerates without any special provision, since the next tick sends a new message to
the same member.
Listing 4.38 Sending a message and delivering the answer. kvraft/src/node.rs
impl Node {
/// Sends each message in a task of its own, so that a member that does
/// not answer delays nothing else.
fn dispatch(self: &Arc<Node>, messages: Vec<Outgoing>) {
for message in messages {
tokio::spawn(Arc::clone(self).send(message));
}
}
/// Carries one request to a member and gives the answer to this member.
async fn send(self: Arc<Node>, outgoing: Outgoing) {
let Some(channel) = self
.peers
.get(&outgoing.to)
.cloned()
else {
return;
};
let answer = match outgoing.message {
Message::Append(request) => {
let call = async move {
ReplicationClient::new(channel)
.append(proto::AppendRequest::from(request))
.await
};
timeout(ANSWER, call)
.await
.ok()
.and_then(Result::ok)
.map(|answer| appended(outgoing.to, answer.into_inner()))
}
Message::Vote(request) => {
let call = async move {
ElectionClient::new(channel)
.request_vote(proto::VoteRequest::from(request))
.await
};
timeout(ANSWER, call)
.await
.ok()
.and_then(Result::ok)
.map(|answer| voted(outgoing.to, answer.into_inner()))
}
// An answer travels as the response of the call that asked.
Message::Appended(..) | Message::Voted(..) => None,
};
if let Some(answer) = answer {
self.deliver(answer);
}
}
/// Gives the member a message and sends what the member returns.
fn deliver(self: &Arc<Node>, message: Message) {
match self.with_member(|member| member.handle(message)) {
Ok(messages) => self.dispatch(messages),
Err(error) => eprintln!("the member could not process a message: {error}"),
}
}
}
run_clock calls tick every 100 ms. answer serves the gRPC handlers: an incoming
request is 1 message for the member, which returns exactly 1 answer to it.
Listing 4.39 The clock, and answering a request from another member. kvraft/src/node.rs
impl Node {
/// The member's answer to a request that arrived over gRPC.
pub fn answer(&self, request: Message) -> io::Result<Message> {
let mut messages = self.with_member(|member| member.handle(request))?;
messages
.pop()
.map(|outgoing| outgoing.message)
.ok_or_else(|| io::Error::other("the member gave no answer"))
}
/// Advances the member's clock, for as long as the node runs.
pub async fn run_clock(self: Arc<Node>) {
let mut ticks = interval(TICK);
loop {
ticks.tick().await;
match self.with_member(Member::tick) {
Ok(messages) => self.dispatch(messages),
Err(error) => eprintln!("the member could not advance its clock: {error}"),
}
}
}
}
Listing 4.40 Writes and reads. kvraft/src/node.rs
impl Node {
fn not_leader(member: &Member) -> ApiError {
match member.leader() {
Some(leader) => ApiError::Misdirected(format!("the leader is member {leader}")),
None => ApiError::NoLeader,
}
}
/// A write on the leader. It is acknowledged when its record is
/// committed and this member has led the same term throughout.
pub async fn write(
self: &Arc<Node>,
change: Change,
condition: Condition,
) -> Result<Written, ApiError> {
let turn = self.turn.lock().await;
let last = {
let member = self.member.lock();
if member.role() != Role::Leader {
return Err(Node::not_leader(&member));
}
member.last_sequence()
};
self.wait_for(last).await?;
let (written, term, messages) = self.with_member(|member| {
if !member.is_settled() {
return Err(Node::not_leader(member));
}
let created = match &change {
Change::Put { key, .. } | Change::Delete { key } => member.get(key).is_none(),
};
let version = member.propose(change, condition)?;
Ok((
Written { version, created },
member.term(),
member.replicate(),
))
})?;
self.dispatch(messages);
self.wait_for(written.version)
.await?;
drop(turn);
// A member that lost the leadership while it waited may hold another
// leader's record at this sequence.
let member = self.member.lock();
if member.role() == Role::Leader && member.term() == term {
Ok(written)
} else {
Err(ApiError::Unavailable(
"the leadership changed before the record was committed".into(),
))
}
}
async fn wait_for(&self, sequence: u64) -> Result<(), ApiError> {
let mut committed = self.committed.subscribe();
let reached = committed.wait_for(|&committed| committed >= sequence);
let in_time = timeout(COMMIT, reached)
.await
.is_ok_and(|reached| reached.is_ok());
if in_time {
Ok(())
} else {
Err(ApiError::Unavailable(format!(
"record {sequence} is not on a majority of the members"
)))
}
}
pub fn get(&self, key: &str) -> Result<Entry, ApiError> {
let member = self.member.lock();
if member.role() != Role::Leader {
return Err(Node::not_leader(&member));
}
if !member.can_read() {
return Err(ApiError::Unavailable(
"this leader cannot confirm that it still leads".into(),
));
}
member
.get(key)
.cloned()
.ok_or(ApiError::NotFound)
}
}
The write path has the structure of chapter 3, with 1 addition after the wait. If a
member loses its leadership while a write is waiting, its uncommitted record may be
replaced by a record of the new leader at the same sequence, and the committed
sequence may then pass that position without the write having been stored at all. A
handler therefore acknowledges a write only if its member still leads the term in
which the record was appended. Otherwise it answers 503, and the outcome of the
write is unknown to its client, to be resolved as chapter 3 described.
Any member that is not the leader answers 421 Misdirected Request and names the
leader it knows of in the detail field.
Listing 4.41 The HTTP handlers and the health of a node. kvraft/src/main.rs
async fn put_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
let written = node
.write(
Change::Put {
key,
value: body.to_vec(),
},
condition,
)
.await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
node.write(Change::Delete { key }, condition)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let cached = version_header(&headers, IF_NONE_MATCH)?;
let entry = node.get(&key)?;
let tag = [(ETAG, etag(entry.version))];
if cached == Some(Condition::Version(entry.version)) {
return Ok((StatusCode::NOT_MODIFIED, tag).into_response());
}
Ok((
tag,
[(CONTENT_TYPE, "application/octet-stream")],
entry.value,
)
.into_response())
}
#[derive(Serialize)]
struct Health {
id: NodeId,
role: &'static str,
term: u64,
/// The leader of the term, when this member knows it.
leader: Option<NodeId>,
last_sequence: u64,
committed: u64,
}
async fn health(State(node): State<Arc<Node>>) -> Json<Health> {
let member = node.member.lock();
let role = match member.role() {
Role::Leader => "leader",
Role::Candidate => "candidate",
Role::Follower => "follower",
};
Json(Health {
id: member.id(),
role,
term: member.term(),
leader: member.leader(),
last_sequence: member.last_sequence(),
committed: member.committed(),
})
}
Listing 4.42 Starting a node. kvraft/src/main.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let peers: BTreeMap<NodeId, String> = options.peers.into_iter().collect();
let member = Member::open(
&options.data,
options.id,
peers.keys().copied().collect(),
!options.no_sync,
)?;
println!(
"member {}, term {}, log ends at {}",
member.id(),
member.term(),
member.last_sequence()
);
let node = Node::new(member, peers)?;
let replication = ReplicationServer::new(Service {
node: Arc::clone(&node),
});
let election = ElectionServer::new(Service {
node: Arc::clone(&node),
});
tokio::spawn(
Server::builder()
.add_service(replication)
.add_service(election)
.serve(options.replication),
);
tokio::spawn(Arc::clone(&node).run_clock());
let routes = Router::new()
.route(
"/v1/keys/{key}",
get(get_key)
.put(put_key)
.delete(delete_key),
)
.route("/healthz", get(health))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(node);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Running 3 nodes
$ ./target/release/kvraft --id 1 --listen 127.0.0.1:7001 --replication 127.0.0.1:7101 --data /var/tmp/m1 \
--peer 2=http://127.0.0.1:7102 --peer 3=http://127.0.0.1:7103 &
member 1, term 0, log ends at 0
listening on 127.0.0.1:7001
$ ./target/release/kvraft --id 2 --listen 127.0.0.1:7002 --replication 127.0.0.1:7102 --data /var/tmp/m2 \
--peer 1=http://127.0.0.1:7101 --peer 3=http://127.0.0.1:7103 &
$ ./target/release/kvraft --id 3 --listen 127.0.0.1:7003 --replication 127.0.0.1:7103 --data /var/tmp/m3 \
--peer 1=http://127.0.0.1:7101 --peer 2=http://127.0.0.1:7102 &
$ for port in 7001 7002 7003; do curl http://127.0.0.1:$port/healthz; echo; done
{"id":1,"role":"follower","term":1,"leader":3,"last_sequence":1,"committed":1}
{"id":2,"role":"follower","term":1,"leader":3,"last_sequence":1,"committed":1}
{"id":3,"role":"leader","term":1,"leader":3,"last_sequence":1,"committed":1}
All 3 members were started without any indication of who should lead. Within 2 seconds, member 3 had been elected in term 1. Every log contains 1 record, the marker of term 1.
$ curl -i -X PUT http://127.0.0.1:7003/v1/keys/user-1 --data-binary 'ada'
HTTP/1.1 201 Created
etag: "2"
content-length: 0
$ curl -i -X PUT http://127.0.0.1:7001/v1/keys/user-1 --data-binary 'x'
HTTP/1.1 421 Misdirected Request
content-type: application/problem+json
content-length: 116
{"type":"/problems/not-leader","title":"This node is not the leader","status":421,"detail":"the leader is member 3"}
The first write of a client receives version 2, since record 1 is the term marker. Versions remain strictly increasing, which is all that a client is entitled to rely upon.
Experiments
As in chapter 3, the 3 nodes run on 1 machine and share its disk.
The leader is lost
probe writes keys continuously for a fixed time. When a node refuses a write or
fails to respond, probe sends the write to the next node. It records every
acknowledged key, and the longest interval between 2 consecutive acknowledged writes.
Listing 4.43 The imports of
probe/src/main.rs. probe/src/main.rs
use std::error::Error;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
use clap::Parser;
use ureq::Agent;
Listing 4.44 Measuring the longest pause between acknowledged writes. probe/src/main.rs
fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let agent: Agent = Agent::config_builder()
.timeout_global(Some(Duration::from_secs(2)))
.build()
.into();
let mut file = BufWriter::new(File::create(&options.acked)?);
let started = Instant::now();
let (mut server, mut number, mut refused) = (0, 1, 0);
let (mut last_success, mut longest, mut longest_at) = (started, Duration::ZERO, Duration::ZERO);
while started.elapsed() < Duration::from_secs(options.seconds) {
let key = format!("key-{number:06}");
let address = format!("{}/v1/keys/{key}", options.servers[server]);
if agent
.put(&address)
.send(format!("value of {key}"))
.is_err()
{
// This node did not accept the write. Try the next one.
refused += 1;
server = (server + 1) % options.servers.len();
thread::sleep(Duration::from_millis(20));
continue;
}
let pause = last_success.elapsed();
if pause > longest {
(longest, longest_at) = (pause, started.elapsed());
}
last_success = Instant::now();
writeln!(file, "{key}")?;
number += 1;
}
file.flush()?;
println!(
"{} writes acknowledged in {} seconds, {refused} attempts refused",
number - 1,
options.seconds
);
println!(
"the longest pause between 2 acknowledged writes was {:.2} s, ending {:.1} s after the start",
longest.as_secs_f64(),
longest_at.as_secs_f64()
);
Ok(())
}
The leader is killed 6 seconds into a run of 20 seconds.
$ ./target/release/probe --seconds 20 --acked acked.txt \
--server http://127.0.0.1:7001 --server http://127.0.0.1:7002 --server http://127.0.0.1:7003 &
$ sleep 6; kill -9 <pid of member 3, the leader>
$ sleep 3; for port in 7001 7002 7003; do curl http://127.0.0.1:$port/healthz; echo; done
{"id":1,"role":"leader","term":2,"leader":1,"last_sequence":610,"committed":609}
{"id":2,"role":"follower","term":2,"leader":1,"last_sequence":611,"committed":610}
curl: (7) Failed to connect to 127.0.0.1 port 7003
1443 writes acknowledged in 20 seconds, 51 attempts refused
the longest pause between 2 acknowledged writes was 1.05 s, ending 7.1 s after the start
$ ./target/release/kvctl verify --server http://127.0.0.1:7001 --acked acked.txt
1443 acknowledged, 1443 found, 0 missing
| Output | What it shows |
|---|---|
| member 1: leader, term 2 | A new leader was elected without any action by an operator. |
| longest pause was 1.05 s | Writes were unavailable for about 1 second, which is the election timeout of member 1 in term 1 (10 ticks) together with the exchange of votes and of the term marker. |
| 51 attempts refused | During the pause, probe tried the nodes in turn and was refused until member 1 had been elected. |
| 1443 acknowledged, 1443 found, 0 missing | No acknowledged write was lost. |
The measured pause can be set against the availability allowance of chapter 1. For the shards led by 1 node of 24, a pause of 1 second consumes about 0.04 seconds of a monthly allowance of 1296. The promotion by hand of chapter 3 would have consumed minutes.
The former leader returns
The member that was killed is started again with its data directory intact, which chapter 3 did not permit.
$ ./target/release/kvraft --id 3 --listen 127.0.0.1:7003 --replication 127.0.0.1:7103 --data /var/tmp/m3 \
--peer 1=http://127.0.0.1:7101 --peer 2=http://127.0.0.1:7102 &
$ for port in 7001 7002 7003; do curl http://127.0.0.1:$port/healthz; echo; done
{"id":1,"role":"leader","term":2,"leader":1,"last_sequence":1447,"committed":1447}
{"id":2,"role":"follower","term":2,"leader":1,"last_sequence":1447,"committed":1447}
{"id":3,"role":"follower","term":2,"leader":1,"last_sequence":1447,"committed":1447}
$ cmp /var/tmp/m1/kv.log /var/tmp/m2/kv.log && cmp /var/tmp/m1/kv.log /var/tmp/m3/kv.log && echo identical
identical
$ cat /var/tmp/m1/state
2 1
It rejoined as a follower of term 2 and received the records it lacked. The 3 logs
are identical, and the state file of member 1 contains 2 1: term 2, and a vote for
member 1, which it gave to itself when it stood for election.
A member is left alone
The leader and 1 follower are killed, so that a single member remains.
$ kill -9 <pid of member 2, the leader> <pid of member 3>
$ sleep 5; curl http://127.0.0.1:7001/healthz
{"id":1,"role":"candidate","term":6,"leader":null,"last_sequence":1449,"committed":1449}
$ curl -X PUT http://127.0.0.1:7001/v1/keys/alone --data-binary 'x'
{"type":"/problems/store-unavailable","title":"The node cannot serve the request","status":503,"detail":"the shard has no leader at the moment"}
$ sleep 3; curl http://127.0.0.1:7001/healthz
{"id":1,"role":"candidate","term":8,"leader":null,"last_sequence":1449,"committed":1449}
$ (members 2 and 3 are started again)
$ sleep 4; for port in 7001 7002 7003; do curl http://127.0.0.1:$port/healthz; echo; done
{"id":1,"role":"leader","term":9,"leader":1,"last_sequence":1450,"committed":1450}
{"id":2,"role":"follower","term":9,"leader":1,"last_sequence":1450,"committed":1450}
{"id":3,"role":"follower","term":9,"leader":1,"last_sequence":1450,"committed":1450}
| Output | What it shows |
|---|---|
| candidate, term 6, then term 8 | The remaining member stands for election again and again. It cannot obtain a second vote, and every attempt increases its term. |
| the shard has no leader at the moment | Writes and reads are refused. 1 member of 3 cannot determine whether the other 2 have failed or have elected a leader between themselves. |
| after the others return: leader of term 9 | The shard resumed in a term above those that the isolated member had reached. |
This is the minority side of a partition once more, seen from the inside. The lone member behaves exactly as it would if the other 2 were running and unreachable, as from its own position it must, since it has no means of telling the 2 situations apart.
Limitations
| Limitation | Consequence |
|---|---|
| An isolated member increases its term at every attempt at election. | When it reconnects, its higher term obliges the current leader to step down and causes an unnecessary election. Implementations of Raft avoid this with a preliminary vote that does not increase the term. |
| The set of members is fixed when a node starts. | Adding or replacing a member requires all nodes to be restarted with new options. The procedure of chapter 1 for a node joining the ring is not implemented. |
| Writes are processed in turn. | As in chapter 3, the write rate is 1 write for each round trip: 72 writes per second in the run above. Chapter 6 addresses this. |
| Every member holds all its records in memory, and the log grows without limit. | The time to restart and the memory used grow with the age of the shard. |
| Clients must find the leader for themselves. | probe tries each node in turn. The router of chapter 5 does this on the clients’ behalf. |
| The commit condition for records of earlier terms is not covered by the simulation. | The condition is implemented, and its absence was not detected by the test. |
The complete programs
Listing 4.45 The complete
raft/src/lib.rs. raft/src/lib.rs
//! A member of a shard that elects its own leader.
//!
//! A `Member` is a replica with a role. It follows the Raft algorithm: a
//! follower that hears nothing from a leader stands for election, a
//! candidate that receives the votes of a majority becomes the leader, and
//! a leader replicates its log. The type does no I/O on the network and
//! reads no clock. The caller delivers messages to `handle`, calls `tick`
//! at a fixed interval, and sends the messages that both return.
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use kv::{Change, Condition, Entry, Log, Record, StoreError, apply, check};
pub type NodeId = u32;
/// Sent by a leader to a follower.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppendRequest {
pub term: u64,
pub leader: NodeId,
/// The sequence of the record that precedes `records`, and its term.
pub previous_sequence: u64,
pub previous_term: u64,
pub records: Vec<Record>,
pub committed_sequence: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppendAnswer {
pub term: u64,
pub from: NodeId,
pub appended: bool,
/// When appended: the last sequence that is known to match the leader's
/// log. When refused: the sequence the leader should continue from.
pub last_sequence: u64,
}
/// Sent by a candidate to every other member.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VoteRequest {
pub term: u64,
pub candidate: NodeId,
/// The end of the candidate's log, and the term of its last record.
pub last_sequence: u64,
pub last_term: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VoteAnswer {
pub term: u64,
pub from: NodeId,
pub granted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
Append(AppendRequest),
Appended(AppendAnswer),
Vote(VoteRequest),
Voted(VoteAnswer),
}
/// A message and the member it is addressed to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outgoing {
pub to: NodeId,
pub message: Message,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Follower,
Candidate,
Leader,
}
/// The ticks a follower waits for a leader before it stands for election:
/// 10 to 19, different for each member and each term, so that 2 members
/// seldom stand at the same moment. A tick is 100 ms in the node.
fn election_timeout(id: NodeId, term: u64) -> u64 {
10 + (u64::from(id) * 7 + term * 3) % 10
}
/// What the leader knows about one follower.
#[derive(Debug, Clone, Copy, Default)]
struct Progress {
/// The sequence after which the next message to the follower begins.
next: u64,
/// The last sequence the follower has confirmed to match.
matched: u64,
/// The tick at which the follower last answered.
heard: u64,
}
pub struct Member {
id: NodeId,
peers: Vec<NodeId>,
directory: PathBuf,
sync: bool,
log: Log,
/// Every record of the log. Record `n` is at index `n - 1`.
records: Vec<Record>,
/// The term in which each record was written.
terms: Vec<u64>,
entries: BTreeMap<String, Entry>,
committed: u64,
/// The current term, and the member this one voted for in it. Both are
/// stored on disk before any answer that depends on them is sent.
term: u64,
voted_for: Option<NodeId>,
role: Role,
/// The leader of the current term, when this member knows it.
leader: Option<NodeId>,
votes: usize,
progress: BTreeMap<NodeId, Progress>,
/// Ticks since the member started, and the tick of the last message
/// from a leader or the last vote granted.
now: u64,
quiet_since: u64,
/// The records this member has removed from its log because they
/// differed from a leader's.
discarded: u64,
}
/// The key of a term marker. A client cannot write the empty key, so the
/// log can use it for a record of its own: the first record a new leader
/// appends, whose value is the leader's term.
const MARKER: &str = "";
fn marker(term: u64) -> Change {
Change::Put {
key: MARKER.to_owned(),
value: term.to_le_bytes().to_vec(),
}
}
/// The term a record starts, if it is a term marker.
fn marked_term(record: &Record) -> Option<u64> {
let Change::Put { key, value } = &record.change else {
return None;
};
if key != MARKER {
return None;
}
let term: [u8; 8] = value.as_slice().try_into().ok()?;
Some(u64::from_le_bytes(term))
}
/// The term of each record: the term of the last marker at or before it.
fn terms_of(records: &[Record], mut term: u64) -> Vec<u64> {
let mut terms = Vec::with_capacity(records.len());
for record in records {
term = marked_term(record).unwrap_or(term);
terms.push(term);
}
terms
}
impl Member {
/// Opens the member in `directory`. It starts as a follower, with
/// nothing applied, in the term it had reached before it stopped.
pub fn open(
directory: &Path,
id: NodeId,
peers: Vec<NodeId>,
sync: bool,
) -> io::Result<Member> {
let (log, recovery) = Log::open(directory, sync)?;
let (term, voted_for) = read_state(directory)?;
let terms = terms_of(&recovery.records, 0);
Ok(Member {
id,
peers,
directory: directory.to_owned(),
sync,
log,
records: recovery.records,
terms,
entries: BTreeMap::new(),
committed: 0,
term,
voted_for,
role: Role::Follower,
leader: None,
votes: 0,
progress: BTreeMap::new(),
now: 0,
quiet_since: 0,
discarded: 0,
})
}
pub fn id(&self) -> NodeId {
self.id
}
pub fn role(&self) -> Role {
self.role
}
pub fn term(&self) -> u64 {
self.term
}
pub fn leader(&self) -> Option<NodeId> {
self.leader
}
pub fn last_sequence(&self) -> u64 {
self.records.len() as u64
}
pub fn committed(&self) -> u64 {
self.committed
}
pub fn records(&self) -> &[Record] {
&self.records
}
pub fn discarded(&self) -> u64 {
self.discarded
}
fn last_term(&self) -> u64 {
self.terms
.last()
.copied()
.unwrap_or(0)
}
/// The term of record `sequence`, or 0 for the position before the log.
fn term_at(&self, sequence: u64) -> u64 {
if sequence == 0 {
0
} else {
self.terms[sequence as usize - 1]
}
}
fn majority(&self) -> usize {
let members = self.peers.len() + 1;
members / 2 + 1
}
}
const STATE_FILE: &str = "state";
/// Reads the term and the vote. A member that has no state file has not
/// voted and is in term 0.
fn read_state(directory: &Path) -> io::Result<(u64, Option<NodeId>)> {
let text = match fs::read_to_string(directory.join(STATE_FILE)) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok((0, None)),
Err(error) => return Err(error),
};
let mut fields = text
.split_whitespace()
.map(str::parse::<u64>);
let invalid = || io::Error::new(io::ErrorKind::InvalidData, "the state file is malformed");
let term = fields
.next()
.and_then(Result::ok)
.ok_or_else(invalid)?;
let voted_for = fields
.next()
.and_then(Result::ok)
.map(|id| id as NodeId);
Ok((term, voted_for))
}
impl Member {
/// Stores the term and the vote. The new file replaces the old one in a
/// single step, so a crash leaves one of the 2 and not a mixture.
fn save_state(&self) -> io::Result<()> {
let temporary = self.directory.join("state.new");
let vote = self
.voted_for
.map_or(String::new(), |id| id.to_string());
let mut file = File::create(&temporary)?;
file.write_all(format!("{} {vote}\n", self.term).as_bytes())?;
if self.sync {
file.sync_all()?;
}
fs::rename(&temporary, self.directory.join(STATE_FILE))?;
if self.sync {
File::open(&self.directory)?.sync_all()?;
}
Ok(())
}
}
impl Member {
/// Applies the records up to `sequence` that are not applied yet. Term
/// markers are records of the log and are not entries.
fn commit(&mut self, sequence: u64) {
let until = sequence.min(self.last_sequence());
// A record that marks the start of a term changes no key.
let changes: Vec<Record> = (self.committed..until)
.map(|index| self.records[index as usize].clone())
.filter(|record| marked_term(record).is_none())
.collect();
for record in changes {
apply(&mut self.entries, record);
}
self.committed = self.committed.max(until);
}
/// The leader's commit rule. A record of an earlier term is not counted
/// on its own: it becomes committed together with the first record of
/// the leader's own term that a majority has stored.
fn advance(&mut self) {
let mut stored: Vec<u64> = self
.progress
.values()
.map(|progress| progress.matched)
.collect();
stored.push(self.last_sequence());
stored.sort_unstable_by(|a, b| b.cmp(a));
let on_majority = stored[self.majority() - 1];
if on_majority > self.committed && self.term_at(on_majority) == self.term {
self.commit(on_majority);
}
}
}
impl Member {
/// Moves to a newer term as a follower. The vote of the old term does
/// not carry over.
fn step_down(&mut self, term: u64) -> io::Result<()> {
if term > self.term {
self.term = term;
self.voted_for = None;
self.save_state()?;
}
self.role = Role::Follower;
self.leader = None;
self.progress.clear();
Ok(())
}
}
impl Member {
/// Stands for election in the next term.
fn stand(&mut self) -> io::Result<Vec<Outgoing>> {
self.term += 1;
self.voted_for = Some(self.id);
self.save_state()?;
self.role = Role::Candidate;
self.leader = None;
self.votes = 1;
self.quiet_since = self.now;
let request = VoteRequest {
term: self.term,
candidate: self.id,
last_sequence: self.last_sequence(),
last_term: self.last_term(),
};
Ok(self
.peers
.iter()
.map(|&to| Outgoing {
to,
message: Message::Vote(request),
})
.collect())
}
}
impl Member {
/// Becomes the leader of the current term. The first record of the term
/// is its marker. When a majority has stored the marker, every earlier
/// record is committed with it.
fn lead(&mut self) -> io::Result<Vec<Outgoing>> {
self.role = Role::Leader;
self.leader = Some(self.id);
let next = self.last_sequence();
self.progress = self
.peers
.iter()
.map(|&peer| {
(
peer,
Progress {
next,
matched: 0,
heard: self.now,
},
)
})
.collect();
let record = self.log.append(marker(self.term))?;
self.records.push(record);
self.terms.push(self.term);
Ok(self.replicate())
}
}
impl Member {
/// Advances the member's clock by one tick. A leader sends every
/// follower a message. A follower or a candidate that has waited for
/// its election timeout stands for election.
pub fn tick(&mut self) -> io::Result<Vec<Outgoing>> {
self.now += 1;
if self.role == Role::Leader {
return Ok(self.replicate());
}
if self.now - self.quiet_since >= election_timeout(self.id, self.term) {
return self.stand();
}
Ok(Vec::new())
}
/// A message for every follower: the records it lacks, or a heartbeat.
pub fn replicate(&self) -> Vec<Outgoing> {
self.peers
.iter()
.filter_map(|&peer| self.append_for(peer))
.collect()
}
fn append_for(&self, peer: NodeId) -> Option<Outgoing> {
let from = self
.progress
.get(&peer)?
.next
.min(self.last_sequence());
let request = AppendRequest {
term: self.term,
leader: self.id,
previous_sequence: from,
previous_term: self.term_at(from),
records: self.records[from as usize..].to_vec(),
committed_sequence: self.committed,
};
Some(Outgoing {
to: peer,
message: Message::Append(request),
})
}
}
impl Member {
/// Processes a message and returns the messages to send in response.
pub fn handle(&mut self, message: Message) -> io::Result<Vec<Outgoing>> {
match message {
Message::Append(request) => {
let to = request.leader;
let answer = self.on_append(request)?;
Ok(vec![Outgoing {
to,
message: Message::Appended(answer),
}])
}
Message::Vote(request) => {
let to = request.candidate;
let answer = self.on_vote(request)?;
Ok(vec![Outgoing {
to,
message: Message::Voted(answer),
}])
}
Message::Appended(answer) => self.on_appended(answer),
Message::Voted(answer) => self.on_voted(answer),
}
}
}
impl Member {
/// A member grants its vote once in a term, and only to a candidate
/// whose log is at least as complete as its own.
fn on_vote(&mut self, request: VoteRequest) -> io::Result<VoteAnswer> {
if request.term > self.term {
self.step_down(request.term)?;
}
let complete =
(request.last_term, request.last_sequence) >= (self.last_term(), self.last_sequence());
let free = self
.voted_for
.is_none_or(|id| id == request.candidate);
let granted = request.term == self.term && complete && free;
if granted {
self.voted_for = Some(request.candidate);
self.save_state()?;
self.quiet_since = self.now;
}
Ok(VoteAnswer {
term: self.term,
from: self.id,
granted,
})
}
fn on_voted(&mut self, answer: VoteAnswer) -> io::Result<Vec<Outgoing>> {
if answer.term > self.term {
self.step_down(answer.term)?;
return Ok(Vec::new());
}
if self.role != Role::Candidate || answer.term != self.term || !answer.granted {
return Ok(Vec::new());
}
self.votes += 1;
if self.votes >= self.majority() {
self.lead()
} else {
Ok(Vec::new())
}
}
}
impl Member {
fn on_append(&mut self, request: AppendRequest) -> io::Result<AppendAnswer> {
let refuse = |member: &Member, last_sequence| AppendAnswer {
term: member.term,
from: member.id,
appended: false,
last_sequence,
};
if request.term < self.term {
return Ok(refuse(self, self.last_sequence()));
}
// A message of the current term or a newer one comes from the leader.
self.step_down(request.term)?;
self.leader = Some(request.leader);
self.quiet_since = self.now;
if request.previous_sequence > self.last_sequence() {
return Ok(refuse(self, self.last_sequence()));
}
if self.term_at(request.previous_sequence) != request.previous_term {
return Ok(refuse(self, request.previous_sequence - 1));
}
let matched = request.previous_sequence + request.records.len() as u64;
self.store(
request.previous_sequence,
request.previous_term,
request.records,
)?;
self.commit(
request
.committed_sequence
.min(matched),
);
Ok(AppendAnswer {
term: self.term,
from: self.id,
appended: true,
last_sequence: matched,
})
}
}
impl Member {
/// Makes the log equal to the leader's from `previous` onward: records
/// the member already has are skipped, a record that differs is removed
/// together with everything after it, and the rest is appended.
fn store(&mut self, previous: u64, previous_term: u64, records: Vec<Record>) -> io::Result<()> {
let terms = terms_of(&records, previous_term);
let mut fresh = 0;
for (index, record) in records.iter().enumerate() {
let sequence = previous + index as u64 + 1;
if sequence > self.last_sequence() {
break;
}
if self.term_at(sequence) != terms[index]
|| &self.records[sequence as usize - 1] != record
{
self.truncate(sequence - 1)?;
break;
}
fresh = index + 1;
}
if fresh < records.len() {
self.log
.append_records(&records[fresh..])?;
self.records
.extend_from_slice(&records[fresh..]);
self.terms
.extend_from_slice(&terms[fresh..]);
}
Ok(())
}
}
impl Member {
/// Removes the records after `keep`. Only records that are not
/// committed can differ from the leader's, so none of them is applied.
fn truncate(&mut self, keep: u64) -> io::Result<()> {
if keep < self.committed {
return Err(io::Error::other(format!(
"record {} is committed and differs from the leader's",
keep + 1
)));
}
let length: usize = self.records[..keep as usize]
.iter()
.map(|record| record.encode().len())
.sum();
self.discarded += self.last_sequence() - keep;
self.log
.truncate(keep, length as u64)?;
self.records.truncate(keep as usize);
self.terms.truncate(keep as usize);
Ok(())
}
}
impl Member {
fn on_appended(&mut self, answer: AppendAnswer) -> io::Result<Vec<Outgoing>> {
if answer.term > self.term {
self.step_down(answer.term)?;
return Ok(Vec::new());
}
if self.role != Role::Leader || answer.term != self.term {
return Ok(Vec::new());
}
let now = self.now;
let Some(progress) = self.progress.get_mut(&answer.from) else {
return Ok(Vec::new());
};
progress.heard = now;
progress.next = answer.last_sequence;
if answer.appended {
progress.matched = progress
.matched
.max(answer.last_sequence);
}
let behind = !answer.appended || answer.last_sequence < self.last_sequence();
self.advance();
Ok(if behind {
self.append_for(answer.from)
.into_iter()
.collect()
} else {
Vec::new()
})
}
}
impl Member {
/// Whether this member is the leader and every record of its log is
/// committed, so that it can accept a write.
pub fn is_settled(&self) -> bool {
self.role == Role::Leader && self.committed == self.last_sequence()
}
/// Checks the condition of a write and appends its record to the log.
/// The caller sends the messages of `replicate` afterwards.
pub fn propose(&mut self, change: Change, condition: Condition) -> Result<u64, StoreError> {
let key = match &change {
Change::Put { key, .. } | Change::Delete { key } => key,
};
let current = self
.entries
.get(key)
.map(|entry| entry.version);
if matches!(change, Change::Delete { .. }) && current.is_none() {
return Err(StoreError::NotFound);
}
check(condition, current)?;
let record = self.log.append(change)?;
let sequence = record.sequence;
self.records.push(record);
self.terms.push(self.term);
Ok(sequence)
}
/// Whether this member may answer a read: it is the leader, the marker
/// of its term is committed, and a majority of the members, itself
/// included, has answered within the last 10 ticks.
pub fn can_read(&self) -> bool {
let recent = self
.progress
.values()
.filter(|progress| self.now - progress.heard < 10)
.count();
self.role == Role::Leader
&& self.term_at(self.committed) == self.term
&& recent + 1 >= self.majority()
}
pub fn get(&self, key: &str) -> Option<&Entry> {
self.entries.get(key)
}
}
Listing 4.46 The complete
raftsim/src/main.rs. raftsim/src/main.rs
//! Command raftsim runs 3 members in one process and decides itself when
//! each clock ticks, which message arrives, which is lost, and which member
//! crashes. It first walks through an election and the repair of a log,
//! and then makes 200 runs of 600 random steps, checking after every step
//! that no 2 members lead the same term and that no acknowledged write is
//! missing.
//!
//! Run it from code/ with:
//!
//! cargo run --release -p raftsim
use std::collections::BTreeMap;
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use kv::{Change, Condition};
use raft::{Member, NodeId, Outgoing, Role};
/// A generator of pseudo-random numbers. The same seed gives the same
/// numbers, so a run that fails can be repeated exactly.
struct Random(u64);
impl Random {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
/// A number below `bound`.
fn below(&mut self, bound: usize) -> usize {
(self.next() % bound as u64) as usize
}
}
const MEMBERS: [NodeId; 3] = [1, 2, 3];
/// A write that a leader has accepted and not yet committed.
struct Pending {
leader: NodeId,
term: u64,
sequence: u64,
key: String,
}
struct Cluster {
root: PathBuf,
/// The members that are running. A crashed member is absent.
up: BTreeMap<NodeId, Member>,
/// The messages that are on their way.
network: Vec<Outgoing>,
pending: Vec<Pending>,
/// The sequence and the key of every acknowledged write.
acknowledged: Vec<(u64, String)>,
/// The leader that was seen in each term.
leaders: BTreeMap<u64, NodeId>,
discarded: u64,
writes: u64,
}
impl Cluster {
fn open(root: &Path) -> Result<Cluster, Box<dyn Error>> {
let mut cluster = Cluster {
root: root.to_owned(),
up: BTreeMap::new(),
network: Vec::new(),
pending: Vec::new(),
acknowledged: Vec::new(),
leaders: BTreeMap::new(),
discarded: 0,
writes: 0,
};
for id in MEMBERS {
cluster.start(id)?;
}
Ok(cluster)
}
fn start(&mut self, id: NodeId) -> Result<(), Box<dyn Error>> {
let peers = MEMBERS
.iter()
.copied()
.filter(|&peer| peer != id)
.collect();
let member = Member::open(
&self
.root
.join(format!("member-{id}")),
id,
peers,
false,
)?;
self.up.insert(id, member);
Ok(())
}
/// Stops a member. Its files remain, and its memory is lost.
fn crash(&mut self, id: NodeId) {
if let Some(member) = self.up.remove(&id) {
self.discarded += member.discarded();
}
}
fn tick(&mut self, id: NodeId) -> Result<(), Box<dyn Error>> {
if let Some(member) = self.up.get_mut(&id) {
self.network.extend(member.tick()?);
}
self.check()
}
/// Delivers the message at `index`. A message for a member that is
/// down is lost.
fn deliver(&mut self, index: usize) -> Result<(), Box<dyn Error>> {
let Outgoing { to, message } = self.network.remove(index);
if let Some(member) = self.up.get_mut(&to) {
self.network
.extend(member.handle(message)?);
}
self.check()
}
/// Delivers one of the messages that are on their way, chosen at random.
fn deliver_any(&mut self, random: &mut Random) -> Result<(), Box<dyn Error>> {
if self.network.is_empty() {
return Ok(());
}
self.deliver(random.below(self.network.len()))
}
/// Loses one of the messages that are on their way.
fn lose_any(&mut self, random: &mut Random) {
if !self.network.is_empty() {
self.network
.remove(random.below(self.network.len()));
}
}
/// Starts a member that is down.
fn restart(&mut self, id: NodeId) -> Result<(), Box<dyn Error>> {
if self.up.contains_key(&id) {
return Ok(());
}
self.start(id)
}
/// Delivers messages until none is on its way.
fn deliver_all(&mut self) -> Result<(), Box<dyn Error>> {
while !self.network.is_empty() {
self.deliver(0)?;
}
Ok(())
}
/// Sends a write to a leader that can accept one, if there is one.
fn write(&mut self) -> Result<bool, Box<dyn Error>> {
let Some(leader) = self
.up
.values_mut()
.find(|member| member.is_settled())
else {
return Ok(false);
};
self.writes += 1;
let key = format!("key-{}", self.writes);
let change = Change::Put {
key: key.clone(),
value: b"1".to_vec(),
};
let sequence = leader.propose(change, Condition::Always)?;
self.pending.push(Pending {
leader: leader.id(),
term: leader.term(),
sequence,
key,
});
self.network
.extend(leader.replicate());
Ok(true)
}
fn leader(&self) -> Option<&Member> {
self.up
.values()
.filter(|member| member.role() == Role::Leader)
.max_by_key(|member| member.term())
}
}
impl Cluster {
/// The properties that must hold after every step.
fn check(&mut self) -> Result<(), Box<dyn Error>> {
// No 2 members lead the same term.
for member in self
.up
.values()
.filter(|member| member.role() == Role::Leader)
{
let first = *self
.leaders
.entry(member.term())
.or_insert(member.id());
if first != member.id() {
return Err(format!(
"members {first} and {} both lead term {}",
member.id(),
member.term()
)
.into());
}
}
// A write is acknowledged when the leader that accepted it, still
// leading the same term, has committed its record.
let up = &self.up;
let committed = |write: &Pending| {
up.get(&write.leader)
.is_some_and(|leader| {
leader.role() == Role::Leader
&& leader.term() == write.term
&& leader.committed() >= write.sequence
})
};
let (done, waiting): (Vec<Pending>, Vec<Pending>) = self
.pending
.drain(..)
.partition(committed);
self.acknowledged.extend(
done.into_iter()
.map(|write| (write.sequence, write.key)),
);
self.pending = waiting;
// Every member that has committed the sequence of an acknowledged
// write holds that write at that sequence.
for (sequence, key) in &self.acknowledged {
for member in self
.up
.values()
.filter(|member| member.committed() >= *sequence)
{
let holds = matches!(&member.records()[*sequence as usize - 1].change, Change::Put { key: held, .. } if held == key);
if !holds {
return Err(format!(
"member {} lost the acknowledged write of {key}",
member.id()
)
.into());
}
}
}
Ok(())
}
}
fn show(cluster: &Cluster) -> String {
let describe = |id: &NodeId| match cluster.up.get(id) {
Some(member) => format!(
"{id}: {:?} term {} log {}",
member.role(),
member.term(),
member.last_sequence()
),
None => format!("{id}: down"),
};
MEMBERS
.iter()
.map(describe)
.collect::<Vec<_>>()
.join(" | ")
}
/// Ticks every running member, delivering all messages after each tick,
/// until a member leads. It returns the number of ticks.
fn elect(cluster: &mut Cluster) -> Result<u64, Box<dyn Error>> {
let mut ticks = 0;
while cluster
.up
.values()
.all(|member| !member.is_settled())
{
ticks += 1;
for id in MEMBERS {
cluster.tick(id)?;
}
cluster.deliver_all()?;
}
Ok(ticks)
}
fn cases(root: &Path) -> Result<(), Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
println!("start: {}", show(&cluster));
let ticks = elect(&mut cluster)?;
println!("after {ticks:2} ticks: {}", show(&cluster));
for _ in 0..3 {
cluster.write()?;
cluster.deliver_all()?;
}
println!(
"after 3 writes: {}, {} acknowledged",
show(&cluster),
cluster.acknowledged.len()
);
// The leader accepts a write and crashes before it sends the record.
let old = cluster
.leader()
.map(Member::id)
.ok_or("no leader")?;
cluster.write()?;
cluster.network.clear();
cluster.crash(old);
println!(
"leader {old} appends record 5 and crashes: {}",
show(&cluster)
);
let ticks = elect(&mut cluster)?;
println!("after {ticks:2} ticks: {}", show(&cluster));
cluster.write()?;
cluster.deliver_all()?;
println!(
"after 1 write: {}, {} acknowledged",
show(&cluster),
cluster.acknowledged.len()
);
cluster.start(old)?;
println!("member {old} returns: {}", show(&cluster));
for _ in 0..2 {
for id in MEMBERS {
cluster.tick(id)?;
}
cluster.deliver_all()?;
}
let discarded = cluster
.up
.get(&old)
.map_or(0, Member::discarded);
println!("after 2 ticks: {}", show(&cluster));
println!(
"member {old} discarded {discarded} record that was never committed, and holds the leader's records in its place"
);
Ok(())
}
const RUNS: u64 = 200;
const STEPS: usize = 600;
/// One run: random ticks, deliveries, losses, crashes, restarts and writes.
fn run(root: &Path, seed: u64) -> Result<Cluster, Box<dyn Error>> {
let mut cluster = Cluster::open(root)?;
let mut random = Random(seed);
for _ in 0..STEPS {
let id = MEMBERS[random.below(MEMBERS.len())];
match random.below(100) {
0..40 => cluster.tick(id)?,
40..82 => cluster.deliver_any(&mut random)?,
82..87 => cluster.lose_any(&mut random),
87..90 => cluster.crash(id),
90..94 => cluster.restart(id)?,
94.. => cluster.write().map(drop)?,
}
}
recover(&mut cluster)?;
Ok(cluster)
}
/// Ends the faults: every member runs and every message arrives. The
/// members must then agree on a leader and on a log that holds every
/// acknowledged write.
fn recover(cluster: &mut Cluster) -> Result<(), Box<dyn Error>> {
for id in MEMBERS {
if !cluster.up.contains_key(&id) {
cluster.start(id)?;
}
}
let agreed = |cluster: &Cluster| {
cluster
.leader()
.is_some_and(|leader| {
leader.is_settled()
&& cluster
.up
.values()
.all(|member| member.records() == leader.records())
})
};
for _ in 0..200 {
if agreed(cluster) {
break;
}
for id in MEMBERS {
cluster.tick(id)?;
}
cluster.deliver_all()?;
}
if !agreed(cluster) {
return Err("the members did not agree after the faults ended".into());
}
let leader = cluster
.leader()
.ok_or("the members have no leader after the faults ended")?;
for (sequence, key) in &cluster.acknowledged {
if leader
.get(key)
.is_none_or(|entry| entry.version != *sequence)
{
return Err(
format!("the acknowledged write of {key} is missing after recovery").into(),
);
}
}
Ok(())
}
fn runs(root: &Path) -> Result<(), Box<dyn Error>> {
let (mut leaders, mut acknowledged, mut discarded, mut most_terms) = (0, 0, 0, 0);
for seed in 1..=RUNS {
let directory = root.join(format!("run-{seed}"));
let mut cluster = run(&directory, seed)?;
for id in MEMBERS {
cluster.crash(id);
}
leaders += cluster.leaders.len();
acknowledged += cluster.acknowledged.len();
discarded += cluster.discarded;
most_terms = most_terms.max(
cluster
.leaders
.keys()
.next_back()
.copied()
.unwrap_or(0),
);
fs::remove_dir_all(&directory)?;
}
println!("{RUNS} runs of {STEPS} random steps:");
println!(" {leaders} leaders were elected, and no term had 2 leaders");
println!(" {acknowledged} writes were acknowledged, and every one was present after recovery");
println!(
" {discarded} uncommitted records were discarded by members whose log differed from a leader's"
);
println!(" the highest term reached in a run was {most_terms}");
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let root = env::temp_dir().join(format!("raftsim-{}", process::id()));
let result = cases(&root.join("cases")).and_then(|()| runs(&root));
fs::remove_dir_all(&root)?;
result
}
Listing 4.47 The complete
kvraft/Cargo.toml. kvraft/Cargo.toml
[package]
name = "kvraft"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kv.workspace = true
kvd.workspace = true
raft.workspace = true
axum.workspace = true
clap.workspace = true
parking_lot.workspace = true
prost.workspace = true
serde.workspace = true
tokio.workspace = true
tonic.workspace = true
tonic-prost.workspace = true
[build-dependencies]
tonic-prost-build.workspace = true
Listing 4.48 The complete
kvraft/src/rpc.rs. kvraft/src/rpc.rs
//! The gRPC services of a member: the generated types, their conversion to
//! the messages of the `raft` crate, and the server side of both calls.
use std::sync::Arc;
use kv::{Change, Record};
use raft::{AppendAnswer, AppendRequest, Message, NodeId, VoteAnswer, VoteRequest};
use tonic::{Request, Response, Status};
use crate::node::Node;
/// The types that `build.rs` generates from `proto/store.proto`. The schema
/// also defines the ring, which this program does not use.
#[allow(dead_code)]
pub mod proto {
tonic::include_proto!("store.v1");
}
pub use proto::election_client::ElectionClient;
pub use proto::election_server::{Election, ElectionServer};
pub use proto::replication_client::ReplicationClient;
pub use proto::replication_server::{Replication, ReplicationServer};
fn record_to_proto(record: &Record) -> proto::Record {
let change = match record.change.clone() {
Change::Put { key, value } => proto::record::Change::Put(proto::Put { key, value }),
Change::Delete { key } => proto::record::Change::Delete(proto::Delete { key }),
};
proto::Record {
sequence: record.sequence,
change: Some(change),
}
}
fn record_from_proto(record: proto::Record) -> Result<Record, Status> {
let change = match record.change {
Some(proto::record::Change::Put(put)) => Change::Put {
key: put.key,
value: put.value,
},
Some(proto::record::Change::Delete(delete)) => Change::Delete { key: delete.key },
None => return Err(Status::invalid_argument("a record without a change")),
};
Ok(Record {
sequence: record.sequence,
change,
})
}
impl From<AppendRequest> for proto::AppendRequest {
fn from(request: AppendRequest) -> Self {
proto::AppendRequest {
shard: 0,
term: request.term,
previous_sequence: request.previous_sequence,
records: request
.records
.iter()
.map(record_to_proto)
.collect(),
committed_sequence: request.committed_sequence,
previous_term: request.previous_term,
leader: request.leader,
}
}
}
impl TryFrom<proto::AppendRequest> for AppendRequest {
type Error = Status;
fn try_from(request: proto::AppendRequest) -> Result<Self, Status> {
Ok(AppendRequest {
term: request.term,
leader: request.leader,
previous_sequence: request.previous_sequence,
previous_term: request.previous_term,
records: request
.records
.into_iter()
.map(record_from_proto)
.collect::<Result<_, _>>()?,
committed_sequence: request.committed_sequence,
})
}
}
impl From<VoteRequest> for proto::VoteRequest {
fn from(request: VoteRequest) -> Self {
proto::VoteRequest {
term: request.term,
candidate: request.candidate,
last_sequence: request.last_sequence,
last_term: request.last_term,
}
}
}
/// The answer of member `from` to an `Append` call, as a message.
pub fn appended(from: NodeId, answer: proto::AppendResponse) -> Message {
Message::Appended(AppendAnswer {
term: answer.term,
from,
appended: answer.appended,
last_sequence: answer.last_sequence,
})
}
/// The answer of member `from` to a `RequestVote` call, as a message.
pub fn voted(from: NodeId, answer: proto::VoteResponse) -> Message {
Message::Voted(VoteAnswer {
term: answer.term,
from,
granted: answer.granted,
})
}
/// The server side of both services. Each call is one message for the
/// member, and the member's answer is the response of the call.
pub struct Service {
pub node: Arc<Node>,
}
#[tonic::async_trait]
impl Replication for Service {
async fn append(
&self,
request: Request<proto::AppendRequest>,
) -> Result<Response<proto::AppendResponse>, Status> {
let request = AppendRequest::try_from(request.into_inner())?;
let answer = self
.node
.answer(Message::Append(request))
.map_err(|error| Status::unavailable(error.to_string()))?;
let Message::Appended(answer) = answer else {
return Err(Status::internal(
"the member answered with another kind of message",
));
};
Ok(Response::new(proto::AppendResponse {
term: answer.term,
appended: answer.appended,
last_sequence: answer.last_sequence,
}))
}
}
#[tonic::async_trait]
impl Election for Service {
async fn request_vote(
&self,
request: Request<proto::VoteRequest>,
) -> Result<Response<proto::VoteResponse>, Status> {
let request = request.into_inner();
let request = VoteRequest {
term: request.term,
candidate: request.candidate,
last_sequence: request.last_sequence,
last_term: request.last_term,
};
let answer = self
.node
.answer(Message::Vote(request))
.map_err(|error| Status::unavailable(error.to_string()))?;
let Message::Voted(answer) = answer else {
return Err(Status::internal(
"the member answered with another kind of message",
));
};
Ok(Response::new(proto::VoteResponse {
term: answer.term,
granted: answer.granted,
}))
}
}
Listing 4.49 The complete
kvraft/src/node.rs. kvraft/src/node.rs
//! A node: a member, its clock, and the connections that carry its
//! messages to the other members.
use std::collections::BTreeMap;
use std::io;
use std::sync::Arc;
use std::time::Duration;
use kv::{Change, Condition, Entry, Written};
use kvd::api::ApiError;
use parking_lot::Mutex;
use raft::{Member, Message, NodeId, Outgoing, Role};
use tokio::sync::watch;
use tokio::task;
use tokio::time::{interval, timeout};
use tonic::transport::{Channel, Endpoint};
use crate::rpc::{ElectionClient, ReplicationClient, appended, proto, voted};
/// The length of a tick of the member's clock.
pub const TICK: Duration = Duration::from_millis(100);
/// How long a member waits for the answer to one message.
const ANSWER: Duration = Duration::from_millis(500);
/// How long a write waits for its record to be committed.
const COMMIT: Duration = Duration::from_secs(1);
pub struct Node {
pub member: Mutex<Member>,
/// A connection to each of the other members.
peers: BTreeMap<NodeId, Channel>,
/// Held by the write that is in progress.
turn: tokio::sync::Mutex<()>,
/// The committed sequence, for writes that wait for it to advance.
committed: watch::Sender<u64>,
}
impl Node {
pub fn new(
member: Member,
addresses: BTreeMap<NodeId, String>,
) -> Result<Arc<Node>, tonic::transport::Error> {
let mut peers = BTreeMap::new();
for (id, address) in addresses {
peers.insert(
id,
Endpoint::from_shared(address)?
.connect_timeout(ANSWER)
.connect_lazy(),
);
}
Ok(Arc::new(Node {
member: Mutex::new(member),
peers,
turn: tokio::sync::Mutex::new(()),
committed: watch::Sender::new(0),
}))
}
}
impl Node {
/// Runs one operation on the member and publishes the committed
/// sequence. The member waits for the disk, so the operation runs where
/// blocking is allowed.
fn with_member<T>(&self, operation: impl FnOnce(&mut Member) -> T) -> T {
task::block_in_place(|| {
let mut member = self.member.lock();
let result = operation(&mut member);
self.committed
.send_replace(member.committed());
result
})
}
}
impl Node {
/// Sends each message in a task of its own, so that a member that does
/// not answer delays nothing else.
fn dispatch(self: &Arc<Node>, messages: Vec<Outgoing>) {
for message in messages {
tokio::spawn(Arc::clone(self).send(message));
}
}
/// Carries one request to a member and gives the answer to this member.
async fn send(self: Arc<Node>, outgoing: Outgoing) {
let Some(channel) = self
.peers
.get(&outgoing.to)
.cloned()
else {
return;
};
let answer = match outgoing.message {
Message::Append(request) => {
let call = async move {
ReplicationClient::new(channel)
.append(proto::AppendRequest::from(request))
.await
};
timeout(ANSWER, call)
.await
.ok()
.and_then(Result::ok)
.map(|answer| appended(outgoing.to, answer.into_inner()))
}
Message::Vote(request) => {
let call = async move {
ElectionClient::new(channel)
.request_vote(proto::VoteRequest::from(request))
.await
};
timeout(ANSWER, call)
.await
.ok()
.and_then(Result::ok)
.map(|answer| voted(outgoing.to, answer.into_inner()))
}
// An answer travels as the response of the call that asked.
Message::Appended(..) | Message::Voted(..) => None,
};
if let Some(answer) = answer {
self.deliver(answer);
}
}
/// Gives the member a message and sends what the member returns.
fn deliver(self: &Arc<Node>, message: Message) {
match self.with_member(|member| member.handle(message)) {
Ok(messages) => self.dispatch(messages),
Err(error) => eprintln!("the member could not process a message: {error}"),
}
}
}
impl Node {
/// The member's answer to a request that arrived over gRPC.
pub fn answer(&self, request: Message) -> io::Result<Message> {
let mut messages = self.with_member(|member| member.handle(request))?;
messages
.pop()
.map(|outgoing| outgoing.message)
.ok_or_else(|| io::Error::other("the member gave no answer"))
}
/// Advances the member's clock, for as long as the node runs.
pub async fn run_clock(self: Arc<Node>) {
let mut ticks = interval(TICK);
loop {
ticks.tick().await;
match self.with_member(Member::tick) {
Ok(messages) => self.dispatch(messages),
Err(error) => eprintln!("the member could not advance its clock: {error}"),
}
}
}
}
impl Node {
fn not_leader(member: &Member) -> ApiError {
match member.leader() {
Some(leader) => ApiError::Misdirected(format!("the leader is member {leader}")),
None => ApiError::NoLeader,
}
}
/// A write on the leader. It is acknowledged when its record is
/// committed and this member has led the same term throughout.
pub async fn write(
self: &Arc<Node>,
change: Change,
condition: Condition,
) -> Result<Written, ApiError> {
let turn = self.turn.lock().await;
let last = {
let member = self.member.lock();
if member.role() != Role::Leader {
return Err(Node::not_leader(&member));
}
member.last_sequence()
};
self.wait_for(last).await?;
let (written, term, messages) = self.with_member(|member| {
if !member.is_settled() {
return Err(Node::not_leader(member));
}
let created = match &change {
Change::Put { key, .. } | Change::Delete { key } => member.get(key).is_none(),
};
let version = member.propose(change, condition)?;
Ok((
Written { version, created },
member.term(),
member.replicate(),
))
})?;
self.dispatch(messages);
self.wait_for(written.version)
.await?;
drop(turn);
// A member that lost the leadership while it waited may hold another
// leader's record at this sequence.
let member = self.member.lock();
if member.role() == Role::Leader && member.term() == term {
Ok(written)
} else {
Err(ApiError::Unavailable(
"the leadership changed before the record was committed".into(),
))
}
}
async fn wait_for(&self, sequence: u64) -> Result<(), ApiError> {
let mut committed = self.committed.subscribe();
let reached = committed.wait_for(|&committed| committed >= sequence);
let in_time = timeout(COMMIT, reached)
.await
.is_ok_and(|reached| reached.is_ok());
if in_time {
Ok(())
} else {
Err(ApiError::Unavailable(format!(
"record {sequence} is not on a majority of the members"
)))
}
}
pub fn get(&self, key: &str) -> Result<Entry, ApiError> {
let member = self.member.lock();
if member.role() != Role::Leader {
return Err(Node::not_leader(&member));
}
if !member.can_read() {
return Err(ApiError::Unavailable(
"this leader cannot confirm that it still leads".into(),
));
}
member
.get(key)
.cloned()
.ok_or(ApiError::NotFound)
}
}
Listing 4.50 The complete
kvraft/src/main.rs. kvraft/src/main.rs
//! Command kvraft is a storage node whose shard elects its own leader.
//! Every node is started the same way, with its id and the replication
//! addresses of the other members.
//!
//! Run 3 nodes from code/ with:
//!
//! cargo run -p kvraft -- --id 1 --listen 127.0.0.1:7001 --replication 127.0.0.1:7101 --data /var/tmp/m1 \
//! --peer 2=http://127.0.0.1:7102 --peer 3=http://127.0.0.1:7103
//!
//! and the same for ids 2 and 3, each with its own ports and the other 2 as peers.
mod node;
mod rpc;
use std::collections::BTreeMap;
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::Parser;
use kv::{Change, Condition};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, version_header, write_condition};
use raft::{Member, NodeId, Role};
use serde::Serialize;
use tokio::net::TcpListener;
use tonic::transport::Server;
use crate::node::Node;
use crate::rpc::{ElectionServer, ReplicationServer, Service};
/// Reads `2=http://127.0.0.1:7102` as member 2 at that address.
fn parse_peer(text: &str) -> Result<(NodeId, String), String> {
let (id, address) = text
.split_once('=')
.ok_or("a peer is written as id=address")?;
let Ok(id) = id.parse() else {
return Err("the id of a peer is a number".to_owned());
};
Ok((id, address.to_owned()))
}
#[derive(Parser)]
struct Options {
/// The id of this member, a number that no other member has.
#[arg(long)]
id: NodeId,
/// The address of the HTTP API.
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
/// The address on which this member accepts messages from the others.
#[arg(long, default_value = "127.0.0.1:7101")]
replication: SocketAddr,
/// Another member and its replication address, as id=address.
#[arg(long = "peer", value_parser = parse_peer)]
peers: Vec<(NodeId, String)>,
#[arg(long, default_value = "data")]
data: PathBuf,
/// Respond without waiting for the disk.
#[arg(long)]
no_sync: bool,
}
async fn put_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
let written = node
.write(
Change::Put {
key,
value: body.to_vec(),
},
condition,
)
.await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let condition = write_condition(&headers)?;
node.write(Change::Delete { key }, condition)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_key(
State(node): State<Arc<Node>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let cached = version_header(&headers, IF_NONE_MATCH)?;
let entry = node.get(&key)?;
let tag = [(ETAG, etag(entry.version))];
if cached == Some(Condition::Version(entry.version)) {
return Ok((StatusCode::NOT_MODIFIED, tag).into_response());
}
Ok((
tag,
[(CONTENT_TYPE, "application/octet-stream")],
entry.value,
)
.into_response())
}
#[derive(Serialize)]
struct Health {
id: NodeId,
role: &'static str,
term: u64,
/// The leader of the term, when this member knows it.
leader: Option<NodeId>,
last_sequence: u64,
committed: u64,
}
async fn health(State(node): State<Arc<Node>>) -> Json<Health> {
let member = node.member.lock();
let role = match member.role() {
Role::Leader => "leader",
Role::Candidate => "candidate",
Role::Follower => "follower",
};
Json(Health {
id: member.id(),
role,
term: member.term(),
leader: member.leader(),
last_sequence: member.last_sequence(),
committed: member.committed(),
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let peers: BTreeMap<NodeId, String> = options.peers.into_iter().collect();
let member = Member::open(
&options.data,
options.id,
peers.keys().copied().collect(),
!options.no_sync,
)?;
println!(
"member {}, term {}, log ends at {}",
member.id(),
member.term(),
member.last_sequence()
);
let node = Node::new(member, peers)?;
let replication = ReplicationServer::new(Service {
node: Arc::clone(&node),
});
let election = ElectionServer::new(Service {
node: Arc::clone(&node),
});
tokio::spawn(
Server::builder()
.add_service(replication)
.add_service(election)
.serve(options.replication),
);
tokio::spawn(Arc::clone(&node).run_clock());
let routes = Router::new()
.route(
"/v1/keys/{key}",
get(get_key)
.put(put_key)
.delete(delete_key),
)
.route("/healthz", get(health))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(node);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Listing 4.51 The complete
probe/src/main.rs. probe/src/main.rs
//! Command probe measures how long a shard is unavailable when its leader
//! is lost. It writes keys for a number of seconds, trying each node in turn
//! until one accepts the write, and reports the longest pause between 2
//! acknowledged writes.
//!
//! Run it from code/ with:
//!
//! cargo run --release -p probe -- --seconds 20 --acked acked.txt \
//! --server http://127.0.0.1:7001 --server http://127.0.0.1:7002 --server http://127.0.0.1:7003
use std::error::Error;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
use clap::Parser;
use ureq::Agent;
#[derive(Parser)]
struct Options {
/// The HTTP address of a node. Given once for each node of the shard.
#[arg(long = "server", required = true)]
servers: Vec<String>,
#[arg(long, default_value_t = 20)]
seconds: u64,
/// The file that receives the key of every acknowledged write.
#[arg(long)]
acked: PathBuf,
}
fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let agent: Agent = Agent::config_builder()
.timeout_global(Some(Duration::from_secs(2)))
.build()
.into();
let mut file = BufWriter::new(File::create(&options.acked)?);
let started = Instant::now();
let (mut server, mut number, mut refused) = (0, 1, 0);
let (mut last_success, mut longest, mut longest_at) = (started, Duration::ZERO, Duration::ZERO);
while started.elapsed() < Duration::from_secs(options.seconds) {
let key = format!("key-{number:06}");
let address = format!("{}/v1/keys/{key}", options.servers[server]);
if agent
.put(&address)
.send(format!("value of {key}"))
.is_err()
{
// This node did not accept the write. Try the next one.
refused += 1;
server = (server + 1) % options.servers.len();
thread::sleep(Duration::from_millis(20));
continue;
}
let pause = last_success.elapsed();
if pause > longest {
(longest, longest_at) = (pause, started.elapsed());
}
last_success = Instant::now();
writeln!(file, "{key}")?;
number += 1;
}
file.flush()?;
println!(
"{} writes acknowledged in {} seconds, {refused} attempts refused",
number - 1,
options.seconds
);
println!(
"the longest pause between 2 acknowledged writes was {:.2} s, ending {:.1} s after the start",
longest.as_secs_f64(),
longest_at.as_secs_f64()
);
Ok(())
}
Further reading
- D. Ongaro and J. Ousterhout, “In Search of an Understandable Consensus Algorithm”, USENIX ATC 2014.
- D. Ongaro, Consensus: Bridging Theory and Practice, doctoral dissertation, Stanford University, 2014.
- L. Lamport, “Paxos Made Simple”, ACM SIGACT News, 2001.
- T. Chandra, R. Griesemer and J. Redstone, “Paxos Made Live: An Engineering Perspective”, PODC 2007.
- M. Fischer, N. Lynch and M. Paterson, “Impossibility of Distributed Consensus with One Faulty Process”, Journal of the ACM, 1985.
- K. Kingsbury, the Jepsen analyses, jepsen.io.
Summary
- Every member starts as a follower, and a follower that hears nothing from a leader for its election timeout becomes a candidate and asks all the others for their votes.
- A member votes at most once in a term, so that a term has at most 1 leader, and the term and the vote are stored on disk before any answer that depends on them is sent.
- A member votes only for a candidate whose log ends at least as far as its own, with terms compared before sequences, from which it follows that an elected leader holds every acknowledged record.
- A term marker, the first record of a leader, records the term of every record without changing the format of the log, and commits the records of earlier terms.
Appendcarries the term of the record that precedes its new records. A follower whose record at that position has a different term refuses, the leader steps back, and the follower replaces its differing records with the leader’s.- Only uncommitted records are ever removed from a log.
- In 200 simulated runs with crashes and lost messages, 480 leaders were elected with no term having 2, and all 504 acknowledged writes were present after recovery.
- The simulation detected 2 disabled voting rules, and did not detect a disabled commit condition for earlier terms.
- With 3 nodes, killing the leader interrupted writes for 1.05 seconds, and none of 1443 acknowledged writes was lost.
Key-Value Store: Router (Low-Level Design)
With chapter 4 complete, we have a shard
that survives the loss of its leader without anybody’s help. Measured against the
design of chapter 1, 2 things are still
wanting. A client has to discover for itself which member of the shard is the leader,
as the program probe did by trying each node in turn and collecting 51 refusals in
the process. And all the data is in a single shard, whereas the design calls for the
keys to be divided among many.
A single small component supplies both. The router stands between the clients and the shards. Clients send every request to it, and for each request it works out which shard owns the key and which member of that shard is at present the leader, and passes the request on. The clients are thereby relieved of all knowledge of the cluster. They know 1 address, and neither the number of shards nor a change of leader is visible to them.
The router is the least intricate program in this part of the book, and it contains 1 decision that is easy to get wrong and expensive when it is: what to do when the member it has asked does not give a clear answer. A router that retries too readily will perform a write twice, and a router that retries too reluctantly will report failures that it could have concealed. The chapter builds the router, states the rule that settles this question, and then repeats the experiment of chapter 4, killing a leader under load, with the router in front.
This chapter covers
- what a router does, and why it holds no data
- a ring file that lists the shards and their members
- finding the shard of a key with the ring of chapter 1
- finding the leader, and the rule that decides when another member may be asked
- 2 shards behind 1 router, measured
- the loss of a leader behind the router, as a client sees it
What a router does
A router keeps no data. Everything it knows comes from a ring file, read when it starts, and from what the members tell it in the course of its work. This is the statelessness that chapter 1 required of routers, and its practical value is considerable: any number of routers may be run side by side, and any of them may be stopped or restarted at any time, since a router that has lost its memory rebuilds it from the first few answers it receives.
The ring file
$ cat ring.json
{
"virtual_nodes": 256,
"shards": [
{ "name": "shard-a", "members": ["http://127.0.0.1:7011", "http://127.0.0.1:7012", "http://127.0.0.1:7013"] },
{ "name": "shard-b", "members": ["http://127.0.0.1:7021", "http://127.0.0.1:7022", "http://127.0.0.1:7023"] }
]
}
Each shard is a group of 3 kvraft members, listed in the order of their ids. In the
design of chapter 1, the ring is held by a coordinator and changes while the system
runs. Here it is a file that the router reads once at startup. That is sufficient for
routing requests, and it leaves changes to the ring, which are a substantial subject
in themselves, outside this chapter.
Listing 5.1 The imports of
kvrouter/src/main.rs. kvrouter/src/main.rs
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_MATCH, IF_NONE_MATCH};
use axum::http::{HeaderMap, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::Parser;
use kvd::api::{ApiError, valid_key};
use ring::Ring;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio::time::sleep;
Listing 5.2 The ring file, and what the router keeps for each shard. kvrouter/src/main.rs
#[derive(Parser)]
struct Options {
#[arg(long, default_value = "127.0.0.1:7000")]
listen: SocketAddr,
/// A JSON file that lists the shards and the addresses of their members.
#[arg(long)]
ring: PathBuf,
}
/// The contents of the ring file.
#[derive(Deserialize)]
struct Config {
virtual_nodes: u32,
shards: Vec<ShardConfig>,
}
#[derive(Deserialize)]
struct ShardConfig {
name: String,
/// The HTTP address of each member, in the order of their ids: member 1 first.
members: Vec<String>,
}
/// A shard, as the router knows it.
struct Shard {
members: Vec<String>,
/// The position in `members` of the member that led when the router last asked.
leader: AtomicUsize,
}
struct Routes {
ring: Ring,
shards: HashMap<String, Shard>,
client: reqwest::Client,
}
For every shard the router remembers a single number, the position in the list of the member that was the leader when it last asked. This is a guess and nothing more. The sections that follow show what happens when the guess is wrong, and the important property of the design is that a wrong guess costs an extra request and never a wrong result.
Finding the shard
Each shard is placed on the ring of chapter 1 with 256 tokens. To look up a key then takes 2 steps: the ring is asked for the name of the owner, and the shard of that name is fetched.
Listing 5.3 Building the ring, and finding the shard of a key. kvrouter/src/main.rs
impl Routes {
/// Builds the ring from the file: every shard receives its tokens.
fn load(config: Config) -> Routes {
let mut ring = Ring::new(config.virtual_nodes);
let mut shards = HashMap::new();
for shard in config.shards {
ring.add(&shard.name);
shards.insert(
shard.name,
Shard {
members: shard.members,
leader: AtomicUsize::new(0),
},
);
}
Routes {
ring,
shards,
client: reqwest::Client::new(),
}
}
/// The shard that owns a key.
fn shard_of(&self, key: &str) -> Option<&Shard> {
self.shards
.get(self.ring.owner(key)?)
}
}
Finding the leader
The router sends the request to the member that it believes to be the leader. Usually the belief is correct. When it is not, the answer of the member tells the router what to do next.
All the cases are decided by a single rule: the router asks another member only if
the first attempt has certainly changed nothing. A follower that answers 421 did
not touch its log. Nor did a member that reports that the shard is electing a leader,
nor one whose process is not running and whose connection was refused. In all 3
cases, to ask another member is safe, since whatever that member does will be the
only effect of the request.
A timeout is a different matter altogether. The leader may have appended the record,
and even committed it, before the connection failed, and the router cannot know. If
it were to send the request to another member, a write might be performed twice, and
a conditional write that had succeeded might be reported as failed. The router
therefore answers 503 and leaves the decision to the client, which is the only
party that knows whether its request is safe to repeat. This is the lost response of
chapter 1 once again, and the router’s rule is a refusal to guess about it on
somebody else’s behalf.
In the code, the possible outcomes of asking a member are the variants of an enum.
Listing 5.4 The 3 outcomes of asking a member. kvrouter/src/main.rs
/// What a member said about a request it did not serve.
#[derive(Deserialize)]
struct Problem {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
detail: String,
}
impl Problem {
/// The position of the leader in the shard's list of members, when the
/// member named it, as in "the leader is member 3".
fn named_leader(&self) -> Option<usize> {
let id: usize = self
.detail
.rsplit(' ')
.next()?
.parse()
.ok()?;
id.checked_sub(1)
}
}
/// The result of asking one member.
enum Attempt {
/// The member served the request. This is its response.
Served(Response),
/// The member changed nothing. It may have named the leader.
Refused(Option<usize>),
/// The request may or may not have taken effect.
Unknown(String),
}
ask sends the request to 1 member and classifies what comes back as 1 of the
outcomes.
Listing 5.5 Asking 1 member. kvrouter/src/main.rs
const NO_LEADER: &str = "/problems/no-leader";
impl Routes {
/// Sends the request to one member and classifies what comes back.
async fn ask(
&self,
member: &str,
method: Method,
key: &str,
headers: &HeaderMap,
body: Bytes,
) -> Attempt {
let conditions = [IF_MATCH, IF_NONE_MATCH]
.into_iter()
.filter_map(|name| {
headers
.get(&name)
.map(|value| (name, value))
});
let request = self
.client
.request(method, format!("{member}/v1/keys/{key}"))
.body(body);
let request = conditions.fold(request, |request, (name, value)| {
request.header(name, value)
});
let response = match request.send().await {
Ok(response) => response,
Err(error) if error.is_connect() => return Attempt::Refused(None),
Err(error) => return Attempt::Unknown(error.to_string()),
};
let status = response.status();
let redirected = status == StatusCode::MISDIRECTED_REQUEST;
if !redirected && status != StatusCode::SERVICE_UNAVAILABLE {
return Attempt::Served(relay(response).await);
}
match response.json::<Problem>().await {
Ok(problem) if redirected || problem.kind == NO_LEADER => {
Attempt::Refused(problem.named_leader())
}
Ok(problem) => Attempt::Unknown(problem.detail),
Err(error) => Attempt::Unknown(error.to_string()),
}
}
}
/// Passes a member's response on to the client: status, version and body.
async fn relay(response: reqwest::Response) -> Response {
let status = response.status();
let headers: HeaderMap = [ETAG, CONTENT_TYPE]
.into_iter()
.filter_map(|name| {
response
.headers()
.get(&name)
.map(|value| (name, value.clone()))
})
.collect();
match response.bytes().await {
Ok(body) => (status, headers, body).into_response(),
Err(error) => ApiError::Unavailable(error.to_string()).into_response(),
}
}
forward is the loop around it. A refusal moves the guess to the member that the
refusal named, or failing that to the next in the list. When every member has refused
once, an election must be in progress, and the router then waits 150 ms between
further attempts, so that it does not flood a shard which is busy choosing a leader.
Listing 5.6 Trying members until one serves the request. kvrouter/src/main.rs
/// How many times the router asks a member before it gives up.
const ATTEMPTS: usize = 12;
/// How long the router waits before it asks again, once every member of a
/// shard has refused.
const PAUSE: Duration = Duration::from_millis(150);
impl Routes {
/// Sends a request to the leader of the key's shard. A member that
/// refuses has changed nothing, so the router asks another.
async fn forward(
&self,
method: Method,
key: &str,
headers: &HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(key)?;
let shard = self
.shard_of(key)
.ok_or(ApiError::NoLeader)?;
for attempt in 1..=ATTEMPTS {
let tried = shard.leader.load(Ordering::Relaxed);
let member = &shard.members[tried];
let named = match self
.ask(member, method.clone(), key, headers, body.clone())
.await
{
Attempt::Served(response) => return Ok(response),
Attempt::Unknown(detail) => return Err(ApiError::Unavailable(detail)),
Attempt::Refused(named) => named,
};
let next = named
.filter(|&position| position < shard.members.len())
.unwrap_or((tried + 1) % shard.members.len());
shard
.leader
.store(next, Ordering::Relaxed);
// Every member has refused once: the shard is electing a leader.
if attempt >= shard.members.len() {
sleep(PAUSE).await;
}
}
Err(ApiError::NoLeader)
}
}
Listing 5.7 The HTTP handlers, and the start of the program. kvrouter/src/main.rs
async fn key(
State(routes): State<Arc<Routes>>,
method: Method,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
routes
.forward(method, &key, &headers, body)
.await
}
#[derive(Serialize)]
struct ShardHealth<'a> {
name: &'a str,
/// The address the router currently sends this shard's requests to.
leader: &'a str,
}
async fn health(State(routes): State<Arc<Routes>>) -> Json<serde_json::Value> {
let mut shards: Vec<ShardHealth> = routes
.shards
.iter()
.map(|(name, shard)| ShardHealth {
name,
leader: &shard.members[shard.leader.load(Ordering::Relaxed)],
})
.collect();
shards.sort_by_key(|shard| shard.name);
Json(serde_json::json!({ "status": "ok", "shards": shards }))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let config: Config = serde_json::from_str(&fs::read_to_string(&options.ring)?)?;
let routes = Arc::new(Routes::load(config));
println!("routing to {} shards", routes.shards.len());
let app = Router::new()
.route("/v1/keys/{key}", get(key).put(key).delete(key))
.route("/healthz", get(health))
.with_state(routes);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
2 shards behind 1 router
We start 6 kvraft members in 2 groups of 3, then the router, and write 400 keys
through it.
$ ./target/release/kvrouter --listen 127.0.0.1:7000 --ring ring.json &
routing to 2 shards
listening on 127.0.0.1:7000
$ ./target/release/kvctl load --server http://127.0.0.1:7000 --count 400 --acked acked.txt
400 writes acknowledged
$ for port in 7013 7023; do curl http://127.0.0.1:$port/healthz; echo; done
{"id":3,"role":"leader","term":1,"leader":3,"last_sequence":206,"committed":206}
{"id":3,"role":"leader","term":1,"leader":3,"last_sequence":196,"committed":196}
$ ./target/release/kvctl verify --server http://127.0.0.1:7000 --acked acked.txt
400 acknowledged, 400 found, 0 missing
| Output | What it shows |
|---|---|
| logs end at 206 and 196 | The 400 keys were divided 205 to 195. Each log also contains 1 term marker. |
| 400 found, 0 missing | A read through the router reaches the shard that holds the key. |
The client used a single address throughout. It never learned how many shards exist, or which members lead them.
A leader is lost behind the router
probe, from chapter 4, is now given the router’s address and no other. While it is
writing, the leader of shard-a is killed.
$ ./target/release/probe --seconds 12 --acked acked.txt --server http://127.0.0.1:7000 &
$ sleep 4; kill -9 <pid of member 3 of shard-a, its leader>
780 writes acknowledged in 12 seconds, 0 attempts refused
the longest pause between 2 acknowledged writes was 1.08 s, ending 5.2 s after the start
$ curl http://127.0.0.1:7000/healthz
{"shards":[{"leader":"http://127.0.0.1:7011","name":"shard-a"},{"leader":"http://127.0.0.1:7023","name":"shard-b"}],"status":"ok"}
$ ./target/release/kvctl verify --server http://127.0.0.1:7000 --acked acked.txt
780 acknowledged, 780 found, 0 missing
| Output | What it shows |
|---|---|
| 0 attempts refused | The client saw no error of any kind. In chapter 4, without a router, the same experiment produced 51 refusals. |
| longest pause 1.08 s | Requests for shard-a waited inside the router while the shard elected a leader. |
leader of shard-a is now member 1 | The router found the new leader by asking, and remembered it. |
| 780 found, 0 missing | No acknowledged write was lost. |
The comparison with chapter 4 shows what the router contributes. The election took as long as it did before, about a second, since the router does nothing to hasten it. What has changed is what the client experiences. A second of refusals, which every client would have had to be written to handle, has become a second of waiting, which requires nothing of the client at all.
Writes to shard-b were at no time affected. probe sends its keys in turn, and so
it waited behind the 1 request for shard-a that was being held. Independent clients
writing to shard-b would have noticed nothing.
What is still missing
| Missing | Effect |
|---|---|
| The ring is a file read at startup. | To add a shard, the router has to be restarted, and existing keys do not move to the new shard. |
| Members do not check which shard a key belongs to. | A router with a wrong ring file could write a key to the wrong shard. Chapter 1 prevents this by sending the version of the ring with every request. |
| Listing keys by prefix is not routed. | The router would have to ask every shard and merge the answers. |
The complete program
Listing 5.8 The complete
kvrouter/Cargo.toml. kvrouter/Cargo.toml
[package]
name = "kvrouter"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kvd.workspace = true
ring.workspace = true
axum.workspace = true
clap.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
Listing 5.9 The complete
kvrouter/src/main.rs. kvrouter/src/main.rs
//! Command kvrouter sends each request to the shard that owns its key, and
//! within that shard to the member that leads. Clients talk to the router
//! and need to know nothing about shards or leaders.
//!
//! Run it from code/ with:
//!
//! cargo run -p kvrouter -- --listen 127.0.0.1:7000 --ring ring.json
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_MATCH, IF_NONE_MATCH};
use axum::http::{HeaderMap, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use clap::Parser;
use kvd::api::{ApiError, valid_key};
use ring::Ring;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio::time::sleep;
#[derive(Parser)]
struct Options {
#[arg(long, default_value = "127.0.0.1:7000")]
listen: SocketAddr,
/// A JSON file that lists the shards and the addresses of their members.
#[arg(long)]
ring: PathBuf,
}
/// The contents of the ring file.
#[derive(Deserialize)]
struct Config {
virtual_nodes: u32,
shards: Vec<ShardConfig>,
}
#[derive(Deserialize)]
struct ShardConfig {
name: String,
/// The HTTP address of each member, in the order of their ids: member 1 first.
members: Vec<String>,
}
/// A shard, as the router knows it.
struct Shard {
members: Vec<String>,
/// The position in `members` of the member that led when the router last asked.
leader: AtomicUsize,
}
struct Routes {
ring: Ring,
shards: HashMap<String, Shard>,
client: reqwest::Client,
}
impl Routes {
/// Builds the ring from the file: every shard receives its tokens.
fn load(config: Config) -> Routes {
let mut ring = Ring::new(config.virtual_nodes);
let mut shards = HashMap::new();
for shard in config.shards {
ring.add(&shard.name);
shards.insert(
shard.name,
Shard {
members: shard.members,
leader: AtomicUsize::new(0),
},
);
}
Routes {
ring,
shards,
client: reqwest::Client::new(),
}
}
/// The shard that owns a key.
fn shard_of(&self, key: &str) -> Option<&Shard> {
self.shards
.get(self.ring.owner(key)?)
}
}
/// What a member said about a request it did not serve.
#[derive(Deserialize)]
struct Problem {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
detail: String,
}
impl Problem {
/// The position of the leader in the shard's list of members, when the
/// member named it, as in "the leader is member 3".
fn named_leader(&self) -> Option<usize> {
let id: usize = self
.detail
.rsplit(' ')
.next()?
.parse()
.ok()?;
id.checked_sub(1)
}
}
/// The result of asking one member.
enum Attempt {
/// The member served the request. This is its response.
Served(Response),
/// The member changed nothing. It may have named the leader.
Refused(Option<usize>),
/// The request may or may not have taken effect.
Unknown(String),
}
const NO_LEADER: &str = "/problems/no-leader";
impl Routes {
/// Sends the request to one member and classifies what comes back.
async fn ask(
&self,
member: &str,
method: Method,
key: &str,
headers: &HeaderMap,
body: Bytes,
) -> Attempt {
let conditions = [IF_MATCH, IF_NONE_MATCH]
.into_iter()
.filter_map(|name| {
headers
.get(&name)
.map(|value| (name, value))
});
let request = self
.client
.request(method, format!("{member}/v1/keys/{key}"))
.body(body);
let request = conditions.fold(request, |request, (name, value)| {
request.header(name, value)
});
let response = match request.send().await {
Ok(response) => response,
Err(error) if error.is_connect() => return Attempt::Refused(None),
Err(error) => return Attempt::Unknown(error.to_string()),
};
let status = response.status();
let redirected = status == StatusCode::MISDIRECTED_REQUEST;
if !redirected && status != StatusCode::SERVICE_UNAVAILABLE {
return Attempt::Served(relay(response).await);
}
match response.json::<Problem>().await {
Ok(problem) if redirected || problem.kind == NO_LEADER => {
Attempt::Refused(problem.named_leader())
}
Ok(problem) => Attempt::Unknown(problem.detail),
Err(error) => Attempt::Unknown(error.to_string()),
}
}
}
/// Passes a member's response on to the client: status, version and body.
async fn relay(response: reqwest::Response) -> Response {
let status = response.status();
let headers: HeaderMap = [ETAG, CONTENT_TYPE]
.into_iter()
.filter_map(|name| {
response
.headers()
.get(&name)
.map(|value| (name, value.clone()))
})
.collect();
match response.bytes().await {
Ok(body) => (status, headers, body).into_response(),
Err(error) => ApiError::Unavailable(error.to_string()).into_response(),
}
}
/// How many times the router asks a member before it gives up.
const ATTEMPTS: usize = 12;
/// How long the router waits before it asks again, once every member of a
/// shard has refused.
const PAUSE: Duration = Duration::from_millis(150);
impl Routes {
/// Sends a request to the leader of the key's shard. A member that
/// refuses has changed nothing, so the router asks another.
async fn forward(
&self,
method: Method,
key: &str,
headers: &HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(key)?;
let shard = self
.shard_of(key)
.ok_or(ApiError::NoLeader)?;
for attempt in 1..=ATTEMPTS {
let tried = shard.leader.load(Ordering::Relaxed);
let member = &shard.members[tried];
let named = match self
.ask(member, method.clone(), key, headers, body.clone())
.await
{
Attempt::Served(response) => return Ok(response),
Attempt::Unknown(detail) => return Err(ApiError::Unavailable(detail)),
Attempt::Refused(named) => named,
};
let next = named
.filter(|&position| position < shard.members.len())
.unwrap_or((tried + 1) % shard.members.len());
shard
.leader
.store(next, Ordering::Relaxed);
// Every member has refused once: the shard is electing a leader.
if attempt >= shard.members.len() {
sleep(PAUSE).await;
}
}
Err(ApiError::NoLeader)
}
}
async fn key(
State(routes): State<Arc<Routes>>,
method: Method,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
routes
.forward(method, &key, &headers, body)
.await
}
#[derive(Serialize)]
struct ShardHealth<'a> {
name: &'a str,
/// The address the router currently sends this shard's requests to.
leader: &'a str,
}
async fn health(State(routes): State<Arc<Routes>>) -> Json<serde_json::Value> {
let mut shards: Vec<ShardHealth> = routes
.shards
.iter()
.map(|(name, shard)| ShardHealth {
name,
leader: &shard.members[shard.leader.load(Ordering::Relaxed)],
})
.collect();
shards.sort_by_key(|shard| shard.name);
Json(serde_json::json!({ "status": "ok", "shards": shards }))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let config: Config = serde_json::from_str(&fs::read_to_string(&options.ring)?)?;
let routes = Arc::new(Routes::load(config));
println!("routing to {} shards", routes.shards.len());
let app = Router::new()
.route("/v1/keys/{key}", get(key).put(key).delete(key))
.route("/healthz", get(health))
.with_state(routes);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
Summary
- A router hashes the key, finds the shard on the ring, and forwards the request to the leader of that shard, and it holds no data of its own.
- The router keeps a guess at the leader of each shard and corrects the guess from the answers it receives.
- It asks another member only when the first attempt certainly changed nothing: after a follower’s
421, after a503that reports no leader, or after a refused connection. - After a timeout it answers
503, since the write may have been performed, and only the client knows whether the request is safe to repeat. - With 2 shards, 400 keys were divided 205 to 195, and every key was found again through the router.
- When a leader was killed, clients behind the router experienced a pause of 1.08 seconds and no errors.
Key-Value Store: Group Commit (Low-Level Design)
The capacity estimate of chapter 1 arrived at 24 nodes by assuming that a node stores 20 writes with every wait for its disk. Without that assumption the same calculation gave 325 nodes. Nothing built so far does anything of the kind. The storage node of chapter 2 waits once for every write, and reaches its limit at about 180 writes per second however many clients are sending to it, since each must wait its turn for a disk that confirms 185 times a second.
This chapter adds the missing technique. Under group commit, a node stores all the writes that are waiting at a given moment with a single wait for the disk. The change is small, some 60 lines in the store and 1 thread in the server, and its effect is out of all proportion to its size: the measured rate rises from 180 writes per second to almost 5000 with 64 clients, and beyond 17000 with 256. It is among the best examples in systems programming of a large gain obtained by rearranging work, with no part of the work made faster.
This chapter covers
- why a single wait for the disk can serve many writes
- a committer thread that forms batches without a timer
- conditional writes within a batch
- measurements with 1, 8, 64 and 256 clients
- a crash in the middle of a batch
The idea
A wait for the disk takes about 5.4 ms whether it stores 1 record or 100. Nearly all of that time is spent by the disk in confirming that the data is safe, and very little in transferring the data, so that the cost is close to a fixed charge for each wait and almost independent of how much is written. Writes that arrive while 1 wait is in progress can therefore share the next.
The design uses no timer, and the point deserves emphasis because the alternative is common. Some systems hold a write back for a millisecond or so in the hope that others will arrive to share its wait. This improves throughput under heavy load and penalises every write under light load, when no others are coming. Here a batch is formed only from writes that are already waiting. A write that arrives alone is stored at once, with no delay, and the batches grow of their own accord as the load grows, since the busier the node is, the more writes accumulate during each wait. The mechanism adjusts itself to the load without any parameter that an operator has to tune.
A batch in the store
Store::commit takes a list of writes and returns a result for each of them.
Listing 6.1 The imports of
kv/src/store.rs. kv/src/store.rs
use std::collections::BTreeMap;
use std::io;
use std::ops::Bound;
use std::path::Path;
use thiserror::Error;
use crate::log::Log;
use crate::record::{Change, Record};
Listing 6.2 Storing a batch of writes with a single wait for the disk. kv/src/store.rs
/// One write of a batch: the change, and the condition it carries.
pub struct Write {
pub change: Change,
pub condition: Condition,
}
impl Store {
/// Performs several writes with a single wait for the disk. Each write
/// is checked as if the writes before it in the batch had already
/// happened. The result of each is returned in the same order.
pub fn commit(&mut self, writes: Vec<Write>) -> Vec<Result<Written, StoreError>> {
// The versions that keys will have once the accepted writes of this
// batch are applied. `None` is a key that a write of the batch deletes.
let mut pending: BTreeMap<String, Option<u64>> = BTreeMap::new();
let mut records = Vec::new();
let mut results = Vec::new();
let first = self.log.last_sequence() + 1;
for write in writes {
let key = match &write.change {
Change::Put { key, .. } | Change::Delete { key } => key.clone(),
};
let current = match pending.get(&key) {
Some(version) => *version,
None => self.version(&key),
};
let deletes = matches!(write.change, Change::Delete { .. });
let checked = if deletes && current.is_none() {
Err(StoreError::NotFound)
} else {
check(write.condition, current)
};
if let Err(error) = checked {
results.push(Err(error));
continue;
}
let sequence = first + records.len() as u64;
let version = if deletes { None } else { Some(sequence) };
pending.insert(key, version);
results.push(Ok(Written {
version: sequence,
created: current.is_none(),
}));
records.push(Record {
sequence,
change: write.change,
});
}
// One write and one wait for the disk, for the whole batch.
if let Err(error) = self.log.append_records(&records) {
let failure = error.to_string();
return results
.into_iter()
.map(|result| result.and(Err(StoreError::Log(io::Error::other(failure.clone())))))
.collect();
}
for record in records {
apply(&mut self.entries, record);
}
results
}
}
The method works in 3 passes.
| Pass | What happens |
|---|---|
| Check | The condition of each write is checked, and the writes that are accepted receive the next sequence numbers. |
| Store | All the accepted records go to the log in 1 write, followed by 1 wait for the disk. |
| Apply | The records are applied to the map, and the results are returned. |
Conditional writes need care in the first pass. Suppose that a batch contains 2
writes to user-1, each carrying If-None-Match: *, which means “create only”. The
first ought to succeed and the second to fail, although neither is yet in the map,
and a check against the map alone would accept both. The pending map resolves the
difficulty. It records the version that each key will have once the accepted writes
of the batch have been applied, and a later write in the same batch is checked
against it. The batch thus behaves, as far as any client can tell, exactly as if its
writes had been performed in sequence, which is the property that makes batching
invisible.
The order of the passes preserves invariant 1 of chapter 2. No result leaves commit
before the wait for the disk has ended, and so no client is answered early.
The committer
A single thread, the committer, owns the store. The handlers of requests do not touch the store at all. Each hands its write to the committer through a channel and waits for the result.
Listing 6.3 The imports of
kvgroup/src/main.rs. kvgroup/src/main.rs
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use axum::Router;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::header::ETAG;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::put;
use clap::Parser;
use kv::{Change, Store, StoreError, Write, Written};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, write_condition};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
Listing 6.4 The options, and a write on its way to the committer. kvgroup/src/main.rs
#[derive(Parser)]
struct Options {
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
#[arg(long, default_value = "data")]
data: PathBuf,
/// The largest number of writes stored with one wait for the disk.
#[arg(long, default_value_t = 256)]
batch: usize,
}
/// A write on its way to the committer, with the channel for its result.
struct Request {
write: Write,
reply: oneshot::Sender<Result<Written, StoreError>>,
}
Listing 6.5 The committer thread. kvgroup/src/main.rs
/// The committer: the one thread that owns the store. It takes the write
/// that has waited longest, adds every write that arrived meanwhile, and
/// stores them together.
fn run_committer(mut store: Store, requests: Receiver<Request>, limit: usize) {
let (mut batches, mut writes) = (0u64, 0u64);
while let Ok(first) = requests.recv() {
// The first request, and whatever else is already waiting.
let mut batch = vec![first];
batch.extend(requests.try_iter().take(limit - 1));
let (changes, replies): (Vec<Write>, Vec<_>) = batch
.into_iter()
.map(|request| (request.write, request.reply))
.unzip();
batches += 1;
writes += changes.len() as u64;
// A handler that has gone away no longer wants its result.
store
.commit(changes)
.into_iter()
.zip(replies)
.for_each(|(result, reply)| drop(reply.send(result)));
if batches.is_multiple_of(200) {
let average = writes as f64 / batches as f64;
println!("{writes} writes in {batches} batches, {average:.1} writes per batch");
}
}
}
This loop is the whole of group commit. recv blocks until a write arrives.
try_iter then yields every write that is already in the channel, without waiting
for more, up to a limit of 256 for the batch. While commit is waiting for the disk,
new writes accumulate in the channel, and the next turn of the loop takes all of
them.
Giving the store to 1 thread has a further merit. Since no other thread touches it, the store needs no lock, and the question of what 2 concurrent writers might do to the map does not arise. Concurrency has been confined to the channel, where it is handled by a well-tested library, and the logic of the store remains sequential.
Listing 6.6 Handing a write to the committer, and the HTTP handlers. kvgroup/src/main.rs
/// Hands a write to the committer and waits for its result.
async fn submit(committer: &Sender<Request>, write: Write) -> Result<Written, ApiError> {
let (reply, result) = oneshot::channel();
let stopped = || ApiError::Unavailable("the committer has stopped".into());
if committer
.send(Request { write, reply })
.is_err()
{
return Err(stopped());
}
let Ok(outcome) = result.await else {
return Err(stopped());
};
Ok(outcome?)
}
async fn put_key(
State(committer): State<Sender<Request>>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let write = Write {
change: Change::Put {
key,
value: body.to_vec(),
},
condition: write_condition(&headers)?,
};
let written = submit(&committer, write).await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(committer): State<Sender<Request>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let write = Write {
change: Change::Delete { key },
condition: write_condition(&headers)?,
};
submit(&committer, write).await?;
Ok(StatusCode::NO_CONTENT)
}
Listing 6.7 Starting the committer and the server. kvgroup/src/main.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let (store, applied, discarded) = Store::open(&options.data, true)?;
println!("recovered {applied} records, cut {discarded} bytes from the end of the log");
let (committer, requests) = mpsc::channel();
thread::spawn(move || run_committer(store, requests, options.batch));
let routes = Router::new()
.route("/v1/keys/{key}", put(put_key).delete(delete_key))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(committer);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Measurements
kvbench runs a number of clients simultaneously. Each client sends its writes in
succession and waits for every answer before sending the next.
Listing 6.8 The imports of
kvbench/src/main.rs. kvbench/src/main.rs
use std::error::Error;
use std::fs;
use std::path::PathBuf;
use std::thread;
use std::time::Instant;
use clap::Parser;
use ureq::Agent;
Listing 6.9 Writing from several clients at once. kvbench/src/main.rs
#[derive(Parser)]
struct Options {
#[arg(long)]
server: String,
/// The number of clients that write at the same time.
#[arg(long, default_value_t = 8)]
clients: u32,
/// The number of writes each client makes.
#[arg(long, default_value_t = 200)]
writes: u32,
#[arg(long)]
acked: PathBuf,
}
/// The writes of one client. It returns the keys that were acknowledged.
fn client(server: &str, number: u32, writes: u32) -> Vec<String> {
let agent = Agent::new_with_defaults();
let mut acknowledged = Vec::new();
for write in 1..=writes {
let key = format!("key-{number:03}-{write:06}");
let address = format!("{server}/v1/keys/{key}");
if agent
.put(&address)
.send(format!("value of {key}"))
.is_ok()
{
acknowledged.push(key);
}
}
acknowledged
}
fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let (server, writes) = (options.server.as_str(), options.writes);
let started = Instant::now();
let acknowledged: Vec<String> = thread::scope(|scope| {
let clients: Vec<_> = (1..=options.clients)
.map(|number| scope.spawn(move || client(server, number, writes)))
.collect();
clients
.into_iter()
.flat_map(|client| client.join().unwrap_or_default())
.collect()
});
let seconds = started.elapsed().as_secs_f64();
let rate = acknowledged.len() as f64 / seconds;
fs::write(&options.acked, acknowledged.join("\n") + "\n")?;
println!(
"{} clients: {} writes acknowledged in {seconds:.2} s, {rate:.0} writes per second",
options.clients,
acknowledged.len()
);
Ok(())
}
$ ./target/release/kvd --data /var/tmp/kvd &
$ ./target/release/kvbench --server http://127.0.0.1:7001 --clients 64 --writes 12 --acked acked.txt
64 clients: 768 writes acknowledged in 4.27 s, 180 writes per second
$ ./target/release/kvgroup --data /var/tmp/kvgroup &
$ ./target/release/kvbench --server http://127.0.0.1:7001 --clients 64 --writes 400 --acked acked.txt
64 clients: 25600 writes acknowledged in 5.13 s, 4993 writes per second
25535 writes in 800 batches, 31.9 writes per batch
| Clients | Without | With group commit | Writes in a batch |
|---|---|---|---|
| 1 | 167 | 165 | 1.0 |
| 8 | 181 | 712 | 4.0 |
| 64 | 180 | 4993 | 31.9 |
| 256 | not measured | 17218 | 119.9 |
Several things can be read from the table. A single client gains nothing, since it sends its next write only when the previous one has been answered, and there is consequently never a second write with which a wait could be shared. Group commit improves throughput under concurrency and does nothing for the latency of an individual write, which remains 1 wait for the disk.
The size of a batch is about half the number of clients: 4 of 8, 32 of 64, 120 of 256. The explanation is that the clients fall into 2 groups that alternate. While 1 half is waiting in the batch that is being stored, the other half has just been answered and is sending its next writes, which form the following batch.
And the assumption of chapter 1 is confirmed with something to spare. The estimate supposed 20 writes for each wait and 3700 writes per second. With 64 clients the node achieved 31.9 writes for each wait and 4993 writes per second.
A crash in the middle of a batch
The server is killed while 64 clients are writing. The program kvd of chapter 2
then opens the same data directory, which incidentally demonstrates that the 2
programs use the same format for the log.
$ ./target/release/kvgroup --data /var/tmp/kvgroup &
$ ./target/release/kvbench --server http://127.0.0.1:7001 --clients 64 --writes 2000 --acked acked.txt &
$ sleep 2; kill -9 <pid of kvgroup>
64 clients: 9341 writes acknowledged in 2.74 s, 3412 writes per second
$ ./target/release/kvd --data /var/tmp/kvgroup &
recovered 9366 keys from 9366 records, cut 0 bytes from the end of the log
$ ./target/release/kvctl verify --server http://127.0.0.1:7001 --acked acked.txt
9341 acknowledged, 9341 found, 0 missing
Every one of the 9341 acknowledged writes is present. The log contains 25 more, which are the batch that had reached the disk when the process was killed and whose answers had not yet been sent. These are the durable and unacknowledged writes of chapter 2, the third case of its figure of crash points, now occurring 25 together. Group commit has not altered the guarantee in any respect. It has enlarged the number of writes that may be in that uncertain state at the moment of a crash from 1 to the size of a batch, and each of the 25 clients concerned resolves the uncertainty in the usual way, by repeating its request.
What is still missing
| Missing | Effect |
|---|---|
kvgroup serves writes only. | Reads need access to the map, which the committer thread owns. A shared view of the map for reading would provide it. |
| The replicated nodes of chapters 3 and 4 still store 1 write for each round trip. | Their leaders could send a whole batch in 1 Append, which the protocol already permits, and the followers already store the records of a message with 1 wait. |
The complete programs
Store::commit is part of kv/src/store.rs, which is listed in full in chapter 2.
Listing 6.10 The complete
kvgroup/src/main.rs. kvgroup/src/main.rs
//! Command kvgroup is the storage node of chapter 2 with group commit: one
//! thread writes the log, and it stores every write that is waiting with a
//! single wait for the disk.
//!
//! Run it from code/ with:
//!
//! cargo run --release -p kvgroup -- --listen 127.0.0.1:7001 --data /var/tmp/kvgroup
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use axum::Router;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::header::ETAG;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::put;
use clap::Parser;
use kv::{Change, Store, StoreError, Write, Written};
use kvd::api::{ApiError, MAX_VALUE, etag, valid_key, write_condition};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
#[derive(Parser)]
struct Options {
#[arg(long, default_value = "127.0.0.1:7001")]
listen: SocketAddr,
#[arg(long, default_value = "data")]
data: PathBuf,
/// The largest number of writes stored with one wait for the disk.
#[arg(long, default_value_t = 256)]
batch: usize,
}
/// A write on its way to the committer, with the channel for its result.
struct Request {
write: Write,
reply: oneshot::Sender<Result<Written, StoreError>>,
}
/// The committer: the one thread that owns the store. It takes the write
/// that has waited longest, adds every write that arrived meanwhile, and
/// stores them together.
fn run_committer(mut store: Store, requests: Receiver<Request>, limit: usize) {
let (mut batches, mut writes) = (0u64, 0u64);
while let Ok(first) = requests.recv() {
// The first request, and whatever else is already waiting.
let mut batch = vec![first];
batch.extend(requests.try_iter().take(limit - 1));
let (changes, replies): (Vec<Write>, Vec<_>) = batch
.into_iter()
.map(|request| (request.write, request.reply))
.unzip();
batches += 1;
writes += changes.len() as u64;
// A handler that has gone away no longer wants its result.
store
.commit(changes)
.into_iter()
.zip(replies)
.for_each(|(result, reply)| drop(reply.send(result)));
if batches.is_multiple_of(200) {
let average = writes as f64 / batches as f64;
println!("{writes} writes in {batches} batches, {average:.1} writes per batch");
}
}
}
/// Hands a write to the committer and waits for its result.
async fn submit(committer: &Sender<Request>, write: Write) -> Result<Written, ApiError> {
let (reply, result) = oneshot::channel();
let stopped = || ApiError::Unavailable("the committer has stopped".into());
if committer
.send(Request { write, reply })
.is_err()
{
return Err(stopped());
}
let Ok(outcome) = result.await else {
return Err(stopped());
};
Ok(outcome?)
}
async fn put_key(
State(committer): State<Sender<Request>>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
valid_key(&key)?;
let write = Write {
change: Change::Put {
key,
value: body.to_vec(),
},
condition: write_condition(&headers)?,
};
let written = submit(&committer, write).await?;
let status = if written.created {
StatusCode::CREATED
} else {
StatusCode::NO_CONTENT
};
Ok((status, [(ETAG, etag(written.version))]).into_response())
}
async fn delete_key(
State(committer): State<Sender<Request>>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
valid_key(&key)?;
let write = Write {
change: Change::Delete { key },
condition: write_condition(&headers)?,
};
submit(&committer, write).await?;
Ok(StatusCode::NO_CONTENT)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let (store, applied, discarded) = Store::open(&options.data, true)?;
println!("recovered {applied} records, cut {discarded} bytes from the end of the log");
let (committer, requests) = mpsc::channel();
thread::spawn(move || run_committer(store, requests, options.batch));
let routes = Router::new()
.route("/v1/keys/{key}", put(put_key).delete(delete_key))
.layer(DefaultBodyLimit::max(MAX_VALUE))
.with_state(committer);
let listener = TcpListener::bind(options.listen).await?;
println!("listening on {}", listener.local_addr()?);
axum::serve(listener, routes).await?;
Ok(())
}
Listing 6.11 The complete
kvbench/src/main.rs. kvbench/src/main.rs
//! Command kvbench writes keys to a server from several clients at once and
//! prints the number of writes per second. Every acknowledged key is added
//! to a file, in the format that `kvctl verify` reads.
//!
//! Run it from code/ with:
//!
//! cargo run --release -p kvbench -- --server http://127.0.0.1:7001 --clients 64 --writes 200 --acked acked.txt
use std::error::Error;
use std::fs;
use std::path::PathBuf;
use std::thread;
use std::time::Instant;
use clap::Parser;
use ureq::Agent;
#[derive(Parser)]
struct Options {
#[arg(long)]
server: String,
/// The number of clients that write at the same time.
#[arg(long, default_value_t = 8)]
clients: u32,
/// The number of writes each client makes.
#[arg(long, default_value_t = 200)]
writes: u32,
#[arg(long)]
acked: PathBuf,
}
/// The writes of one client. It returns the keys that were acknowledged.
fn client(server: &str, number: u32, writes: u32) -> Vec<String> {
let agent = Agent::new_with_defaults();
let mut acknowledged = Vec::new();
for write in 1..=writes {
let key = format!("key-{number:03}-{write:06}");
let address = format!("{server}/v1/keys/{key}");
if agent
.put(&address)
.send(format!("value of {key}"))
.is_ok()
{
acknowledged.push(key);
}
}
acknowledged
}
fn main() -> Result<(), Box<dyn Error>> {
let options = Options::parse();
let (server, writes) = (options.server.as_str(), options.writes);
let started = Instant::now();
let acknowledged: Vec<String> = thread::scope(|scope| {
let clients: Vec<_> = (1..=options.clients)
.map(|number| scope.spawn(move || client(server, number, writes)))
.collect();
clients
.into_iter()
.flat_map(|client| client.join().unwrap_or_default())
.collect()
});
let seconds = started.elapsed().as_secs_f64();
let rate = acknowledged.len() as f64 / seconds;
fs::write(&options.acked, acknowledged.join("\n") + "\n")?;
println!(
"{} clients: {} writes acknowledged in {seconds:.2} s, {rate:.0} writes per second",
options.clients,
acknowledged.len()
);
Ok(())
}
Further reading
- D. DeWitt and others, “Implementation Techniques for Main Memory Database Systems”, SIGMOD 1984, which introduced group commit.
- J. Gray and A. Reuter, Transaction Processing: Concepts and Techniques, Morgan Kaufmann, 1992.
Summary
- A wait for the disk costs nearly the same for 1 record as for 100, so that the writes which arrive during a wait can share the next.
- The committer thread takes every write that is waiting and stores them together. It uses no timer, and a write that arrives alone is not delayed.
- Within a batch, every conditional write is checked against the writes that precede it, so that a batch behaves as its writes would in sequence.
- With 64 clients the node stored 4993 writes per second, against 180 without group commit, and with 256 clients it stored 17218.
- After being killed under load, the node held all 9341 acknowledged writes, and 25 more that had reached the disk without being acknowledged.
Rate Limiter: System Design
A service that accepts requests from the outside world has no say in how many of them arrive. Its capacity is fixed by the machines it runs on, while the demand placed on it is decided by other people’s programs, and whenever demand exceeds capacity for more than a moment the service fails in a way that does not distinguish between the guilty and the innocent: the requests of a well-behaved client wait in the same queue as the requests of the client that caused the overload, and they time out together. A rate limiter gives the service back some of the control it lacks. It attaches an allowance to each caller, counts what each caller sends, and refuses whatever exceeds the allowance before the service has spent any work on it, so that a problem which would have been shared by everybody becomes a refusal addressed to the caller responsible.
Stated like this, the idea seems to leave little to design, and yet 2 parts of it turn out to be subtle. The first is the meaning of the allowance itself, because a phrase such as “5 requests per second” admits several readings that differ sharply in what they let through, and each reading leads to a different algorithm. The second is the counting, which is trivial on 1 machine and becomes a small distributed systems problem as soon as the limiter runs on several machines that each see only a part of a caller’s traffic. This chapter takes the 2 in that order. It examines 5 algorithms by following the same 11 requests through each of them, and then builds the system around the one we choose.
This chapter covers
- why a service limits its callers, and what happens to a service that does not
- the rate and the burst, which every limit contains whether or not it says so
- 5 algorithms followed step by step on the same traffic: fixed window counter, sliding window log, sliding window counter, token bucket and leaky bucket
- how the algorithms compare and how to choose between them
- where a limiter runs, and whose requests it counts together
- counting correctly when the limiter runs on many machines
- what a refused caller should be told
- what the limiter does when its own parts fail
Why a service limits its callers
Consider an API server that can answer 1000 requests per second and serves 200 clients, each of which normally sends a few requests per second. One day a client is released with a defect in its error handling, so that a failed call is retried immediately and without pause, and this single client begins to send 5000 requests per second. The server has no means of setting these requests aside, since it cannot know who sent a request until it has accepted the connection and read the request, and by then the cost has been paid. Its queue lengthens and the response time of every client grows with it. Some of the other clients, finding their calls slow, give up and try again, which adds to the queue the very requests that were already in it. Within a minute or so the 199 clients that did nothing wrong are receiving errors on account of the 1 that did, and the operators of the service are looking at a failure whose cause lies in a program they do not own and cannot fix.
With a rate limiter in front of the server, the same defect has a very different outcome. The limiter recognises the caller, finds that it has exhausted its allowance, and answers with a short refusal that costs a small fraction of what a real response would have cost. The defective client receives a great many refusals, which is an accurate reflection of its behaviour, and the remaining clients are served as usual and never learn that anything was wrong.
Protection against overload is the best known reason for limiting callers, and there are 2 others that a design usually has to serve at the same time. One is fairness: a large customer running a nightly export may stay well within what the service can bear and still consume so much of it that smaller customers are crowded out, and an allowance per customer is the simplest way of dividing capacity among them. The other is the control of cost and abuse, where the concern is what each individual request makes possible. If every call costs money to answer, an unlimited caller is an unlimited bill, and if every call is a guess at a password, a login form that permits 5 attempts per minute for an account turns guessing into a hopeless undertaking, whereas a form without a limit will patiently evaluate millions of guesses a day. In this last case the limit is a security control as much as a protection, a distinction that matters later when we decide how the limiter should behave when it breaks.
It is worth separating the rate limiter from a neighbouring mechanism with which it is often confused. Load shedding is what a service does when it is close to overload and drops a share of its incoming requests, without regard to who sent them, in order to keep answering the rest. A rate limiter acts earlier and on different grounds, since it refuses a particular caller for exceeding that caller’s own allowance, and it does so even when the service has capacity to spare. The 2 complement each other, and large systems generally have both.
What “5 requests per second” can mean
The phrase “each client may send 5 requests per second” has the appearance of a complete specification, and the easiest way to see that it is incomplete is to imagine 3 clients and to ask of each whether it has kept to the limit.
| Client | What it sends |
|---|---|
| A | 1 request every 200 ms, for an hour |
| B | nothing for 10 seconds, and then 5 requests within the same millisecond |
| C | 5 requests at 0.9 s and 5 more at 1.1 s |
Nobody would dispute that client A is within the limit. Client B has also sent 5 requests in a second, and in fact far fewer than 5 per second on average, although the server received all 5 at the same instant and had to work on them concurrently. Client C sent 5 requests in the first second of the clock and 5 in the next, so that each calendar second contains exactly the permitted number, and nevertheless the server received 10 requests within 200 ms.
Clients B and C expose 2 questions on which the phrase is silent. The first concerns the burst, by which we mean the number of requests that may arrive together. A limit of 5 per second does not say whether the 5 may come in the same instant or must be spread across the second, and there is no universally right answer, because services differ in what they can tolerate. A web API usually ought to permit short bursts, since that is how honest clients behave: a page is opened and issues 6 calls at once, and then nothing happens while a person reads. A service that feeds a device or a downstream system able to handle only one thing after another may need its requests evenly spaced, and would be harmed by exactly the burst that the web API welcomes.
The second question concerns the second itself. “Per second” may refer to the seconds of the clock, each beginning when the previous one ends, or it may refer to every span of 1000 ms regardless of where the span begins. Client C satisfies the first reading and violates the second, and the difference between the 2 readings is a factor of 2 in the load the server may have to carry.
Each of the 5 algorithms that follow amounts to a particular pair of answers to these 2 questions. They are best understood as different definitions of the limit that happen to be cheap to compute, and the reason no one of them has displaced the others is that the right definition depends on what is being protected.
A limit also depends on a third thing, which the phrase names without defining, namely the client. The limiter must count requests under some identity, and the identity under which requests are added together is called the key of the limit. It may be an API key, a user id, an IP address, or some combination of these. The choice of key deserves a section of its own after the algorithms, because a poorly chosen key will undo the work of a well chosen algorithm.
The traffic used in this chapter
So that the algorithms can be compared on equal terms, every one of them is given the same requests from a single client under the same limit of 5 requests per second. The client sends 11 requests within 650 ms, at the following times in milliseconds.
| Request | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Time | 600 | 700 | 800 | 900 | 950 | 1000 | 1050 | 1100 | 1150 | 1200 | 1250 |
By any reading of the limit this client is sending too much, since it issues 11 requests in well under a second where 5 are permitted. The times have been chosen so that the requests lie across the 1 second mark of the clock, with 5 before it and 6 after it, because that is the arrangement on which the algorithms disagree most. One would expect a sound limiter to forward about 5 of the 11 and perhaps a few more as time goes by. The 5 algorithms forward 10, 5, 6, 8 and 9 of them respectively, and the reasons for these differences are the substance of the next 5 sections.
Each algorithm is accompanied by a picture that proceeds in steps, one step for each arriving request. The Next button advances to the following request and shows what the limiter has stored and what it decides. In these pictures a green dot marks a request that was forwarded to the API and a red cross marks a request that was refused.
Algorithm 1: the fixed window counter
The most direct way of enforcing 5 requests per second is to divide time into windows of 1 second, aligned with the clock, and to count the requests that arrive in the current window. The limiter then needs to remember only a single number for each client. Every forwarded request increases the number by 1, a request that finds the number already at 5 is refused, and when the clock passes into the next window the number begins again at 0.
At the end of the sequence 10 of the 11 requests have been forwarded. Requests 1 to 5 arrived between 600 and 950 ms and were counted in window 0, which they filled. At 1000 ms the clock moved into window 1, where the count stood at 0 again, so that requests 6 to 10 were forwarded as readily as the first 5 had been, and only request 11 met a full count.
The server therefore received 10 requests within 600 ms from a client whose limit is 5 per second, and it is important to see that the algorithm did precisely what it was defined to do. Each window contains exactly 5 forwarded requests. The rule is expressed in terms of the seconds of the clock, the client’s burst happened to fall across the boundary between 2 of them, and the rule has nothing to say about spans of time that do not coincide with its windows.
This behaviour is the defining weakness of the fixed window, and its extent can be stated exactly: a client can send up to twice the limit within an arbitrarily short span of time, by sending one full allowance at the end of a window and another at the start of the next. No ill intent is needed for this to occur. A great many programs do their work on a schedule, waking at the start of each minute or each hour, and a limit that is reset on the minute or the hour will meet such a program exactly at its boundary, day after day.
The synchronised reset has a second consequence that is less often noticed. Because the counts of all clients return to 0 at the same instant, all the clients that had been refused and were retrying succeed together at the start of the next window, and the server experiences a surge of traffic at every boundary that the limiter itself has organised.
These properties make the fixed window a poor choice for protecting a server against overload, since the boundary admits twice the load that the limit appears to promise. They matter much less when the window is long and the purpose is to meter usage, and this is where the algorithm is properly at home. Daily and monthly quotas are almost always fixed windows, for the good reason that customers readily understand a quota that “resets at midnight”, and a doubling of the daily quota around midnight is spread over enough time to be harmless. The cost is as low as a limiter’s can be: a count and the number of the window to which it belongs.
Algorithm 2: the sliding window log
The fixed window goes wrong because its windows are fastened to the clock, and the obvious remedy is to fasten the window to the request: when a request arrives, the limiter should look back exactly 1 second from that moment and count what it forwarded during that span, wherever the span happens to begin.
To do this the limiter has to know when each recent request arrived, and so it keeps a log, a list of the arrival times of the requests it has forwarded. On each new request it first discards the times that lie more than 1 second in the past. If fewer than 5 times then remain, the request is forwarded and its own arrival time is appended to the log, and otherwise the request is refused.
This time only 5 requests are forwarded. From 1000 ms onwards each arriving request looks back over the preceding second and finds the 5 entries from 600 to 950 ms still within it, and the fact that the clock has passed the 1 second mark is of no significance to the rule. The oldest entry, 600, leaves the log at 1600 ms, and until then nothing more can pass.
The sliding window log is exact in a sense that none of the other algorithms can match. Whichever span of 1 second one chooses to examine, beginning at any instant whatever, it contains at most 5 forwarded requests, and this is surely what most people have in mind when they say “5 per second”.
Exactness is paid for in memory. Where the fixed window stores 1 number for a client, the log stores a timestamp for every forwarded request up to the limit. For a limit of 5 per second that is 5 timestamps and of no concern. For a limit of 10000 requests per hour it is as many as 10000 timestamps for a single client, about 80 KB, and for a million active clients about 80 GB. The cost thus rises in proportion to the size of the limit, with the awkward result that the most generous limits are the most expensive to enforce.
The description above contains a decision that is easily overlooked, in the words “the requests it has forwarded”. One has to decide whether a refused request should also be entered in the log. In the version shown it is left out, so that a client which keeps on sending is served again as soon as its old entries expire. Some implementations record refused requests as well, and under that rule a client that never stops sending is never served again, since its own refused requests keep the log permanently full. That is a much harsher policy, and occasionally it is the one intended, for instance as a defence against password guessing. What must be avoided is arriving at it unintentionally, which is what happens in an implementation that appends every request to the log first and checks the count afterwards.
The log is therefore the natural choice for limits that are small and must be strict, such as 5 login attempts per minute or 3 password resets per hour, where its memory cost is negligible and its exactness is valuable. It is a poor choice for large limits applied to a large number of clients.
Algorithm 3: the sliding window counter
We now have one algorithm that is cheap and wrong at the boundary and another that is exact and expensive, and it is natural to look for something between them. The sliding window counter keeps the 2 counts that a fixed window limiter would have kept for 2 consecutive windows, and from them it estimates what the log would have reported.
The limiter stores the count of the current window and the count of the window before it. When a request arrives, the second that precedes it overlaps both windows: it includes the whole of the current window up to now, together with the final portion of the previous window. If the current window began 250 ms ago, the preceding second extends 750 ms back into the previous window and so covers 75% of it.
The limiter cannot know how the requests of the previous window were distributed within that window, having kept only their number, and so it makes the simplest assumption available, which is that they were spread evenly. On that assumption 75% of the window contains 75% of its requests, and the number of requests in the preceding second is estimated as
estimate = previous count × share of the previous window still covered + current count
A request is forwarded when the estimate, with the request itself included, does not exceed the limit.
Requests 6 to 10 are refused, as they were by the log. Immediately after the boundary the preceding second still covers nearly all of the previous window, so that nearly all of its 5 requests are counted, and the burst that the fixed window admitted across the boundary does not get through.
Request 11, at 1250 ms, is forwarded, and it is in this decision that the estimate shows what it is. The limiter calculates 5 × 0.75 + 0 = 3.75 and concludes that there is room for another request. In reality all 5 of the earlier requests arrived between 600 and 950 ms and therefore lie entirely within the preceding second, so that the true count is 5, and the log, which knew this, refused the same request. The estimate came out too low because the requests were concentrated at the end of their window, while the formula took them to be evenly spread.
The error can equally fall on the other side. When the requests of the previous window were concentrated at its beginning, the formula continues to count some of them after they have become more than 1 second old, and the limiter then refuses a request that the log would have forwarded. For traffic that is reasonably smooth the error is small in either direction, typically a few percent of the limit, and the worst case is the kind of burst at the very end of a window that we constructed deliberately. Even in the worst case the client gains a fraction of one allowance, where the fixed window would have given it a whole additional allowance.
In exchange for this modest inaccuracy the algorithm needs 2 counts per client whatever the size of the limit. For a limit of 10000 per hour that is 2 numbers where the log requires up to 10000 timestamps, and this trade explains why the sliding window counter is so often chosen for public APIs with large limits and many clients. It should not be used where a small limit has to be enforced exactly.
Algorithm 4: the token bucket
The 3 algorithms considered so far all count requests within a window, and they treat the burst only by implication. The remaining 2 start from a different picture, in which the burst is represented explicitly.
Imagine a bucket that holds tokens, each token being a permission to send 1 request. Tokens are added to the bucket at a steady rate, which in our case is 1 every 200 ms or 5 per second. The bucket has a capacity, here 5 tokens, and a token that arrives when the bucket is already full is discarded. A request is forwarded if it can remove 1 token from the bucket, and it is refused if the bucket is empty.
The 2 numbers that describe a token bucket correspond directly to the 2 questions that a limit has to answer. The rate determines how many requests per second a client can go on sending indefinitely, since over a long period it cannot spend tokens faster than they arrive. The capacity determines how many requests a client can send in the same instant after a period of quiet, since a client that has been silent finds the bucket full and is free to empty it at once. After such a burst the bucket is empty and the client is held to the rate until it pauses long enough for the bucket to fill again.
That the rate and the capacity can be chosen independently is the particular strength of this algorithm. An API whose clients typically open a page with 20 calls and then fall silent can be given a rate of 5 per second and a capacity of 20, which serves those clients well and still bounds what they can sustain. A window counter has a single number at its disposal and cannot express such a limit.
Here 8 requests are forwarded. The first 5 consume the contents of the full bucket. While they are arriving, and in the 300 ms that follow, 650 ms elapse in all, during which 3 further tokens are added, and requests 6, 7 and 10 each find one of them.
This result repays some attention, because it regularly surprises people who have configured a token bucket and then measured it. The limit is described as “5 per second”, and the limiter forwarded 8 requests in 600 ms. The explanation is that a token bucket with rate r and capacity b forwards at most b + r × t requests in any span of t seconds, namely the contents of the bucket together with whatever arrives during the span. Over 1 second, with the numbers used here, this comes to 5 + 5 = 10. Whoever sets the capacity equal to the rate and expects a strict limit of 5 in every second will therefore observe up to twice that number in the second following a quiet period. If the server truly cannot bear more than 5 requests in any second, then the capacity must be made smaller, or the rate lower, or the algorithm of the next section is the one to use.
How the bucket is stored
The picture suggests a timer that drops a token into every bucket every 200 ms. For a million clients that would mean 5 million updates per second, almost all of them applied to the buckets of clients that are sending nothing at all, and no practical limiter works in this way.
The timer can be dispensed with entirely. The limiter stores 2 numbers for each client: the number of tokens in the bucket, and the time at which that number was last brought up to date. When a request arrives, the limiter works out how much time has passed since that moment, adds the tokens that would have arrived in the interval, and caps the result at the capacity. It then removes 1 token if one is available and stores the new number together with the present time. The steps of the picture show this arithmetic in captions such as “50 ms have passed, which adds 0.25 tokens”.
The same captions show why the number of tokens has to be a fraction. A request that arrives 50 ms after its predecessor has earned a quarter of a token, and if that quarter were rounded down to nothing on every request, a client sending every 50 ms would never accumulate a token at all.
Storing the bucket in this way has a welcome consequence for memory. A bucket that has not been touched for capacity ÷ rate seconds, which is 1 second in our example, must be full, and a full bucket is indistinguishable from a bucket that has never been used. The limiter may therefore delete the entry of any client that has been silent for that long and create a full bucket when the client next appears, so that memory is occupied only by the clients that are active at the moment.
With 2 numbers of state, a few arithmetic operations per request and a burst that the operator sets directly, the token bucket is the usual choice for the general protection of an API. It is unsuitable only where the traffic reaching the service must be evenly spaced.
Algorithm 5: the leaky bucket
The token bucket passes a burst through to the service, within the bound set by its capacity. The leaky bucket does the opposite: it accepts a burst and releases it slowly.
The name comes from a bucket with a small hole in its base. However quickly water is poured in, it leaves through the hole at a constant rate, and if water continues to be poured in faster than it leaks, the bucket eventually fills and the excess runs over the side.
Applied to requests, the bucket is a queue. An arriving request joins the back of the queue, and requests leave the front of it at a fixed pace, 1 every 200 ms, to be forwarded to the API. The queue has a fixed number of places, 5 in this example, and a request that arrives when all of them are occupied is refused.
The leaky bucket accepts 9 of the 11 requests, more than any algorithm except the fixed window, and yet it places a lighter burden on the API than any of the others. The lower axis of the picture explains the apparent paradox. The requests arrived bunched together within 650 ms, and they departed at intervals of 200 ms over a period of 1.6 seconds, so that the API at no time received 2 requests less than 200 ms apart.
This is what the leaky bucket is for. The other 4 algorithms decide only whether a request is forwarded, and a request that is forwarded goes on its way at the instant of its arrival. The leaky bucket decides in addition when the request is forwarded, and in doing so it alters the shape of the traffic as well as its volume.
The smoothing has a price, and it is the client who pays it. A request that joins the queue behind 4 others waits 800 ms before it so much as reaches the API. For a person waiting on a web page such a delay is worse than a prompt refusal followed by a retry. For a background job that sends email through a provider which accepts exactly 5 messages per second it is ideal, since nothing is refused, nothing has to be retried, and the provider’s limit is observed to the letter.
A queue of waiting requests also holds resources, because each waiting request keeps a connection open and occupies memory in the limiter. For this reason the leaky bucket is found more often inside a system, between a service and something slower that it feeds, than at the public edge of an API.
The leaky bucket as a meter
The literature uses the name “leaky bucket” for a second algorithm as well, and the 2 are frequently confused. In this other version nothing waits. The bucket has a level, which rises by 1 with each accepted request and falls at a constant rate, and a request that would raise the level above the capacity is refused, while every accepted request is forwarded immediately.
This “leaky bucket as a meter” makes exactly the same decisions as a token bucket. The level of the water is the number of tokens missing from a full token bucket, and the one drains at the rate at which the other fills. When a document speaks of a leaky bucket and nothing is being queued, what it describes is a token bucket seen upside down, and everything said in the previous section applies to it, the burst included.
How the queue is stored
The queueing version, too, needs less state than its picture suggests. Because requests depart at a fixed pace, the limiter does not have to consult a list of them in order to reach a decision. It is enough to keep 1 number, the time of the next free turn to depart. An arriving request is assigned that turn, and the number is advanced by 200 ms. If the turn lies 1 second or more in the future, then 5 requests must already be waiting, and the new arrival is refused. The captions of the picture report this number at each step, as in “the next free turn to leave is at 1400 ms”.
Deciding from a single stored time in this manner is an old technique. It was standardised in the 1990s for telephone and data networks under the name generic cell rate algorithm, usually abbreviated GCRA, and it is used in present-day rate limiters for the same reason it was attractive then, which is that it requires 1 number of state per client.
The algorithms compared
The table below collects the results for the 11 requests of this chapter under the limit of 5 per second.
| Algorithm | Forwarded | What the API received |
|---|---|---|
| Fixed window counter | 10 | 10 requests within 600 ms |
| Sliding window log | 5 | 5 requests, then nothing until 1600 ms |
| Sliding window counter | 6 | 5 requests, and a 6th at 1250 ms that an exact count would have refused |
| Token bucket | 8 | 5 at once from the full bucket, then 1 for each 200 ms that passed |
| Leaky bucket | 9 | 1 request every 200 ms for 1.6 seconds |
The simulation in the book’s repository sends 3 further kinds of traffic through the same 5 algorithms. In its results the column “most in any second” gives the largest number of requests that reached the API within any span of 1000 ms, which is the quantity the server actually has to withstand.
| Traffic | Algorithm | Forwarded | Most in any second |
|---|---|---|---|
| 20 requests in the same instant | fixed window | 5 | 5 |
| sliding window log | 5 | 5 | |
| sliding window counter | 5 | 5 | |
| token bucket | 5 | 5 | |
| leaky bucket | 5 | 5, spread over 800 ms | |
| 5 requests shortly before the 1 second mark and 5 shortly after | fixed window | 10 | 10 |
| sliding window log | 5 | 5 | |
| sliding window counter | 5 | 5 | |
| token bucket | 5 | 5 | |
| leaky bucket | 6 | 5 | |
| 10 requests per second for 3 seconds | fixed window | 15 | 5 |
| sliding window log | 15 | 5 | |
| sliding window counter | 13 | 5 | |
| token bucket | 19 | 9 | |
| leaky bucket | 20 | 5 |
The first block of this table carries a warning for anyone who tests a limiter. A single burst does not distinguish the algorithms at all, since every one of them forwards 5 requests out of 20. Their differences emerge only when traffic continues for a while or crosses a boundary, and a limiter that has passed a quick test with one burst may therefore still behave quite differently from what its author expects once it is in production.
The second and third blocks show, as measurements, the 2 effects described earlier. The fixed window alone admits 10 requests in a second when they are arranged around the boundary. The token bucket admits 9 in the first second of sustained traffic, which is its full bucket of 5 and the 4 tokens that arrived meanwhile. In the third block the sliding window counter forwards 13 requests where the exact log forwards 15, its estimate having erred on the strict side on this occasion.
The properties of the 5 algorithms can be summarised as follows.
| Algorithm | State per client | Burst | Accuracy | Typical use |
|---|---|---|---|---|
| Fixed window counter | 1 count | the limit at once, and twice the limit across a boundary | exact within a clock window only | long quotas, such as calls per day |
| Sliding window log | 1 timestamp per request, up to the limit | the limit at once | exact over every window | small, strict limits, such as login attempts |
| Sliding window counter | 2 counts | the limit at once | within a few percent | large limits on many clients |
| Token bucket | 2 numbers | the capacity, chosen separately from the rate | exact for rate and capacity | general protection of an API |
| Leaky bucket | 1 number, and the waiting requests | accepted and then spread out | output is evenly spaced | feeding something that needs a steady pace |
In choosing among them it is usually enough to consider what the limit is for. If whatever stands behind the limiter can work only at a fixed pace, the leaky bucket is the appropriate choice, with the understanding that requests will wait. If the limit is small and has to be exact, as with a handful of attempts per minute on something sensitive, the sliding window log should be used, its memory cost being negligible at that size. If the limit is a long quota that customers read about in a price list, a fixed window that resets at a predictable time is both the simplest and the easiest to explain. For the general protection of an API, which is the commonest case, the choice lies between the token bucket, when clients are bursty and a bounded burst does no harm, and the sliding window counter, when the limit is meant to hold over every window to within a small error.
The design in the remainder of this chapter uses a token bucket with a rate of 5 per second and a capacity of 10 for each client. This is the most widespread arrangement at an API gateway, being cheap, well suited to the way real clients behave, and equipped with a burst that the operator controls directly.
Use cases
The limiter has 2 kinds of user. Clients encounter it on every call they make, usually without noticing, and operators set the limits it enforces.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | Each client has a limit: a number of requests per second, and a burst it may send at once. |
| 1 | A request within the limit passes to the API unchanged. |
| 2 | A request over the limit is answered with 429 Too Many Requests and the time to wait. |
| 3 | An operator can change a client’s limit without a restart. |
| 4 | Different routes of the API can have different limits for the same client. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Latency | The check adds less than 1 ms to a request. |
| 1 | Availability | If the limiter cannot decide, requests pass. A broken limiter must not take the API down. |
| 2 | Consistency | Eventual. A client may briefly exceed its limit by a few requests when limiter nodes disagree. |
| 3 | Scale | 1 million clients and 50000 requests per second. |
Non-functional requirement 2 calls for some justification, since in it we deliberately choose the weaker of the consistency models introduced in chapter 1. The limiter is a protective layer, and the consequences of its being slightly wrong are mild: if a limit of 100 requests per second occasionally admits 103, the API is still protected and no data anywhere is incorrect. To make every count exact, all the limiter nodes would have to agree on a client’s count before any of them answered a request, and agreement of that kind is what chapters 3 and 4 obtain by means of a leader and a majority, at the cost of a disk write and a network round trip. Incurring that cost on every call to the API would make the protection more expensive than much of what it protects. We therefore accept counts that are slightly wrong for short periods, and concentrate on bounding how wrong they can be.
Non-functional requirement 1 is likewise a decision that could have gone the other way, and for certain limits it does. The section on failures takes it up again.
Core entities
The 2 entities differ in character, and the difference shapes the design. A Limit is configuration. It changes when an operator changes it, perhaps a few times a day, it must survive restarts, and it is read enormously more often than it is written, so each limiter node keeps a copy of the limits in memory and refreshes it periodically. A Bucket is running state that changes with every request and can be lost without real harm, since a bucket that has been lost is created again full, and the only effect is that 1 client is granted 1 additional burst.
Capacity estimate
Arithmetic ahead. The buckets of 1 million clients need about 64 MB of memory. They fit on 1 node, and we run 3 for availability.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Memory per bucket | client id 40 bytes + tokens 8 + time 8 + overhead 8 | 64 bytes |
| Memory for all clients | 1000000 × 64 bytes | 64 MB |
| Checks per second | 1 per request | 50000 |
| The same clients with a sliding window log and a limit of 10000 per hour | 1000000 × 10000 × 8 bytes | 80 GB |
The last row of the calculation is included for contrast. It shows that the choice of algorithm can change the memory required by a factor of more than 1000.
The API
A rate limiter offers clients no API of its own. It makes itself known through the responses of the API it protects, by means of 1 status code and a small number of headers.
A request within the limit
GET /v1/orders HTTP/1.1
Host: api.example
Authorization: Bearer client-42
HTTP/1.1 200 OK
RateLimit-Limit: 5
RateLimit-Remaining: 3
RateLimit-Reset: 1
The 3 headers inform the client of its rate, of the number of requests it could still send at this moment, and of the number of seconds after which its allowance will be full again, so that a client which reads them can slow down before it is ever refused. The names of such headers differ from one API to another, and an API should document the ones it sends.
A request over the limit
GET /v1/orders HTTP/1.1
Host: api.example
Authorization: Bearer client-42
HTTP/1.1 429 Too Many Requests
Retry-After: 2
Content-Type: application/problem+json
{
"type": "/problems/rate-limited",
"title": "Too many requests",
"status": 429,
"detail": "5 requests per second are allowed"
}
429 Too Many Requests is the status code that HTTP provides for this situation, and
Retry-After is the standard header for stating how many seconds the client should
wait.
An operator sets a limit
PUT /v1/limits/client-42 HTTP/1.1
Host: limiter.internal
Content-Type: application/json
{ "rate": 5, "capacity": 10 }
HTTP/1.1 200 OK
Content-Type: application/json
{ "client": "client-42", "rate": 5, "capacity": 10 }
PUT replaces the whole limit of the named client, and repeating the request leaves
the limit as it was after the first.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| API gateway | asks the limiter before it forwards a request | none | another gateway takes its traffic |
| Rate limiter | determines the key, applies the token bucket rule and decides | a copy of the limits, in memory | requests pass unchecked until it returns |
| Counter store | holds the buckets, shared by all limiter nodes | the buckets, in memory | limiters fall back to local buckets |
One request, step by step
The steps follow the arrows of figure 7.12.
- A client sends
GET /v1/orderswith its credentials. The gateway verifies the credentials and obtains the client id,client-42, from them. - The gateway asks the rate limiter whether
client-42may make 1 request to this route now. - The limiter finds the limit of
client-42in its own memory. It then asks the counter store to bring the client’s bucket up to date for the time that has passed and to remove 1 token, as a single operation, and the store replies with the decision and the number of tokens remaining. - If a token was removed, the gateway forwards the request to an API server and adds the
RateLimitheaders to the response. If the bucket was empty, the gateway answers429itself, and the API servers never see the request.
The order of the first 2 steps is deliberate. The gateway authenticates the caller before consulting the limiter because the limiter needs to know whose request it is counting. It follows that requests which fail authentication must be limited in some other way, usually by IP address, since otherwise an attacker could submit login attempts without limit and never come within reach of a limit that is keyed by client.
Where the limiter runs
The design places the limiter at the gateway. A limiter can in fact run in any of 3 places, and the 3 protect against different things.
| Place | What it sees | What it is suited to | What it cannot do |
|---|---|---|---|
| In the client | the client’s own outgoing calls | keeping a well-behaved client within a known limit, so that it is never refused | protect the service, since a defective or hostile client does not run it |
| At the gateway | every request, before any service has worked on it | protecting all services with 1 set of rules and refusing cheaply | know what a request will cost once it is inside a service |
| Inside a service | the requests to that service, in full detail | limits that depend on content, such as rows returned or money moved | refuse a request before it has consumed a connection and some processing |
These are complements, and a mature system uses all of them. A carefully written
client library paces its own calls with a leaky bucket so that it seldom sees a 429.
The gateway enforces the limit regardless, because a service cannot make its safety
depend on the good conduct of its clients. And an expensive operation deep inside a
service may carry a tighter limit of its own, based on knowledge that the gateway does
not have.
Whose requests are counted together
The key of a limit determines which requests are added up, and a badly chosen key causes the limiter to penalise the wrong people while leaving the intended target untouched.
The IP address is the only identity available before a caller has authenticated, and it is a poor one in both directions. A university, a company or a mobile network may place thousands of people behind a single address, all of whom then share one allowance and are refused together when any one of them is busy. An attacker in control of 10000 addresses, on the other hand, enjoys 10000 allowances. A limit by IP address is therefore best kept generous and regarded as a coarse defence for unauthenticated traffic.
Once the caller is known, an API key or a user id is a far better key, since it identifies the party actually responsible for the traffic. Combining it with the route gives finer control, at the cost of 1 bucket for every pair of client and route. A limit keyed on nothing at all, which counts every request to a service, protects the service’s total capacity and provides no fairness whatever, because a single client is free to use it up.
In practice several limits are applied to each request, and the request is forwarded only if every one of them permits it. A representative set is shown below.
| Key | Limit | Purpose |
|---|---|---|
| IP address | 100 per second | coarse defence against unauthenticated abuse |
| client id | 5 per second, burst 10 | fairness between clients |
client id and POST /v1/exports | 2 per minute | an expensive operation |
| the whole service | 40000 per second | the real capacity of the service |
Layered limits introduce a subtlety of their own. Suppose a request is admitted by the second limit and then refused by the third. A token has already been removed from the client’s general bucket on behalf of a request that was never served, and a client that keeps running into the limit on exports will find that its general allowance drains away as well. The correct procedure is to examine all the applicable limits first and to remove tokens only when every one of them has room, which with a shared store means that all the buckets concerned are checked and updated within the same single operation.
Counting on many machines
Everything said so far has assumed that 1 limiter sees all the requests of a client.
At 50000 requests per second, and for the sake of availability, there will be several
limiter nodes behind a load balancer, and the requests of client-42 will be
distributed among them. The question of where the buckets are to be kept has 3
answers in common use.
Each node keeps its own buckets
The simplest arrangement is for every limiter node to keep buckets in its own memory and to consult nobody. Nothing could be faster, and there is no dependency that might fail.
The arrangement is also wrong by a constant factor. With 3 nodes and traffic spread evenly among them, each node sees a third of the client’s requests and allows the full limit, so that the client obtains 3 times its allowance. One might give each node a third of the limit, but this is correct only for as long as the traffic is in fact evenly spread, which a load balancer does not undertake to ensure for any individual client: a client that keeps 1 connection open sends everything to 1 node and receives a third of what it is entitled to. The size of the error also changes whenever a node is added or removed.
The load balancer can be instructed to send all the requests of a client to the same node, for instance by hashing the client id as chapter 1 hashes keys. Local buckets are then exact. The weakness shows itself when a node is added or fails, since the clients that are moved to another node begin there with a fresh and full bucket. For a coarse protective limit this is often acceptable, and a number of large systems operate in exactly this way.
A shared counter store
In the second arrangement all the limiter nodes keep the buckets in 1 shared in-memory store, so that a request works on the same bucket whichever node it reaches. This is the arrangement our design adopts.
It brings with it a problem that did not exist on a single machine. Updating a token bucket consists of 3 steps: reading the bucket, computing the new number of tokens, and writing the result back. When 2 limiter nodes carry out these steps for the same client at the same time, the steps of one may be interleaved with the steps of the other.
Each node saw 1 token and forwarded a request, and the bucket lost only 1 token. No error is reported anywhere. The limit has been exceeded, and it will be exceeded more often the more traffic the client sends, so that the fault is at its worst in exactly the circumstances for which the limiter was installed.
The remedy is to make the 3 steps indivisible. The limiter sends the store a single request whose meaning is “bring this bucket up to date, remove 1 token if there is one, and report the outcome”, and the store carries out the whole of it before attending to any other request for the same bucket. In-memory stores provide for this in 2 ways: by executing a small script inside the store as one unit, or by offering a compare-and-set operation that writes a value only if it has not changed since it was read, as described in chapter 1. The fixed window is the easy case here, since its entire update is “add 1 and return the new value”, which every store of this kind offers as a single operation.
The same reasoning extends to time. If every limiter node supplies its own clock reading with its request, then 2 nodes whose clocks differ by 20 ms will cause the recorded time of a bucket to jump backwards and forwards, and the refill calculation will sometimes be presented with a negative interval and sometimes credit the same 20 ms twice. This is avoided by letting the store apply its own clock to every bucket, so that all the updates of a bucket are measured against a single clock.
What the shared store costs is 1 network round trip per request, well under 1 ms within a data centre, together with a new dependency, since the limiter can now do its work properly only while the store is reachable.
Local tokens obtained in batches
The third arrangement retains the shared store and avoids consulting it for every request. A limiter node asks the store for a batch of tokens on behalf of a client, say 10, and serves the next 10 requests of that client from its own memory, returning to the store only when the batch is spent.
The number of calls to the store is thereby divided by the batch size, and the price is a loss of accuracy at the margins. Tokens lying unused in the memory of one node are unavailable to the others, so that a client may be refused at one node while another still holds tokens for it, and a node that crashes takes its unspent tokens with it. The larger the batch, the fewer the calls and the coarser the count.
| Local buckets | Shared store | Local batches | |
|---|---|---|---|
| Calls to the store per request | 0 | 1 | 1 ÷ batch size |
| Accuracy | wrong by up to the number of nodes | exact, provided the update is 1 operation | wrong by up to batch size × number of nodes |
| If the store is down | unaffected | must fall back | continues until the batches are spent |
| Typical use | coarse protection, or requests routed by client | most API gateways | very high request rates |
For this design we choose the shared store. A single in-memory store handles 50000 checks per second comfortably, and non-functional requirement 0 leaves room for the round trip.
What a refused caller should be told
A refusal is a message addressed to a program, and whether the limiter reduces the load on the system or multiplies it depends on what that program does next.
Consider a client that responds to 429 by retrying at once. It is refused again, and
retries again, and the limiter finds itself answering many times more requests from
this client than it did before the limit was reached. Each refusal is cheap, but the
total is not negligible, and none of it is of any use. The Retry-After header exists
to prevent this, by telling the client the earliest moment at which a further attempt
can succeed. With a token bucket that moment is easily calculated, since the limiter
knows how many tokens are lacking and how quickly they arrive.
A second effect arises when many clients are involved. Suppose that 1000 clients are
refused within the same second, perhaps because a brief outage caused all of them to
retry together, and that each is told Retry-After: 2. Exactly 2 seconds later 1000
requests arrive at the same instant. This is known as a thundering herd, and the
remedy lies with the clients, which should wait for the stated time and then for an
additional random interval, called jitter, so that their retries are spread out.
Retries, lengthening pauses and jitter are treated in detail in chapter 13.
The limiter’s own boundary can also be made less abrupt. Some systems maintain 2 thresholds, adding a warning header to responses once the lower one is passed and refusing only at the higher. An operator who is about to introduce a new limit can first run it in a mode that merely records which requests it would have refused, and after a week the record will show whether the limit would have harmed legitimate clients, before any client has actually been turned away.
When the limiter itself fails
The limiter depends on the counter store, and the store may become unreachable. Each request then confronts the limiter with a decision for which no good answer exists: it cannot count, and it must nevertheless either forward the request or refuse it. To forward it is to fail open, which keeps the API available and leaves it unprotected for the duration. To refuse it is to fail closed, which keeps the API protected and makes it unavailable to everyone.
For the general protection of an API we fail open. The limiter exists in order to keep the API available, and if a fault in the limiter’s own store were to make the API unavailable, the limiter would have brought about the very outage it was installed to prevent. It should also be remembered that at most moments no client is misbehaving, so that an hour without limits will usually pass without incident.
Failing open need not mean having no limit at all. When the store cannot be reached, each limiter node can fall back on buckets in its own memory, in the manner of the first arrangement above, with every node enforcing its share of each limit. The count is coarser than usual, and a client that goes badly wrong is still contained.
For some limits the decision must be reversed. A limit on login attempts, on password resets, or on calls each of which costs money is a control on security or expenditure, and to forward requests without limit in those cases is worse than to refuse them. Such limits fail closed, and a sound design names them one by one, since a single switch for the whole limiter cannot serve both kinds.
The store itself can be run as a leader with followers, as in chapter 3, so that the loss of 1 machine does not make it unreachable. After a change of leader the new leader may lack the most recent updates, because an in-memory store commonly replicates without waiting for a majority to confirm. For buckets this is harmless, amounting to a few extra tokens for a few clients, and it is the eventual consistency of non-functional requirement 2 as it appears in practice.
The design, deployed
At 50000 requests per second, no component of the design is a single machine. Clients reach 1 of 3 zones, each with a pair of load balancers in front of 10 API gateways. Every gateway asks a limiter node in its own zone, and the 9 limiter nodes are interchangeable, since none of them holds a bucket. All of them read and update the buckets in a single counter store, which runs as a leader with 2 followers in the other zones. Only requests that pass reach the 100 API servers, which are the reason for the whole arrangement and by far the largest tier.
How real systems do it
The algorithms of this chapter are all in production somewhere, and the published accounts agree closely with what was said of each.
Stripe has described 4 limiters that it runs in front of its API. The most important is a request rate limiter for each user, built as a token bucket whose state is held in an in-memory store, as in this chapter. Beside it is a limiter on the number of requests a user may have in progress at once, which catches expensive requests that a rate alone would miss. The other 2 are load shedders, which act on the state of the whole system and reserve capacity for critical requests, and which are the load shedding that the opening section distinguished from rate limiting.
Cloudflare chose the sliding window counter for the limiter at its edge and published a measurement of its accuracy. Over 400 million requests from 270000 sources, the estimate from 2 counters wrongly allowed or refused 0.003% of requests, with an average difference of 6% between the estimated and the true rate. This supports the statement above that the error is small for real traffic, and it should be read with the caution that it describes 1 network’s traffic.
The generic cell rate algorithm comes from the standards for ATM networks of the
1990s, and it is the basis of several widely used limiter libraries. Envoy, a proxy
commonly used as a gateway, offers both arrangements described under counting on many
machines: a token bucket local to each proxy, and a call to a shared rate limit
service, and the 2 can be combined, with the local one as a first line of
defence in front of the shared one. The response headers shown in the API section
follow a draft of the IETF, which has changed between versions and is not yet a
standard, whereas the status 429 and the header Retry-After are standardised.
One technique in common use is missing from this chapter. A well-behaved client can limit itself adaptively, lowering its own rate when it sees refusals and raising it slowly when it does not, which the AWS client libraries do with a token bucket for retries. Chapter 13 describes the same idea from the side of the caller.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Using a fixed window to protect a server from overload | A client sends a full allowance on each side of a boundary, and the server receives twice the limit within moments. | Use a token bucket or a sliding window, and keep fixed windows for long quotas. |
| Setting a token bucket’s capacity equal to its rate and reading the result as a strict limit per second | After a quiet period the contents of the bucket and a second’s refill pass together, up to twice the rate in 1 second. | Set the capacity to the burst that the server can really absorb. |
| Reading and writing the bucket in separate steps | 2 limiter nodes read the same value and both forward a request. | Refill and remove the token in a single operation inside the store. |
| Refilling every bucket from a timer | Millions of updates per second are spent on clients that send nothing. | Refill when a request arrives, from the time that has passed. |
| Limiting by IP address alone | Thousands of users behind 1 address share an allowance, while an attacker with many addresses has many allowances. | Use the client id once the caller is authenticated, and the IP address only before that. |
| Removing a token for one limit before checking the others | A request refused by a later limit has already consumed allowance. | Check all the limits, then remove tokens from all of them, in 1 operation. |
| Letting each limiter node supply its own clock reading | Clocks that differ make the recorded time of a bucket run backwards. | Let the store’s clock time every update. |
| Refusing everything when the store is down | A fault in the protective layer takes the API down. | Fail open with local buckets, except for the limits identified as security controls. |
Answering 429 without Retry-After | Clients retry at once and add to the load. | State the time to wait, and ask the authors of clients to add jitter. |
| Appending a request to the sliding window log before checking the count | A client that keeps sending is never served again. | Log only forwarded requests, unless a lasting block is intended. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| The counter store is unreachable | nothing, since requests pass | limiter nodes use local buckets with a share of each limit until the store returns |
| The leader of the counter store is replaced | a few clients are allowed a few extra requests | the new leader continues with buckets that are at most a moment out of date |
| A limiter node stops | nothing | the gateway uses the other nodes, and the buckets are in the store |
A client ignores 429 and keeps sending | that client continues to receive 429 | each refusal costs 1 store operation and no work in the API |
| An operator sets a limit far too low | many clients receive 429 | the record of the trial mode reveals the problem beforehand, and PUT restores the previous value |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Token bucket | a rate and a burst that are set independently, in 2 numbers of state | up to capacity + rate requests in the first second after a silence |
| Shared counter store | every node counts on the same bucket | 1 round trip per request, and a dependency |
| Eventual consistency | no agreement protocol on the path of every request | a limit can be exceeded by a few requests |
| Fail open | the API stays available when the store is down | weaker protection during that time |
| Limiter at the gateway | 1 place for all rules, and cheap refusals | no knowledge of what a request costs inside a service |
The simulation
Simulation in the repository. The program code/ratelimit simulates this design. To run it, clone the repository and type cargo run --release -p ratelimit in the code directory.
References
- P. Tarjan, “Scaling your API with rate limiters”, Stripe engineering blog, 2017.
- J. Desgats, “How we built rate limiting capable of scaling to millions of domains”, Cloudflare blog, 2017.
- ATM Forum, Traffic Management Specification Version 4.0, 1996, for the generic cell rate algorithm.
- RFC 6585, Additional HTTP Status Codes, 2012, for status 429.
- RFC 9110, HTTP Semantics, 2022, for
Retry-After. - IETF HTTP API working group, “RateLimit header fields for HTTP”, Internet-Draft.
- Envoy proxy documentation, “Global rate limiting” and “Local rate limiting”.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Rate limiter | a component that refuses requests from a caller that has sent too many |
| Rate | the number of requests per unit of time that a caller may send indefinitely |
| Burst | a group of requests sent at the same moment, or the largest such group that is allowed |
| Key | the identity under which requests are counted together, such as a client id |
| Window | a span of time over which requests are counted |
| Fixed window counter | a count for each clock window, beginning again at 0 in every window |
| Sliding window log | a list of the arrival times of recent requests, counted exactly |
| Sliding window counter | an estimate of the sliding count, made from the counts of 2 fixed windows |
| Token bucket | a rule with a refill rate and a capacity, in which each request removes 1 token |
| Leaky bucket | a queue that releases requests at a fixed pace |
| GCRA | the generic cell rate algorithm, which decides from 1 stored time |
| Load shedding | dropping requests, whoever sent them, when a service is close to overload |
429 Too Many Requests | the HTTP status for a request refused by a limit |
Retry-After | the HTTP header that states how long to wait before trying again |
| Thundering herd | many clients retrying at the same moment |
| Jitter | a random additional wait that spreads retries out |
| Fail open | to let requests pass when the check itself cannot be made |
| Fail closed | to refuse requests when the check itself cannot be made |
Summary
- Every limit contains a rate and a permitted burst, and the phrase “5 per second” specifies only the first.
- The fixed window counter is the cheapest algorithm and admits twice the limit across a window boundary, which confines it to long quotas.
- The sliding window log is exact and keeps a timestamp for every request, which suits it to small, strict limits.
- The sliding window counter estimates the log from 2 counts, with a small error in either direction.
- The token bucket sets rate and burst independently in 2 numbers of state, and forwards up to capacity + rate × time.
- The leaky bucket queues requests and releases them at a fixed pace, shaping the traffic at the cost of waiting.
- On many machines the buckets are kept in a shared store, and each update is a single indivisible operation timed by the store’s clock.
- A refusal carries
Retry-After, and well-written clients add jitter before retrying. - General limits fail open with local buckets, and limits that act as security controls fail closed.
Unique ID Generator: System Design
Almost every record that a system stores needs a name by which the rest of the system can refer to it. An order is mentioned in an invoice, a message in a reply, a user in a thousand other rows, and each of these references works only if the name it uses belongs to exactly 1 record and will never belong to another. Such a name is an identifier, or id, and for as long as a system consists of a single database the question of where ids come from hardly arises, because the database keeps a counter and hands out the next number with every insert.
The question becomes interesting when records are created on many machines at once. The counter that served 1 database so well now has to be shared, and a shared counter is a place where every creation of every record in the system must queue, and a machine whose failure stops all of them. This chapter is about producing identifiers without that shared point. We examine 4 ways of doing it, of which the last, an id composed of a timestamp, a node number and a counter, is the one we then design in full, and we give particular attention to the ways in which each scheme can quietly produce the same id twice.
This chapter covers
- what an identifier is required to do, beyond being unique
- a counter in 1 database, and what it costs at scale
- random identifiers, the probability that 2 of them collide, and their effect on an index
- blocks of numbers handed out by a coordinator
- identifiers composed of time, node number and counter
- clocks that run backwards, restarts, and node numbers given out twice
- what an id reveals to the people who see it
- why the order of ids from different nodes is only approximate
What an identifier has to do
Uniqueness is the one property that cannot be compromised, and it is worth being precise about its scope. An id has to differ from every other id of the same kind that has ever been issued or ever will be, on any machine, including machines that have since been replaced and machines that were restarted a moment ago and have forgotten what they did before. Most of the difficulties in this chapter come from that last clause, since it is easy to design a generator that never repeats itself while it is running and considerably harder to design one that does not repeat itself after it has lost its memory.
Several further properties are desirable, and the 4 schemes differ mainly in which of them they provide. An id should be small, because it is stored not only in its own record but in every record that refers to it and in every index over those references, so that the difference between 8 bytes and 16 is multiplied many times over. It should be obtainable without a network call, since a call adds latency to every insert and a dependency that may be unavailable. And it is very useful if ids increase with time, so that an id issued later is larger than one issued earlier.
This last property deserves an explanation, because its value is not obvious until one considers how a database stores an index. An index keeps its entries in sorted order, divided among pages of fixed size, and an insert has to write the page into which the new entry falls. If new ids are always larger than existing ones, every insert falls on the last page, which is therefore the only page that needs to be in memory, and the index grows by appending. If new ids are scattered at random across the whole range, each insert falls on a different page, nearly every page of the index is being modified at any moment, and once the index has outgrown memory each insert costs a read from disk.
Time-ordered ids bring smaller conveniences as well. Sorting by id is sorting by age, so that “the newest 20 orders” can be read from the end of the primary index without a separate index on the creation time, and an engineer looking at 2 ids in a log can tell at a glance which came first.
A counter in 1 database
The traditional source of ids is a counter kept by the database, usually called an auto-increment column or a sequence. Its ids are as small as ids can be, they are strictly increasing, and uniqueness is guaranteed by the simplest possible argument, namely that 1 component gives out each number once.
The cost lies entirely in the phrase “1 component”. Every insert anywhere in the system must reach that database, which bounds the rate at which the whole system can create records by what 1 machine can do, and adds a network round trip for every service that is not in the same data centre. If the database is unavailable, nothing can be created at all. A system that has divided its data among many shards, as the key-value store of chapter 1 does, would be reintroducing through its ids the single point of failure that sharding was meant to remove.
A well-known refinement keeps the counter and removes some of the dependence on 1 machine. 2 databases each maintain a counter, the first giving out odd numbers and the second even numbers, so that either can serve requests when the other is down and their ids never coincide. The idea extends to n databases, each stepping by n from a different starting point. Its weakness is rigidity. The number of databases is built into every id that has been issued, and to add a third database to a pair one must change the step on machines that are in service, without ever letting 2 of them issue the same number during the change. The ids are also no longer ordered in time, since one database may be far ahead of the other.
Random identifiers
At the opposite extreme is the idea of using no coordination whatsoever. Each machine picks a number at random from a range so large that 2 machines are extremely unlikely ever to pick the same one. The best-known form is the universally unique identifier, or UUID, a 128-bit value, and in its version 4 all but 6 of those bits are random, which leaves 122 random bits.
Whether “extremely unlikely” is unlikely enough can be calculated. Among n ids drawn at random from 2 to the power 122 possible values, the probability that at least 2 are equal is approximately n squared divided by 2 to the power 123. A system that generates a million ids every second for 30 years produces about 10 to the power 15 ids, and the probability of a single collision among all of them comes to roughly 1 in 10 million. For nearly every purpose this is as good as certainty, provided the random numbers are truly random. That proviso is the practical risk of the scheme: a generator seeded from the clock, or 2 virtual machines cloned from the same image with the same internal state, will produce identical “random” ids with a probability that has nothing to do with the calculation above.
Random ids have 2 real costs. They occupy 16 bytes where a counter needs 8, and that is paid again in every reference and every index. And they are scattered uniformly over their range, which is the worst case for index locality shown in the figure above. Many teams have adopted random UUIDs as primary keys while their tables were small and have discovered the second cost only when the index no longer fitted in memory and insert throughput fell sharply.
The response to this has been to give up some random bits in exchange for order. Version 7 of the UUID standard places a 48-bit timestamp in milliseconds at the front and fills the rest with random bits, so that ids generated later sort after ids generated earlier, to the precision of a millisecond, while 2 ids from the same millisecond are distinguished by 74 random bits. This keeps the great advantage of random ids, which is that a machine needs nothing from anybody in order to make one, and removes the scattered inserts. What it cannot remove is the size, and if ids must fit in 64 bits, as they must wherever they are to be stored as an ordinary integer column, there is not enough room for both a useful timestamp and enough random bits to make collisions negligible.
Blocks of numbers
A third scheme returns to the counter and reduces the cost of sharing it. A coordinator holds a single durable counter. A node that needs ids asks the coordinator for a block of them, 1000 at once for example, upon which the coordinator advances its counter by 1000 and the node gives out the numbers of its block one by one from memory. The node contacts the coordinator again only when the block is used up.
Uniqueness follows from the fact that no number belongs to 2 blocks, and the coordinator is consulted once per 1000 ids, so that its load and its influence on latency are a thousandth of what they were with a plain shared counter. If the coordinator fails, every node can continue until its current block is exhausted, which with a suitable block size gives operators ample time to restore it.
2 properties are lost. The ids contain gaps, because a node that crashes or is restarted discards the unused remainder of its block, and anyone who assumed that ids are consecutive, for instance in order to count records by subtracting one id from another, will be misled. And the ids are no longer ordered in time across nodes, since a slow node may still be giving out numbers from an old block long after a busy node has moved on to much larger ones. Where neither property is needed, this is a simple and dependable scheme, and it yields the smallest ids of any distributed method.
Time, node number and counter
The fourth scheme obtains uniqueness without any communication while a node is running, keeps the id within 64 bits, and preserves the order of time. It does so by composing each id from 3 numbers that the node already has at hand: the current time in milliseconds, the node’s own number, and a counter of the ids the node has made in the current millisecond. The arrangement became widely known through a generator that a large social network published in 2010 under the name Snowflake, and ids of this form are often called by that name.
The argument for uniqueness considers the ways in which 2 ids might be related, and shows that in each case they differ in at least 1 field. If they were made by different nodes, they differ in the node number, provided every node has a number of its own. If they were made by the same node in different milliseconds, they differ in the time, provided the node’s clock does not return to a millisecond it has already used. And if they were made by the same node in the same millisecond, they differ in the counter, provided the node does not issue more ids in a millisecond than the counter can count. The 3 provisos are the whole of the engineering problem. Each corresponds to a way in which the scheme fails if it is neglected, and the 3 sections after the next deal with them in turn.
The order of the fields within the id is what makes ids sortable by time. Because the time occupies the most significant bits, comparing 2 ids as integers compares their times first, and only when the times are equal does the comparison fall through to the node number and the counter.
The sizes of the fields are a budget of 63 bits to be divided, and the division shown is the common one. 41 bits of milliseconds last for 69 years, counted from a starting date that the service chooses for itself, and choosing a recent date in place of the conventional 1970 gives the scheme its full 69 years from the day it goes into service. 10 bits allow 1024 nodes. 12 bits allow each node 4096 ids per millisecond, or about 4 million per second. A system with few nodes and a very high rate might prefer 8 bits of node and 14 of counter, and a system spread over many small machines the reverse, and the only firm rule is that the division must be fixed before the first id is issued, since ids made under 2 different divisions cannot safely coexist.
When a millisecond is full
A node that has issued 4096 ids within 1 millisecond has no counter values left for that millisecond. The correct response is to wait until the clock has advanced and to continue with a counter of 0, which delays the caller by less than a millisecond. In the simulation for this chapter, 3 nodes are each asked for 5000 ids in every one of 100 milliseconds, and each makes exactly 4096 per millisecond, so that 1228800 ids are made in all, every one of them different, and 271200 requests have to wait.
The response to avoid is letting the counter overflow. An overflowing counter carries into the adjacent field, which in this layout is the node number, and the node then issues ids that appear to come from its neighbour and may coincide with ids that the neighbour has really made.
The clock
The second proviso, that a node’s clock never returns to a millisecond it has already used, is not one that a computer’s clock satisfies without help. The clock of a machine drifts, by an amount that is typically some tens of milliseconds a day, and a time service corrects it periodically by comparison with better clocks. When the machine’s clock is found to be ahead, the correction may set it back, and for a brief period afterwards the clock shows times that it has shown before.
The defence shown in these steps is for the node to remember the time of the last id it issued and to refuse to issue an id for any earlier time. A step backwards of a few milliseconds is then waited out. A step of seconds or minutes indicates that something is wrong with the machine or its time service, and it is better for the node to report itself unhealthy, so that callers go elsewhere and an operator is alerted, than to stall silently for as long as the step lasts.
This defence depends on the node’s memory of its last id, and that memory is lost when the node restarts. Consider a node that issues an id at a time its clock calls 5000, crashes, and is running again 20 ms later with a clock that has meanwhile been corrected backwards by 50 ms. The restarted node remembers nothing, reads 4970 from its clock, and issues ids for milliseconds that its former self has already used. 2 precautions are taken against this. The node can record on disk, every few seconds, a time somewhat in the future before which it promises not to issue ids after a restart, and on starting it waits until its clock has passed the recorded time. Alternatively, or in addition, a starting node can wait for longer than the largest clock correction that is considered possible before it issues its first id. Both cost a short delay at startup and nothing afterwards.
It is tempting to avoid the problem by using the machine’s monotonic clock, which the operating system guarantees never to run backwards. That clock, however, measures time from an arbitrary starting point, commonly the moment the machine was switched on, and its readings are therefore meaningless on any other machine and after any reboot. It can serve to detect that the wall clock has jumped, by comparing the progress of the 2, and it cannot replace the wall clock as the source of the timestamp.
The node number
The first proviso requires that no 2 nodes that are making ids ever hold the same number. Of the 3 provisos it is the one whose violation is most damaging, because 2 nodes with the same number and reasonably accurate clocks produce identical ids continuously, and nothing in an id or in either node can reveal that this is happening. The duplicates surface later and elsewhere, as rejected inserts if the ids are primary keys with a uniqueness constraint, or as 2 different records answering to 1 name if they are not.
Node numbers therefore need an authority. The usual arrangement is the one of our design, in which a starting node obtains its number from a coordinator of the kind described in chapter 1, which records the numbers in use and gives each to 1 node only. The alternatives are to write the numbers into the configuration of each machine, which is workable for a handful of machines administered by hand and dangerous wherever machines are created from a common image, or to derive the number from something assumed to be unique to the machine, such as part of its network address, which holds only as long as the assumption does.
Reusing a number requires care for the same reason that restarting a node does. When a node has been retired and its number is given to a new node, the new node must not issue ids for any millisecond that the old one used, and since the 2 clocks may disagree, the coordinator should withhold a released number for longer than the largest clock difference that is thought possible. With 1024 numbers available and far fewer nodes in service, there is rarely any pressure to reuse a number quickly.
What an id reveals
An id that is handed to the outside world, in a URL or an API response, tells its recipient whatever its construction encodes, and it is as well to decide in advance whether that is acceptable.
An id of the time, node and counter form discloses exactly when the record was created, to the millisecond, to anyone who knows the layout, and the layout of such ids is common knowledge. For a public post this hardly matters, while for a medical record or an account it may. Sequential ids from a counter disclose something of commercial interest: a competitor who places an order today and another a week later, and subtracts the 2 order numbers, learns how many orders the business took in that week.
Ordered ids are also guessable. Given one valid id, neighbouring values are likely to be valid as well, and a service that returns a record to anyone presenting its id, without checking that the caller is entitled to see it, can be read from end to end by counting. The proper remedy is the authorisation check, and an id should never be relied upon as a secret. Where an unguessable reference is truly required, as in a link for resetting a password, it should be a separate random token, kept apart from the id of the record.
The order of ids is approximate
Since the time is the leading field, it is natural to read the order of 2 ids as the order in which 2 events occurred, and within a single node that reading is correct. Between nodes it holds only to the accuracy of their clocks. If the clock of node 1 is 15 ms ahead of the clock of node 2, an id that node 2 makes now is smaller than an id that node 1 made 10 ms ago, although it was made later.
Ids of this kind are accordingly described as roughly sorted: any 2 ids made further apart in time than the largest difference between clocks are in the right order, and ids made closer together than that may be in either order. This is entirely adequate for the purposes mentioned at the start of the chapter, such as index locality and listing recent records. It is inadequate as evidence that one event caused or preceded another. A system that needs to know the true order of 2 writes, for example in order to decide which of them should prevail, must obtain that order from a mechanism that establishes it, such as the sequence numbers that a leader assigns in chapter 3, and must not infer it from identifiers.
The 4 schemes compared
| Counter in 1 database | Random (UUID version 4) | Blocks of numbers | Time, node, counter | |
|---|---|---|---|---|
| Size | 64 bits | 128 bits | 64 bits | 64 bits |
| Network call | 1 per id | none | 1 per block | none, after startup |
| Ordered by time | exactly | no | no | roughly |
| What uniqueness rests on | 1 machine | the quality of random numbers | the coordinator’s counter | distinct node numbers and a clock that does not repeat |
| Fails silently when | it does not | random state is cloned | it does not | 2 nodes share a number, or a clock repeats |
| When the shared component is down | no ids | not applicable | ids until blocks run out | running nodes are unaffected |
The row on silent failure explains why the last scheme, for all its advantages, asks for more care than the others. The counter and the block schemes derive uniqueness from a single durable number and cannot produce a duplicate without losing that number. The schemes that need no coordination purchase their independence with assumptions about the environment, and when an assumption fails, no component is in a position to notice.
Our design uses time, node number and counter, because the requirements below ask for 64-bit ids, rough ordering by time, and continued operation when every other component is down, and no other scheme provides all 3.
Use cases
Services ask for ids, at a very high rate and with no interest in how they are made. Operators have a single duty, which is to see that every node receives a number of its own.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | Each caller receives an id that no other caller has received or will receive. |
| 1 | Ids fit in 64 bits, so that they can be database keys. |
| 2 | An id made later is larger than an id made earlier, to within a few milliseconds. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Availability | Any node can make ids while every other node is down. |
| 1 | Latency | Making an id takes microseconds and involves no network call. |
| 2 | Consistency | Uniqueness is absolute. Order between nodes is approximate, limited by how far their clocks differ. |
| 3 | Scale | Up to 1024 nodes, each making 4 million ids per second. |
The consistency models of chapter 1 do not apply to this design in the ordinary sense, because the nodes share no data that could be consistent or inconsistent. The uniqueness of ids follows from their layout. The only fact that nodes must agree on is which number belongs to which node, and that fact is established once, at startup, by a coordinator that is strongly consistent.
Core entities
Capacity estimate
Arithmetic ahead. 1 node can make 4096 ids per millisecond, and the 41 bits of time last 69 years.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Ids per node per millisecond | 2 to the power 12 | 4096 |
| Ids per node per second | 4096 × 1000 | 4096000 |
| Nodes | 2 to the power 10 | 1024 |
| Years of time | 2 to the power 41 ms ÷ (1000 × 60 × 60 × 24 × 365) | 69.7 |
The API
The service has a single operation. Callers that need ids in very large numbers often link the generator into their own process as a library, each such process being a node with its own number, and thereby avoid the network altogether.
Get an id
POST /v1/ids HTTP/1.1
Host: ids.example
HTTP/1.1 201 Created
Content-Type: application/json
{ "id": "8388636672" }
We send the id as a string, because JSON numbers lose precision above 2 to the power 53.
The id is returned as a string for a reason that has caught many APIs unprepared. JSON itself places no limit on the size of a number, but the most widely used JSON parsers, those of web browsers, read every number as a 64-bit floating-point value, which represents integers exactly only up to 2 to the power 53. A larger id is silently rounded to a nearby value, so that 2 different ids can arrive in the browser as the same number.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Id node | makes ids from its clock, its number and a counter | its number and the time of its last id | the others carry on; its number is not reused while it may return |
| Coordinator | gives each starting node a number that no other node holds | the numbers in use | running nodes are unaffected; new nodes cannot start |
One request, step by step
The steps follow the arrows of figure 8.8.
- When an id node starts, it asks the coordinator for a node number. The coordinator gives out each number once. This is the only time a node needs anyone else.
- A service sends
POST /v1/idsto any id node. The node reads its clock, takes the next counter value for that millisecond, and joins time, node number and counter into 1 number.
It is the absence of arrows between the id nodes that characterises this design. Once a node holds its number, it needs nothing from the coordinator or from any other node, and the availability of the service as a whole is that of whichever single node a caller manages to reach.
The design, deployed
The id nodes are spread over 3 zones and placed behind a load balancer only as a convenience for callers, since any node can answer any request. Each zone is given a range of node numbers, so that the number in an id also says where the id was made, which helps when a duplicate has to be traced. The coordinator is consulted when a node starts and at no other time. Callers with very high rates do not use the network at all: they link the generator into their own processes, each of which then holds a node number of its own.
How real systems do it
The scheme of time, node number and counter is the one that Twitter announced in June 2010 under the name Snowflake, with exactly the division used here: 1 unused bit, 41 bits of milliseconds, 10 bits for the machine and 12 for a sequence. Twitter adopted it when it moved its tweets from a single database to a sharded store and could no longer rely on the database’s counter. The layout has been widely copied with different divisions. Instagram described a variant in 2012 with 41 bits of time, 13 bits for the logical shard and 10 for a sequence, generated inside the database itself, so that no separate service is needed. Discord uses the original layout with its own starting date.
The counter in a pair of databases, stepping by 2, is the scheme that Flickr published as its ticket servers. Blocks of numbers handed out by a coordinator are how many database sequences are cached in practice.
Version 7 of the UUID, with a millisecond timestamp in its first 48 bits, was standardised in RFC 9562 in 2024, and it exists because of the problem of index locality described at the start of this chapter.
There is an important case in which the advice of this chapter is reversed. In a store that partitions its data by ranges of keys, as Spanner, CockroachDB and Bigtable do, keys that increase with time send every insert to the last range, and that 1 range and the machines holding it become a bottleneck however large the cluster. The documentation of such stores warns against time-ordered primary keys for exactly this reason, and recommends keys whose leading bits are spread out, by a hash or by reversing the bits of a sequence. The locality that helps an index on 1 machine becomes a hot spot on many, and which of the 2 effects governs depends on how the store beneath partitions its keys.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Giving 2 nodes the same number | Both issue identical ids for as long as they run, and nothing reports it. | Let 1 coordinator assign the numbers, and hold a released number back before reusing it. |
| Trusting the clock to move forwards | After a correction backwards, a node repeats ids it has already issued. | Remember the time of the last id and refuse to issue one for an earlier time. |
| Forgetting the last time on restart | A restarted node with a corrected clock reuses milliseconds from before the crash. | Record a time on disk before which no id will be issued, or wait at startup for longer than any possible correction. |
| Letting the counter overflow | The overflow carries into the node number, and the ids collide with another node’s. | Wait for the next millisecond when the counter is used up. |
| Sending ids as JSON numbers | Browsers round integers above 2 to the power 53, and 2 ids become equal. | Send ids as strings. |
| Using random ids as the key of a large table | Inserts are scattered over the whole index, which must then stay in memory. | Use ids that begin with a timestamp. |
| Treating an id as a secret | Neighbouring ids are valid and can be guessed. | Check on every request that the caller may see the record. |
| Reading the order of ids as the order of events | Ids from 2 nodes can be out of order by the difference between their clocks. | Take the order of events from a log’s sequence numbers. |
| Changing the sizes of the fields after launch | Ids made under the old and the new layout can coincide. | Fix the layout before the first id is issued. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A node’s clock steps back | that node answers 503 for the length of the step | the caller asks another node |
| 2 nodes are given the same number | duplicate ids, silently | prevented by the coordinator; nothing in the id can detect it afterwards |
| The coordinator is down | nothing | running nodes keep their numbers |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Ids from local state only | no network call, no single point of failure | order between nodes is only as good as the clocks |
| 41 bits of time | ids sort by creation time | the scheme ends after 69 years |
| 12 bits of counter | 4096 ids per millisecond per node | a busier caller has to wait for the next millisecond |
The simulation
Simulation in the repository. The program code/idgen simulates this design. To run it, clone the repository and type cargo run --release -p idgen in the code directory.
References
- R. King, “Announcing Snowflake”, Twitter engineering blog, 2010.
- Instagram engineering blog, “Sharding & IDs at Instagram”, 2012.
- K. Elliott-McCrea, “Ticket Servers: Distributed Unique Primary Keys on the Cheap”, Flickr code blog, 2010.
- RFC 9562, Universally Unique IDentifiers (UUIDs), 2024.
- Google Cloud Spanner documentation, “Schema design best practices”, on avoiding monotonically increasing keys.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Identifier, id | a value that names 1 record and no other |
| Sequence | a counter kept by a database that gives out the next number on request |
| UUID | a 128-bit identifier, of which version 4 is random and version 7 begins with a timestamp |
| Collision | 2 records receiving the same id |
| Block | a range of consecutive numbers given to 1 node to hand out |
| Node number | a small number that belongs to exactly 1 generator node |
| Counter | the count of ids that a node has made in the current millisecond |
| Clock correction | an adjustment of a machine’s clock by a time service, possibly backwards |
| Monotonic clock | a clock that never runs backwards and has no meaning outside its own machine |
| Roughly sorted | in the right order except among ids made within a short time of each other |
Summary
- An id must be unique across every machine and every restart, and it is the restarts that make this hard.
- A counter in 1 database is exact and small, and makes every insert depend on 1 machine.
- Random 128-bit ids need no coordination and collide with negligible probability, at the cost of size and of scattered inserts unless they begin with a timestamp.
- Blocks of numbers reduce a shared counter to 1 call per block, and give up consecutive numbers and order in time.
- An id composed of time, node number and counter fits in 64 bits, needs no communication, and sorts roughly by time.
- That scheme is correct only if node numbers are distinct, the clock never repeats a millisecond, and the counter does not overflow, and it fails silently when one of these does not hold.
- The order of ids from different nodes is accurate only to the difference between their clocks.
Distributed Cache: System Design
A database answers a question by doing work: it locates pages on disk, follows an index, perhaps joins several tables and sorts the result. When the same question is asked again a moment later, it does all of that work again, although nothing has changed and the answer will be the same. A cache is a component that remembers recent answers, keeps them in memory, and returns them without consulting the database, so that a question which took 20 ms the first time takes less than 1 ms on every subsequent occasion and places no load on the database at all.
That description contains the whole appeal of caching and conceals nearly all of its difficulty. The remembered answer is a copy, and from the moment it is made the original is free to change without the copy’s knowledge. A cache also has less room than the data it copies from, so that it must continually decide what to forget. And because a system that has a cache soon comes to rely on it, the moments at which the cache cannot help, when an entry has just expired or a cache node has just been lost, are precisely the moments at which the database receives a load it has not carried for months. This chapter treats each of these in turn: what to keep, how a copy and its original drift apart and how far that can be controlled, and how a cache fails and takes other things with it.
This chapter covers
- what a cache is worth, as a function of how often it can answer
- 4 ways of arranging a cache and a database, and what each guarantees
- which entries to remove when memory is full: least recently used, least frequently used, and their relatives
- how a cached value becomes stale, including a race that defeats the usual precautions
- time limits, and why they are the last line of defence
- the stampede, penetration by keys that do not exist, and the avalanche
- spreading a cache over many nodes with the hash ring of chapter 1
- what happens to the database when the cache is lost
What a cache is worth
The fraction of reads that a cache is able to answer is called its hit ratio, a read answered from the cache being a hit and a read that has to go on to the database a miss. Almost everything about the value of a cache follows from this single number, and the relationship is less linear than intuition suggests.
Suppose that a read from the cache takes 1 ms and a read from the database 20 ms. The average read then costs the hit ratio times 1 ms plus the miss ratio times 20 ms.
A cache that answers half of all reads has barely halved the average, since the slow half still dominates. At 90% the average has fallen to 2.9 ms, and at 99% to 1.19 ms. The more important effect, however, is on the database. Between a hit ratio of 90% and one of 99%, the reads reaching the database fall from 10% of the total to 1%, which is a tenfold reduction, and it is usually this reduction, more than the gain in latency, that a cache is installed to obtain. A database that could not possibly serve the whole read load directly is able to serve the 1% that the cache passes on.
The same arithmetic contains a warning that the rest of the chapter develops. A system whose database is sized for 1% of the read load is a system that cannot survive without its cache. If the hit ratio falls from 99% to 90%, the load on the database multiplies by 10, and if the cache is emptied altogether it multiplies by 100.
Whether a high hit ratio is attainable depends on the traffic. Caching works because requests are unevenly distributed, a small number of keys being asked for again and again while the great majority are asked for rarely. The simulation for this chapter sends a million requests for 100000 keys, with popularity concentrated on a few, to a cache of various sizes.
| Size of the cache | Requests answered from the cache |
|---|---|
| 1% of the keys | 5.7% |
| 5% of the keys | 24.7% |
| 10% of the keys | 42.2% |
| 25% of the keys | 72.7% |
A cache holding a quarter of the keys answers nearly 3 quarters of the requests. With traffic more sharply concentrated than this, which is common, the same hit ratio would be reached with a much smaller cache, and with requests spread evenly over the keys a cache of 25% would answer only 25%. The distribution of real traffic should therefore be measured before a cache is sized, and no figure quoted from elsewhere, including the figures above, is a substitute.
Arranging a cache and a database
There are 4 established ways of placing a cache beside a database, which differ in who is responsible for filling the cache and in what order the 2 are written.
In the first and commonest arrangement, called cache-aside, the application manages everything. To read, it asks the cache, and on a miss it reads the database and stores the result in the cache for the benefit of later readers. To write, it updates the database and then removes the corresponding entry from the cache, so that the next reader will miss and fetch the new value. The cache is a passive store that knows nothing of the database. The great merit of this arrangement is that the cache is optional: if it is unreachable, the application reads the database directly and continues to work, only more slowly.
Read-through moves the same logic into the cache. The application asks the cache for a key, and the cache, on a miss, fetches the value from the database itself. The behaviour is that of cache-aside, with the advantage that the fetching code exists in 1 place, where every application shares it, and the disadvantage that the cache must be taught how to query the database.
In write-through, the application writes to the cache, and the cache writes to the database before confirming. Every write thus refreshes the cache, which consequently always holds the latest value of whatever has been written through it. The cost is that every write pays for 2 stores, and that entries are cached on writing whether or not anybody will read them.
Write-behind is write-through with the database write postponed. The cache confirms the write at once and passes it to the database later, usually in batches. Writes become very fast, and a burst of them is smoothed into a steady stream. The price is durability: writes that the cache has confirmed and has not yet passed on are lost if the cache node fails, which is the kind of loss that chapter 2 went to such lengths to prevent. Write-behind is appropriate for data whose occasional loss is tolerable, such as counters of page views, and for little else.
| Arrangement | Who fills the cache | On a write | Staleness | If the cache is down |
|---|---|---|---|---|
| Cache-aside | the application, after a miss | database first, then the entry is removed | possible, bounded by the time limit | reads go to the database |
| Read-through | the cache, on a miss | as for cache-aside | possible, bounded by the time limit | reads fail unless the application has a fallback |
| Write-through | the cache, on every write | cache and database together | none for data written this way | writes fail |
| Write-behind | the cache, on every write | cache now, database later | none in the cache; the database lags | confirmed writes can be lost |
The design of this chapter uses cache-aside, because requirement by requirement it is the arrangement in which a failure of the cache does the least harm.
What to remove when memory is full
In steady operation a cache is always full, since it has less memory than there is data and something is inserted on every miss. Each insertion therefore requires an eviction, the removal of an existing entry, and the rule by which the victim is chosen determines the hit ratio that a cache of a given size achieves. The ideal rule would remove the entry that will not be needed for the longest time to come. As the future is unknown, practical rules estimate it from the past, and they differ in which aspect of the past they consult.
Least recently used
The rule known as least recently used, or LRU, removes the entry that has gone longest without being read or written. Its justification is the observation that requests come in clusters, so that something used a moment ago is likely to be used again soon, and something untouched for a long time is likely to remain so. The cache keeps its entries in order of last use, moves an entry to the front whenever it is used, and evicts from the back.
The final step exhibits the characteristic weakness of the rule. A had been requested 3 times in succession and was plainly the most popular key, and yet a single pass over 3 other keys, each requested once, was enough to push it out. In practice this happens when a job scans a large number of keys once, for a report or a backup: the scan passes through the cache, evicting the truly popular entries in favour of entries that will never be requested again, and the hit ratio collapses until ordinary traffic has restored the popular entries.
Least frequently used
Least frequently used, or LFU, consults how often an entry has been used and disregards when. Every entry carries a count of its uses, and the entry with the lowest count is evicted.
On this sequence the rule does better, since the count of 3 protects A from the single requests for B, C and D, and the last request is a hit. A scan can no longer displace popular entries, because the scanned keys never accumulate a count.
LFU has the opposite weakness. A count, once earned, lasts indefinitely, so that an entry which was requested 10000 times last month and has not been requested since continues to occupy its place, while a newly popular entry begins at 1 and is in danger of eviction before it has had time to prove itself. Practical versions therefore let the counts decay, halving them at intervals, so that popularity has to be maintained to be kept.
Other rules
First in, first out evicts the entry that was inserted longest ago, without regard to use. It needs no bookkeeping on reads, which makes it the cheapest rule, and it evicts popular and unpopular entries alike, which generally makes it the least effective.
Evicting an entry chosen at random sounds like no rule at all and performs surprisingly close to LRU on many workloads. A refinement that several in-memory stores have adopted is to pick a handful of entries at random and evict the least recently used among them. This approximates LRU closely while avoiding the ordered list that true LRU must update on every single read, an update which becomes a point of contention when many threads read the cache at once.
The most effective rules in current use combine recency and frequency. One widely adopted approach admits a new entry to the main part of the cache only if an approximate count of its past requests exceeds that of the entry it would displace, so that keys seen once, whether from a scan or from the long tail of unpopular keys, are turned away at the door. The details are beyond this chapter, and the principle is the one the 2 pictures above have already shown, namely that recency and frequency each catch what the other misses.
| Rule | Evicts | Strength | Weakness |
|---|---|---|---|
| Least recently used | the entry unused for longest | adapts quickly when popularity shifts | a scan of cold keys flushes the popular ones |
| Least frequently used | the entry with the fewest uses | scans do no harm | formerly popular entries linger, and new ones struggle |
| First in, first out | the oldest insertion | no work on reads | ignores popularity altogether |
| Random sample, then least recently used | the stalest of a few random entries | close to LRU without an ordered list | slightly lower hit ratio |
Our design uses least recently used, as most caches do. It is simple, it suits traffic in which popularity changes from hour to hour, and its weakness can be avoided by keeping scans away from the cache.
How a copy becomes stale
A cached value is stale when the database holds a newer one. Some staleness is inherent in caching and has to be accepted, and the purpose of the design is to bound it and to make sure that it is never permanent.
Under cache-aside, staleness is addressed by the rule that a writer, having updated the database, removes the entry from the cache. It may be asked why the writer does not place the new value in the cache directly, which would spare the next reader a miss. The reason is that 2 writers can then interfere. If writer 1 sets the database to 11 and writer 2 sets it to 12, and their subsequent writes to the cache happen to arrive in the opposite order, the database ends at 12 and the cache at 11. Removing the entry is immune to this, because removing something twice, in either order, leaves it removed.
The order of the 2 steps of a write also matters, the database being written before the entry is removed. If the entry were removed first, a reader arriving between the removal and the database write would miss, read the old value from the database, and put it back into the cache, where it would remain after the write had completed.
Even with both precautions observed, a stale value can become established, through an interleaving of a reader and a writer that is rare and entirely possible.
What makes this race instructive is that neither request did anything wrong. The reader followed the rule for reads and the writer the rule for writes, and the result is a cache that disagrees with the database and will go on disagreeing, since no further event is scheduled to correct it. The race needs a read to be delayed, between fetching from the database and storing in the cache, for longer than an entire write takes, which is unusual, and on a busy system unusual events occur many times a day.
There are 3 defences, of increasing strength. The first is to give every entry a time limit, discussed in the next section, so that even an entry that should have been removed disappears eventually. The second is for the writer to remove the entry a second time after a short delay, which catches a reader that stored its stale value just after the first removal. The third makes the reader’s store conditional. On a miss the cache hands the reader a token, the removal of an entry invalidates any token outstanding for it, and a store that presents an invalidated token is rejected. In the steps above, request 2’s removal would have invalidated the token of request 1, whose attempt to store 10 would then have failed, leaving the cache empty and correct.
Time limits
Every entry in a cache should carry a time to live, or TTL, after which the cache discards it regardless of anything else. The TTL is what converts every failure of invalidation, whether the race just described, a removal that was lost because the cache was briefly unreachable, or a code path that updates the database and forgets the cache, from a permanent error into a temporary one. It is the single mechanism that bounds staleness unconditionally, and for that reason it should be present even where explicit removal is believed to be complete.
Choosing the value is a judgement about the data. A long TTL gives a higher hit ratio and allows stale values to live longer, and a short one the reverse. The question to ask of each kind of data is how long a wrong answer could be shown before somebody is harmed. A product description may tolerate an hour, the number of items in stock a few seconds, and an account balance nothing at all, which is another way of saying that it should not be cached in this manner.
3 ways in which a cache fails under load
The failures considered so far concern correctness. The 3 considered next concern load, and each of them has brought down systems whose caches were functioning exactly as designed.
The stampede
When a popular entry expires or is removed, every request that arrives before it has been restored is a miss. For an entry that is read 500 times per second and takes 100 ms to recompute, some 50 requests will arrive during the recomputation, and under plain cache-aside every one of them queries the database for the same value.
If the query is expensive, the database slows down under 50 simultaneous copies of it, the recomputation takes longer, still more requests arrive during it, and the situation feeds on itself. This is called a stampede.
The principal remedy is to ensure that only 1 request recomputes a given entry. The first request to miss takes a short-lived lock on the key and proceeds to the database, and the others, finding the lock taken, wait briefly and read the cache again, by which time the value is usually there. A complementary technique avoids the miss altogether by refreshing popular entries shortly before they expire: a request that finds an entry close to its expiry may, with a probability that rises as the expiry approaches, recompute it early while all other requests continue to be served the existing value. A third approach is to serve the expired value for a short grace period while a single request fetches a fresh one in the background, which trades a little staleness for the complete absence of a stampede.
Penetration by keys that do not exist
A cache stores answers, and a request for something that does not exist has, on the face of it, no answer to store. Consequently every request for a nonexistent key misses the cache and reaches the database, which searches and finds nothing, and the next request for the same key does the same. An attacker who requests random ids, or merely a defective client that keeps asking for a deleted record, thus obtains unimpeded access to the database, the cache contributing nothing. This is known as cache penetration.
The straightforward remedy is to cache the absence. When the database reports that a key does not exist, the application stores a marker to that effect under a short TTL, and subsequent requests for the key are answered from the cache. Where the set of possible nonexistent keys is very large, as with random ids, the markers themselves could fill the cache, and a more economical structure is placed in front: a Bloom filter, which is a compact summary of the set of keys that do exist. It can state with certainty that a key is absent, and it occasionally reports an absent key as possibly present, in which case the request proceeds to the cache and the database as usual. A filter covering a billion keys occupies little more than a gigabyte.
The avalanche
The third failure arises when a large part of the cache becomes empty at once. This happens when many entries were loaded together and given the same TTL, for example by a job that warms the cache at startup, so that they all expire in the same instant. It happens on a larger scale when a cache node fails or is restarted. In either case the hit ratio falls abruptly, and by the arithmetic of the first section a fall from 99% to 90% multiplies the load on the database by 10.
Against synchronised expiry the remedy is to add a random amount to each TTL, so that entries loaded together expire over a spread of time. Against the loss of a node, the remedies are structural and are the subject of the next section. In both cases the database should be defended independently, by a limit on the number of concurrent queries that the application will send to it, so that an empty cache results in slow responses and some refusals while the database itself stays up. The rate limiter of chapter 7 and load shedding serve exactly this purpose.
A cache on many nodes
With 100 GB of entries and nodes of 16 GB, the cache has to be spread over several machines, and each key must be assigned to one of them in a way that every client computes identically. This is the problem that chapter 1 solved for the key-value store, and the solution is the same: a consistent hash ring on which each node owns many small ranges, so that adding or removing a node moves only the keys of that node.
The argument for consistent hashing is, if anything, stronger for a cache than for a store. A key that moves to a different node is, from the cache’s point of view, a key that has been forgotten, since the new node does not have it. Were keys assigned by taking a hash modulo the number of nodes, the addition of a single node would move nearly all keys, which is to say that it would empty the cache, with the consequences just described. On the ring, adding an eighth node to 7 moves about an eighth of the keys.
In this design the ring lives in a library inside each service, and the cache nodes are unaware of one another. A node holds entries, evicts and expires them, and that is all. There is no leader and no replication between nodes in the basic design, on the reasoning that every entry is a copy of something held durably elsewhere, so that the loss of a node loses nothing that cannot be fetched again.
That reasoning is sound with respect to data and incomplete with respect to load. The loss of 1 node in 8 turns an eighth of all reads into misses until those entries have been fetched again, and the database must be able to bear this. Where it cannot, each entry can be written to 2 nodes, the next one clockwise on the ring serving as a standby, at the cost of half the cache’s capacity. A related precaution concerns a node that returns after a restart with its memory empty: its entries can be loaded gradually by admitting only a fraction of its normal traffic at first, so that its misses arrive at the database as a trickle.
A last difficulty on many nodes is the hot key, a single entry so popular that the 1 node holding it is overloaded while the others are idle. No arrangement of the ring helps, since the ring spreads keys and this is 1 key. The usual answers are to keep a copy of such entries in the memory of each application process for a second or 2, in front of the distributed cache, or to store the entry under several derived keys on different nodes and have readers choose among them at random.
Use cases
Services read through the cache and inform it when the data behind an entry has changed. Operators add and remove nodes.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A service can store a value under a key for a limited time. |
| 1 | A service can read a value, and learns whether the cache had it. |
| 2 | A service can delete a key when the data behind it changes. |
| 3 | When memory is full, the cache drops the entries that were used longest ago. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Latency | A read takes under 1 ms. |
| 1 | Availability | The loss of a cache node loses no data that matters, because every entry is a copy. |
| 2 | Consistency | Eventual. A cached value can be older than the database for as long as its time limit. |
| 3 | Scale | 100 GB of entries and 200000 reads per second. |
A cache is eventually consistent by its nature, and non-functional requirement 2 says so openly. For some period after a write, a reader may still receive the previous value, and the TTL of the entry is the bound on that period. The design does not attempt to remove this window. It attempts to make its length a deliberate choice for each kind of data, and to ensure, by means of the TTL, that no failure can extend it indefinitely.
Core entities
Capacity estimate
Arithmetic ahead. 100 GB at 16 GB of memory per node needs 7 nodes, and we run 8.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Memory needed | given | 100 GB |
| Usable memory per node | 16 GB × 0.9 | 14.4 GB |
| Nodes | 100 ÷ 14.4 | 6.9, so 7 |
| Reads per node | 200000 ÷ 8 | 25000 per second |
The API
Values are opaque bytes, as in the key-value store of chapter 1, and the time limit of an entry travels in a header.
Store a value for 60 seconds
PUT /v1/entries/user-42 HTTP/1.1
Host: cache.example
Cache-Control: max-age=60
Content-Length: 9
ada, 1815
HTTP/1.1 204 No Content
After 60 seconds the entry is gone, whether or not it was read.
Read a value that is not cached
GET /v1/entries/user-43 HTTP/1.1
Host: cache.example
HTTP/1.1 404 Not Found
Misses are ordinary answers: the caller reads the database and stores the result.
A miss is reported with 404, since from the point of view of HTTP the resource does
not exist at that address. It is an ordinary outcome and no error, and the caller
responds to it by reading the database and storing the result.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Cache library | hashes a key and talks to the node that holds it | a copy of the ring | nothing to recover: it is part of the service |
| Cache node | keeps entries in memory and drops the least recently used | entries, in memory only | its keys miss until they are fetched again |
| Database | holds the real data | everything | outside the scope of the cache |
One request, step by step
The steps follow the arrows of figure 9.8.
- A service asks the cache library for
user-42. - The library hashes the key on its ring and asks the 1 cache node that owns it. That node answers with the value, or with
404. - On
404, the service readsuser-42from the database, which is slower. - The service stores the value in the cache with a time limit, so that the next reader gets a hit.
A write follows the complementary path. The service updates the database first, and
when the database has confirmed, it sends DELETE for the key to the cache node that
owns it. If that DELETE fails because the node is unreachable, the entry survives
with the old value until its TTL expires, which is the case the TTL exists for.
The design, deployed
The deployment has a feature that distinguishes it from most others in this book, which is the absence of a load balancer. Each of the 400 service instances contains the cache library with its copy of the ring, and sends every request directly to the 1 cache node that owns the key. The 8 nodes are spread over 3 zones. With a hit ratio of 99%, about 2000 of the 200000 reads per second go on to the database, whose followers serve them. The figure makes the dependence plain: the database tier is sized for the traffic that passes the cache, and would receive 100 times as much if the cache tier were lost.
How real systems do it
The most thorough public account of a cache at scale is Facebook’s paper on its use of memcached, and nearly every difficulty in this chapter appears in it with the remedy that Facebook adopted.
The stale value left by an overlapping read and write, and the stampede on a missing entry, are both solved there by 1 mechanism, the lease. On a miss, the cache gives the client a token that entitles it to fill the entry. A delete invalidates any outstanding token, so that a late fill with an old value is rejected, which is the conditional store described above. And the cache issues a token for a given key only at intervals, telling other clients that miss in the meantime to wait briefly and try again, which prevents the stampede. The paper reports that leases reduced the peak rate of database queries for a set of keys prone to stampedes from 17000 per second to 1300.
The paper also describes what it does about the avalanche. When a cache server fails, its keys are not moved to the remaining servers, which might then be overloaded in turn. Requests for them go to a small set of idle machines kept for the purpose, called the gutter. Invalidation is driven from the database’s own log of committed changes, which is more dependable than relying on every application to issue its deletes, and a cluster that starts empty is warmed by reading from a neighbouring cluster’s cache before it is allowed to reach the database.
On eviction, the rule of sampling a few entries and removing the least recently used among them is what Redis does by default, with 5 samples. The admission policy that compares the estimated frequency of a newcomer with that of the entry it would replace is TinyLFU, implemented in the Caffeine library. A study of the cache clusters at Twitter found, among other things, that for many workloads at realistic cache sizes plain first in, first out performed about as well as LRU, which has since led to a family of simple queue-based policies. The technique of refreshing an entry early with a probability that rises towards its expiry has been analysed formally and shown to be optimal in a certain sense.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Entries without a time limit | A missed or lost removal leaves a wrong value in place permanently. | Give every entry a TTL, even where removal on write is believed to be complete. |
| Writing the new value into the cache on update | 2 writers can leave the cache holding the older of their values. | Remove the entry and let the next reader fetch it. |
| Removing the entry before writing the database | A reader in between restores the old value. | Write the database first, then remove. |
| Assuming that removal after writing closes every race | A delayed reader can store an old value after the removal. | Rely on the TTL, and where it matters make the reader’s store conditional. |
| Letting every miss query the database | An expired popular entry produces a stampede. | Let 1 request recompute while the others wait or are served the old value. |
| Not caching absences | Requests for nonexistent keys reach the database every time. | Cache a marker for “does not exist” with a short TTL, or put a Bloom filter in front. |
| The same TTL on entries loaded together | They expire together, and the hit ratio drops at once. | Add a random amount to each TTL. |
| Sizing the database for the load that passes the cache | The loss of the cache multiplies the database’s load beyond what it can serve. | Limit concurrent queries to the database, and test with the cache empty. |
| Assigning keys by hash modulo the number of nodes | Adding a node moves nearly every key, which empties the cache. | Use a consistent hash ring. |
| Scanning through the cache | A batch job evicts the popular entries. | Let scans read the database directly, without storing what they read. |
| Caching data that must be exact | Readers act on a balance or a permission that has changed. | Read such data from the database, or use the write-through arrangement. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A cache node is lost | slower responses for its share of the keys | the keys are fetched from the database again |
| The whole cache is empty after a restart | the database receives the full read load | start with a limit on the number of concurrent database reads |
| A value changes in the database | readers may see the old value | the writer deletes the key; otherwise the time limit ends the staleness |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Memory only | reads in microseconds | everything is lost on a restart |
| Least recently used | keeps the popular keys without configuration | a scan over many keys can push the popular ones out |
| Time limits | a bound on staleness without any coordination | values are stale within the bound |
The simulation
Simulation in the repository. The program code/cachesim simulates this design. To run it, clone the repository and type cargo run --release -p cachesim in the code directory.
References
- R. Nishtala and others, “Scaling Memcache at Facebook”, NSDI 2013.
- G. Einziger, R. Friedman and B. Manes, “TinyLFU: A Highly Efficient Cache Admission Policy”, ACM Transactions on Storage, 2017.
- J. Yang, Y. Yue and K. Rashmi, “A Large Scale Analysis of Hundreds of In-memory Cache Clusters at Twitter”, OSDI 2020.
- A. Vattani, F. Chierichetti and K. Lowenstein, “Optimal Probabilistic Cache Stampede Prevention”, VLDB 2015.
- Redis documentation, “Key eviction”, on approximated LRU.
- B. Bloom, “Space/Time Trade-offs in Hash Coding with Allowable Errors”, Communications of the ACM, 1970.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Cache | a store of copies kept in memory so that repeated reads are fast |
| Hit, miss | a read that the cache answers, and a read that it cannot |
| Hit ratio | the fraction of reads that are hits |
| Cache-aside | the arrangement in which the application reads the database on a miss and fills the cache |
| Read-through | the arrangement in which the cache fetches from the database on a miss |
| Write-through | the arrangement in which a write goes to the cache and the database together |
| Write-behind | the arrangement in which the cache confirms a write and passes it to the database later |
| Eviction | removing an entry to make room for another |
| Least recently used, LRU | evicting the entry that has gone longest without use |
| Least frequently used, LFU | evicting the entry with the fewest uses |
| Stale | older than the value now in the database |
| Invalidation | removing or replacing an entry because its original has changed |
| Time to live, TTL | the time after which the cache discards an entry |
| Stampede | many requests recomputing the same entry at once after a miss |
| Cache penetration | requests for keys that do not exist, which the cache cannot answer |
| Bloom filter | a compact summary of a set that can say for certain that a key is absent |
| Avalanche | a large part of the cache becoming empty at once |
| Hot key | 1 entry popular enough to overload the node that holds it |
Summary
- The value of a cache lies in its hit ratio, and going from 90% to 99% reduces the load on the database tenfold.
- A system sized for the load that passes its cache cannot run without the cache, so the database needs its own protection.
- In cache-aside, a reader fills the cache after a miss, and a writer updates the database and then removes the entry.
- Least recently used adapts quickly and is flushed by scans, while least frequently used resists scans and holds on to entries that are no longer popular.
- A read and a write can overlap so that the cache keeps an old value, and the time limit on every entry is what bounds the damage.
- A stampede is prevented by letting 1 request recompute an entry, penetration by caching absences, and an avalanche by spreading expiry times.
- Keys are assigned to cache nodes with a consistent hash ring, so that a change of nodes forgets only a small share of the entries.
Distributed Lock: System Design
Some operations must be performed by 1 party only. A nightly billing run that charges every customer should execute once, on 1 machine, a file that is being rewritten should not be rewritten by 2 processes whose changes would interleave, and of the replicas of a database exactly 1 should accept writes. Within a single process this need is met by a lock, an object that 1 thread holds while it works and that makes every other thread wait, and the operating system guarantees that the lock behaves as advertised.
A distributed lock attempts to offer the same service to processes on different machines, and it has to do so under conditions that a lock inside 1 process is spared. The holder of a distributed lock may crash without releasing it. It may be cut off from the network and continue working in the belief that it still holds the lock. It may stop for a while, through no fault of its own, and resume later with no awareness that time has passed. In each case the other participants can observe only that the holder has fallen silent, and they cannot tell which of these things has happened. The central result of this chapter is that under these conditions a lock service cannot, by itself, guarantee that only 1 process acts at any moment, however carefully it is built, and that the guarantee has to be completed by the resource which the lock protects.
This chapter covers
- the 2 purposes of a lock, and why they call for different amounts of care
- a lock as an entry in a shared store, and why it has to expire
- the paused holder, which no lease can exclude
- fencing tokens, by which the protected resource rejects a late holder
- releasing a lock safely, and renewing a lease
- why the lock service itself must store each lock on a majority
- waiting for a lock without a stampede
- when a lock is the wrong tool
What a lock is for
Locks are taken for 2 quite different reasons, and it is necessary to know which one applies before deciding how much machinery a lock deserves.
In the first case the lock serves efficiency. Its purpose is to avoid doing the same work twice: sending a reminder email from 2 machines, or having 5 servers recompute the same expensive report. If the lock occasionally fails and 2 processes do proceed together, the consequence is some wasted effort or a duplicated email, which is regrettable and nothing more. A lock of this kind may be simple and may be wrong once in a long while.
In the second case the lock serves correctness. Here 2 processes acting together would corrupt something: a file written by both, an account debited twice, 2 database nodes both accepting writes as leader. When such a lock fails, the result is lost or damaged data, and “once in a long while” is not an acceptable frequency.
Much of the confusion surrounding distributed locks arises from applying a mechanism that is adequate for the first purpose to the second. The simple designs described in the next 2 sections are perfectly serviceable for efficiency. The remainder of the chapter concerns what must be added for correctness, and it turns out to be a good deal.
A lock as an entry in a store
The basic construction requires nothing more than a store that all the parties can
reach and that offers 1 atomic operation: create this key, provided it does not
already exist. To acquire the lock named nightly-billing, a worker asks the store to
create that key with the worker’s own name as its value. If the creation succeeds, the
worker holds the lock. If the key already exists, another worker holds it, and the
creation fails. To release the lock, the holder deletes the key.
The atomicity of “create if absent” is what makes this a lock. Were the worker to read the key first and create it afterwards, in 2 separate operations, 2 workers could both read that the key was absent and both proceed to create it, which is the same race that chapter 7 found between 2 rate limiter nodes sharing a bucket.
This lock has an obvious and fatal defect. If the worker that holds it crashes, or loses its network connection, the key is never deleted, and the lock remains taken for ever. Every other worker waits indefinitely on a holder that no longer exists.
Leases
The remedy is to let the lock expire. The key is created with a time limit, 10 seconds for example, and the store deletes it automatically when that time has passed. A lock that expires in this way is called a lease. A worker that needs the lock for longer than the lease must renew it periodically, by asking the store to extend the time limit while the worker’s name is still on the key, and a worker that crashes ceases to renew, so that its lease runs out and the lock becomes available to others.
The lease repairs the defect of the previous section and in doing so introduces the difficulty that occupies the rest of this chapter. The lock can now pass to a second worker without the first having agreed to give it up. Whenever a lease ends, there are 2 possibilities which the store cannot distinguish: the holder is dead, in which case handing the lock to someone else is exactly right, or the holder is alive and has merely been prevented from renewing, in which case there will shortly be 2 workers that each consider themselves the holder.
The duration of the lease expresses a trade between these cases. A short lease means that a crashed holder is replaced quickly and that a slow or briefly disconnected holder is more often presumed dead. A long lease means the opposite. No duration makes the second case impossible.
The holder that pauses
It might seem that a careful worker could protect itself against the second case. It knows when its lease ends, so it could check the clock before each step of its work and stop if the lease has run out.
This fails, and the reason is fundamental. No amount of careful programming removes it. Between the moment at which the worker checks its lease and the moment at which the effect of its next action reaches the protected resource, an arbitrary amount of time can pass without the worker being aware of it. The process may be suspended by its runtime in order to reclaim memory, which in some languages can take seconds. The virtual machine in which it runs may be paused by its host, for instance while being moved to another physical machine. The operating system may be short of memory and spend a long time reading the process’s pages back from disk. Or the worker’s write may be on its way through a congested network, delayed in a buffer for longer than the lease has left to run. In every one of these cases the worker performed its check correctly, found the lease valid, and acted in good faith, and the action took effect after the lease had ended and the lock had been given to someone else.
Nor can the worker rely on its clock to notice. A lease is measured on the clock of the lock service, while the worker reads its own, and chapter 8 has already shown that the clocks of 2 machines differ and that either may be corrected at any moment.
The conclusion has to be stated without qualification. A worker can never be certain, at the instant its action takes effect, that it still holds the lock. Any design in which correctness depends on that certainty is unsound, and the degree of unsoundness is merely a question of how often pauses of sufficient length occur.
Fencing tokens
Since the lock service cannot prevent a late holder from acting, and the holder cannot detect its own lateness, the only remaining party that can intervene is the resource being protected. It needs some means of recognising that a request comes from a holder whose lease has been superseded, and the lock service can supply that means at very small cost.
Each time the service grants the lock, it also issues a number that is larger than any it has issued before. This number is the fencing token. The holder attaches its token to every request it sends to the protected resource, and the resource remembers the highest token it has seen and refuses any request that carries a lower one.
The picture follows the scenario of the previous section to its end. Worker A was suspended, its lease expired, B was granted the lock with token 2 and wrote, and A then resumed and attempted to write with token 1. The storage refused, on the simple ground that 1 is less than 2. Nothing in this depends on clocks, on the length of the pause, or on A’s noticing anything.
The token changes the nature of what the lock service provides. Without tokens, the service is asked to guarantee that 2 workers never act together, which it cannot. With tokens, it is asked only to issue increasing numbers and to grant the lock to 1 worker per number, which it can, and the exclusion is enforced at the 1 place where it can be enforced, namely the resource itself.
There is a requirement hidden in this arrangement, and it is the reason fencing is used less often than it should be. The protected resource must take part. It has to accept a token with each request, store the highest, and compare. A database can do this readily, by keeping the token in a column and making each write conditional on the token’s being no lower than the stored one, which is the compare-and-set operation of chapter 1. A resource that cannot be made to check tokens, such as a third-party API that merely accepts requests, cannot be protected for correctness by any distributed lock at all, and in that case one must either accept the risk or redesign the operation so that performing it twice does no harm.
Releasing and renewing
Releasing a lock looks like the simplest of the operations and conceals a well-known error. A worker that releases by deleting the key, without regard to its contents, may delete a lock that it no longer holds.
The correct release is conditional: delete the key only if its value is still my name. The comparison and the deletion have to be a single atomic operation in the store, for the usual reason that the lock might change hands between a separate read and delete. The same condition applies to renewal, which must extend the lease only if the caller is still the holder.
Renewal raises a question of timing. A worker normally renews at intervals of about a third of the lease, so that 2 consecutive renewals may be lost before the lease is in danger. The renewing is commonly done by a separate thread, so that the work itself need not be interrupted, and this has a hazard of its own. If the working thread becomes stuck while the renewing thread continues happily, the lock is held for ever by a worker that is accomplishing nothing, which is the original defect returned by another route. A sound implementation ties the 2 together, for example by renewing only while the work reports progress.
The lock service must not lose locks
Everything so far has treated the store that holds the lock as reliable, and a lock service on a single machine is as reliable as that machine. For availability the service is therefore replicated, and how it is replicated matters a great deal.
Many in-memory stores replicate in the manner that chapter 3 called asynchronous: the leader confirms a write to the client at once and forwards it to its followers afterwards. For a cache this is a sensible choice. For a lock it is a flaw.
The lock service is the 1 component in this design that requires strong consistency in the full sense of chapter 1. A granted lock has to survive the failure of any single node, which means that it has to be stored on a majority of nodes before it is confirmed, exactly as chapter 3 stores a record and chapter 4 elects a leader. Token numbers must likewise come from the replicated log, so that a new leader continues from the highest token ever issued and does not start again from a lower one. In practice this means building the lock service on a consensus system of the kind developed in chapters 3 and 4, and the coordinator of chapter 1 is such a system and commonly offers locks as one of its services.
An alternative that is sometimes proposed avoids consensus by using several independent stores. The worker attempts to create the lock on each of 5 unrelated nodes and regards itself as the holder if it succeeds on at least 3 within a short time. This is more dependable than a single node, since no 1 failure loses the lock. Its safety, however, rests on assumptions about time: that the clocks of the 5 nodes advance at nearly the same rate, and that the worker is not paused between acquiring the lock and using it. The previous sections have shown that neither assumption can be relied upon, and so this approach is suitable for locks taken for efficiency and should be combined with fencing, like any other, when correctness is at stake. It cannot itself supply fencing tokens, since 5 independent nodes have no common counter.
Waiting for a lock
A worker that fails to acquire a lock generally wishes to obtain it as soon as it is free. The crude method is to try again at intervals, which either wastes requests if the interval is short or wastes time if it is long.
Lock services therefore let a worker watch a key and be notified when it is deleted. The naive use of this facility has a defect at scale. If 100 workers are all watching the same lock, its release wakes all 100, all of them attempt to acquire it, 1 succeeds, and 99 resume watching, so that every release costs 100 notifications and 100 attempts. This is the thundering herd that chapter 7 met among retrying clients.
The established remedy arranges the waiters in a queue. Each worker, on arriving, creates an entry of its own that the service numbers in order of arrival. The worker whose entry bears the lowest number holds the lock. Every other worker watches only the entry immediately ahead of its own. When the holder finishes and deletes its entry, exactly 1 worker is notified, the next in line, and it becomes the holder. The entries are tied to the sessions of their workers, so that the entry of a worker that crashes is removed by the service and the queue closes up behind it. As well as avoiding the herd, this arrangement is fair, since the lock is granted in the order in which it was requested.
When a lock is the wrong tool
A distributed lock used for correctness requires a consensus system, fencing tokens, and the cooperation of the protected resource. It is worth asking, before paying that price, whether the problem can be solved without mutual exclusion.
Often it can. If the operation can be made idempotent, so that performing it twice has the same effect as performing it once, then 2 workers doing it together is harmless and no lock is needed: chapter 19 uses this approach for payments. If the operation is an update to a single record, a conditional write expresses “change this only if nobody else has changed it since I read it” directly, and the loser of a race reads again and retries, which is the compare-and-set of chapter 1 and is called optimistic concurrency. And if work can be partitioned so that each item has exactly 1 owner, as the message queue of chapter 11 assigns each partition to 1 consumer, then exclusion is achieved by the assignment and need not be negotiated for each item.
It will be observed that fencing is itself a conditional write, and this is no coincidence. In a correct design the resource’s own check does the real work of protection, and the lock in front of it serves chiefly to keep workers from wasting effort on attempts that would be refused. Seen in this light, a lock is an optimisation layered over a conditional write, and a design that has the conditional write may find that it needs the lock less than it supposed.
Use cases
The lock service has a single kind of user, a worker that must be alone during some critical step.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A worker can acquire a named lock. At most 1 worker holds a lock at any time. |
| 1 | A lock is held for a limited time, a lease, and the holder can renew it. |
| 2 | A lock whose lease has ended can be acquired by another worker. |
| 3 | Every lease comes with a token that is larger than the token of every earlier lease. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Consistency | Strong. At any moment, the lock service grants a lock to at most 1 worker. |
| 1 | Availability | The service survives the loss of 1 of its 3 nodes. |
| 2 | Latency | Acquiring a lock takes 1 round trip to a majority, a few milliseconds. |
This is one of the few designs in the book that demands strong consistency without reservation. A cache may be stale and a rate limiter may be off by a few requests, but a lock service that grants 1 lock to 2 holders has failed at the only thing it is for. The lock and its token are therefore stored on a majority of the service’s nodes before a grant is confirmed, and when a majority cannot be reached the service refuses to grant locks at all, preferring unavailability to error.
Core entities
Capacity estimate
Arithmetic ahead. A lock is about 100 bytes, so a million locks fit in 100 MB, and 3 small nodes are enough.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Memory per lock | name 50 bytes + holder 30 + token 8 + expiry 8 | about 100 bytes |
| Memory for 1 million locks | 1000000 × 100 bytes | 100 MB |
The API
A lock is a resource with a name. Acquiring it creates the resource, renewing it replaces the expiry, and releasing it deletes the resource.
Acquire a lock for 10 seconds
POST /v1/locks/nightly-billing HTTP/1.1
Host: locks.example
Content-Type: application/json
{ "holder": "worker-a", "seconds": 10 }
HTTP/1.1 201 Created
Content-Type: application/json
{ "token": 1, "expires_in": 10 }
Workers have to pass the token on to whatever the lock protects.
Acquire a lock that is held
POST /v1/locks/nightly-billing HTTP/1.1
Host: locks.example
Content-Type: application/json
{ "holder": "worker-b", "seconds": 10 }
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "/problems/lock-held",
"title": "The lock is held",
"status": 409,
"detail": "held for 9 more seconds"
}
409 Conflict is the status for a request that is valid but cannot be carried out in the current state.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Worker | acquires the lock, then does its work | the token of its lease | its lease runs out, and another worker can acquire the lock |
| Lock service | grants leases and counts tokens | the locks, replicated on 3 nodes | the other 2 nodes continue |
| Storage | the thing the lock protects; checks the token | the highest token it has accepted | outside the scope of the lock service |
One request, step by step
The steps follow the arrows of figure 10.6.
- A worker sends
POST /v1/locks/nightly-billing. The lock service, a group of 3 nodes that agree by majority, records the worker as holder for 10 seconds and returns token 1. - The worker does its protected work and sends token 1 with every write to the storage. The storage compares the token with the highest it has accepted.
The scenario, simulated
The simulation for this chapter plays out the paused holder with and without help from the storage. Its output for the fenced case reads as follows.
| Time | Event | Result |
|---|---|---|
| 0 s | worker A acquires the lock | token 1, lease until 10 s |
| 1 s | worker B asks for the lock | refused |
| 2 s | worker A is paused for 15 seconds | |
| 12 s | the lease has ended, and worker B acquires the lock | token 2 |
| 13 s | worker B writes with token 2 | accepted |
| 17 s | worker A resumes and writes with token 1 | refused |
At the end the storage holds what worker B wrote.
The design, deployed
The lock service is small. It has 3 nodes, 1 in each zone, which form a single consensus group of the kind built in chapter 4, and workers reach whichever of them is the leader. It needs no more machines than this, because a well-designed system takes locks rarely and holds them for long periods, so that the rate of requests is low. What the figure is chiefly meant to show is the second arrow. The workers write to the protected storage directly, and the lock service is not on that path at all, which is why the last line of defence has to be in the storage.
How real systems do it
The lock service of this chapter is modelled on Chubby, which Google described in 2006 and which is the origin of most of the vocabulary. Chubby is a small replicated store whose locks are leases renewed by periodic messages, and its paper makes 2 observations that bear directly on this chapter. It introduces the sequencer, a token obtained with a lock that the holder passes to the servers it uses and that they check, which is the fencing token under its original name. And it reports that the service was designed for coarse locks held for hours or days, chiefly for electing leaders, and that using it for fine-grained locking is discouraged.
ZooKeeper provides the queue of waiters described above as a standard recipe. Each waiter creates a node that is numbered in sequence and tied to its session, and watches only the node immediately before its own. In etcd, a lock is a key attached to a lease, and the revision number at which the key was created, which increases with every change to the store, serves as a fencing token.
The approach of acquiring a lock on a majority of independent in-memory stores is the Redlock algorithm proposed by the author of Redis. Martin Kleppmann’s analysis of it in 2016 set out the argument of this chapter: that a pause of the process or a jump of a clock can leave 2 clients each believing that it holds the lock, that no lease-based lock can prevent this, and that safety therefore requires fencing, which Redlock does not provide since it has no source of increasing numbers. The author of Redis replied in defence of the algorithm, and the exchange remains the clearest public discussion of what a distributed lock can and cannot promise.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| A lock without a time limit | A holder that crashes keeps the lock for ever. | Make every lock a lease. |
| Believing that a lease guarantees 1 holder | A paused or delayed holder acts after its lease has ended. | Have the protected resource check a fencing token. |
| Checking the lease and then writing | The pause can fall between the check and the write. | Let the resource decide, on the token, at the moment of the write. |
| Releasing by deleting the key unconditionally | A late holder deletes the lock of its successor. | Delete only if the key still carries the caller’s name, in 1 operation. |
| Reading the key and then creating it | 2 workers both find it absent and both create it. | Use the store’s atomic create-if-absent. |
| A lock store that confirms before replicating | A failover forgets the lock, and it is granted twice. | Store each lock on a majority before confirming. |
| Renewing from a thread that ignores the work | A stuck worker holds the lock indefinitely. | Renew only while the work makes progress. |
| Every waiter watching the lock itself | Each release wakes all of them. | Queue the waiters, each watching the one ahead. |
| A lease shorter than the usual length of the work | The lock changes hands in the middle of ordinary runs. | Set the lease well above the normal duration, and renew. |
| Taking a lock where a conditional write would do | Machinery and a dependency that add no safety. | Use compare-and-set or an idempotent operation. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A worker crashes while holding a lock | the lock is unavailable until the lease ends | another worker acquires it afterwards |
| A worker pauses for longer than its lease | nothing wrong: its late writes are refused | the worker sees the refusal and stops |
| A lock service node is lost | nothing | the remaining 2 nodes form a majority |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Leases | a crashed holder cannot block others for ever | work that takes longer than a lease needs renewals |
| Fencing tokens | a late holder cannot cause damage | the protected resource has to check tokens |
| A majority of 3 nodes | at most 1 holder per lock | acquiring a lock costs a round trip |
The simulation
Simulation in the repository. The program code/locksim simulates this design. To run it, clone the repository and type cargo run --release -p locksim in the code directory.
References
- M. Burrows, “The Chubby Lock Service for Loosely-Coupled Distributed Systems”, OSDI 2006.
- P. Hunt and others, “ZooKeeper: Wait-free Coordination for Internet-scale Systems”, USENIX ATC 2010.
- M. Kleppmann, “How to do distributed locking”, 2016.
- S. Sanfilippo, “Is Redlock safe?”, 2016.
- C. Gray and D. Cheriton, “Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency”, SOSP 1989.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Lock | a right, held by 1 party, to act alone on something |
| Mutual exclusion | the property that at most 1 party acts at any moment |
| Lease | a lock that expires unless its holder renews it |
| Renewal | extending a lease before it expires |
| Process pause | a period in which a process does not run and is unaware of it |
| Fencing token | a number, larger with every grant of a lock, that the protected resource checks |
| Watch | a request to be notified when a key changes or is deleted |
| Session | the connection between a worker and the lock service, whose end removes the worker’s entries |
| Idempotent | having the same effect whether performed once or several times |
| Optimistic concurrency | writing on condition that nobody else has written since the data was read |
Summary
- A lock taken for efficiency may occasionally fail, and a lock taken for correctness may not, and the 2 need very different designs.
- A lock in a shared store has to be a lease, since a holder that crashes would otherwise keep it for ever.
- A lease can end while its holder is paused or delayed, and the holder cannot detect this, so a lease alone does not guarantee 1 holder.
- A fencing token, which grows with every grant and is checked by the protected resource, rejects the late holder.
- Release and renewal must be conditional on the caller still being the holder.
- The lock service stores each lock and token on a majority, because a lock forgotten in a failover is a lock granted twice.
- Waiters are queued so that a release notifies 1 of them.
- Idempotent operations and conditional writes often remove the need for a lock.
Message Queue: System Design
When one service needs another to do something, the direct course is to call it and wait for the answer. An order service that has accepted an order calls the billing service to issue an invoice, then the warehouse to reserve the goods, then the mail service to send a confirmation, and replies to the customer when all 3 have answered. This works, and it binds the 4 services together more tightly than is good for any of them. The order service is now as slow as the slowest of the 3 and as available as all of them together, so that an outage of the mail service, which is surely the least important of the 3, prevents customers from placing orders.
A message queue loosens this binding. The order service records the fact that an order was created by writing a short message to the queue, and its part is then finished. Billing, the warehouse and the mail service each read the message when they are able to, at their own pace, and a service that is down for an hour reads it an hour late and is otherwise none the worse. The sender and the receivers need not be running at the same time, need not be equally fast, and need not know of one another’s existence.
What the queue takes on in exchange is a set of promises that are easy to state and hard to keep: that a message once accepted is not lost, that it reaches every party entitled to it, that related messages arrive in the order in which they were sent, and that all of this continues to hold when machines fail in the middle of things. This chapter designs a queue of the kind that keeps its messages in a log, and most of it is devoted to stating exactly what can and what cannot be promised about delivery.
This chapter covers
- what a queue changes in the relationship between 2 services
- 2 kinds of queue: one that hands out tasks, and one that keeps a log
- partitions, keys, and what ordering a queue can preserve
- consumer groups, and how partitions are shared and reassigned
- at most once, at least once, and why “exactly once” needs the consumer’s help
- what a producer must do so that a retry does not duplicate a message
- how a queue stays durable, using the replication of chapter 3
- messages that can never be processed, consumers that fall behind, and publishing together with a database write
What a queue changes
A queue placed between 2 services alters their relationship in 3 respects, each of which is a reason for introducing one.
It separates them in time. The sender, called the producer, hands its message to the queue and proceeds without waiting for anybody to act on it, and the receiver, called the consumer, takes the message whenever it is ready. Neither depends on the other being available at that moment, and the availability of the producer is no longer limited by that of its consumers.
It absorbs differences in speed. If orders arrive in a burst of 1000 per second for a minute while the billing service can process 200 per second, direct calls would overwhelm billing or force the order service to refuse customers. With a queue between them, the burst accumulates as a backlog of 48000 messages, which billing works through in the following 4 minutes. The queue has levelled the load, turning a peak that billing could not meet into a delay that it can.
And it permits several independent receivers. The order service publishes a single message, “order 7 created”, and has no need to know who is interested. A new service, written next year to compute sales statistics, can begin reading the same messages without any change to the producer.
These benefits come at a definite cost, which should be acknowledged at the outset. The producer no longer learns whether its message was acted upon, or when. Errors that a direct call would have reported immediately now occur later, in another service, and have to be found there. And the system as a whole has become eventually consistent in the sense of chapter 1: for some time after an order is accepted, no invoice exists for it, and every part of the system has to be designed in the knowledge that this interval exists and is sometimes long.
2 kinds of queue
The word “queue” is applied to 2 designs that share the vocabulary of producers and consumers and differ in almost everything else.
The older design, which we may call a task queue, treats a message as a piece of work to be done once. Consumers take messages from the front, and each message is given to exactly 1 of them. When the consumer reports that it has finished, by sending an acknowledgement, the queue deletes the message. If the consumer fails to acknowledge within a set time, the queue assumes it has crashed and makes the message available to another. The queue holds only what has not yet been done, and a message that has been processed is gone.
The newer design is a log. Messages are appended to the end of a file and stay there, each identified by its position, which is called its offset. Reading a message does not remove it. A consumer keeps track of the offset it has reached, reads onward from there, and periodically records its position with the queue. Messages are deleted only when they exceed a configured age or the log exceeds a configured size, quite independently of whether anybody has read them.
| Task queue | Log | |
|---|---|---|
| After a message is processed | it is deleted | it remains until it is old |
| Several independent readers | each message goes to 1 of them | each reader sees every message |
| Reading old messages again | impossible | start from an earlier offset |
| What the queue tracks per consumer | which messages are outstanding | 1 number, the offset |
| Order of processing | lost when consumers work in parallel | kept within a partition |
| Typical use | distributing jobs among workers | publishing events to many services |
The log has become the more common basis for communication between services, chiefly on account of the second and third rows. Because reading does not consume, any number of services can read the same messages, each at its own offset. Because messages are retained, a consumer that had a defect can be corrected and set back to reprocess the last day’s messages, and a newly written service can begin from the start of the log and build its state from history. The log is also the structure this book already knows best: it is the append-only log of chapter 2, replicated as in chapter 3, and offered to other services as a product.
The task queue remains the better choice for what its name describes, the distribution of independent jobs among a pool of workers, and it reappears in chapter 12. The remainder of this chapter concerns the log.
Partitions and order
A single log has a single end to which every message is appended, and is therefore limited to the rate at which 1 machine can write and to the capacity of 1 disk. To go beyond that, a named stream of messages, called a topic, is divided into several logs, called partitions, which can be placed on different machines and written concurrently.
Dividing the topic raises the matter of order. Within 1 partition, messages have a definite order, that of their offsets, and every consumer reads them in that order. Between partitions there is no order at all: if one message is in partition 0 and another in partition 1, nothing records which was published first, and 2 consumers may process them in either sequence.
For many messages this is of no consequence. For some it matters a great deal. “Order 7 created”, “order 7 paid” and “order 7 shipped” must be processed in that order, while the order of “order 7 paid” relative to “order 8 created” is immaterial. The requirement, in other words, is for order among messages about the same thing, and the queue provides exactly this by means of a key. The producer attaches a key to each message, here the order number, and the queue chooses the partition by hashing the key, so that all messages with the same key are appended to the same partition, where their order is preserved.
3 consequences of this arrangement tend to be discovered later than they should be. The first is that the choice of key is a design decision with lasting effects, since it fixes which messages are ordered relative to which. The second is that the number of partitions cannot be changed casually: the partition of a key is its hash modulo the number of partitions, so that adding partitions sends new messages for an existing key to a different partition from the old ones, and their order across the change is lost. The third is that a key which accounts for a large share of all messages, such as a single very large customer, makes its partition far busier than the others, and no number of additional partitions relieves it.
Consumer groups
A topic with 4 partitions can be read by 4 consumers working in parallel, and the mechanism that coordinates them is the consumer group. All instances of 1 service, for example the 4 running copies of the billing service, belong to 1 group, and the queue ensures that each partition is read by exactly 1 member of the group at any moment. Different services form different groups, and each group reads the whole topic independently, with its own offsets.
Confining each partition to 1 reader per group is what preserves order during processing. If 2 members of the same group read the same partition, one might process “order 7 paid” before the other had finished with “order 7 created”. The rule has the further consequence, shown in the fourth step, that the number of partitions is an upper limit on parallelism. A fifth consumer in a group that reads 4 partitions has nothing to do.
The redistribution of partitions when a member joins or leaves is called a rebalance, and it is a regular source of trouble in operation. The group detects a failed member by the absence of its periodic heartbeat, a short message that says only “I am alive”. While a rebalance is in progress, the partitions being moved are read by nobody. A consumer that is merely slow, taking longer over a batch of messages than the heartbeat timeout allows, is declared dead, loses its partitions, and rejoins, which triggers another rebalance, and a group can spend a large part of its time rebalancing in this way without any member having actually failed. The remedies are to send heartbeats from a thread separate from the one doing the work, and to limit the number of messages fetched at once so that a batch is always finished well within the timeout.
What “delivered” can mean
A consumer that handles a message does 2 things: it performs the work that the message calls for, and it records with the queue that the message has been dealt with, which in a log is done by committing an offset. These are separate operations on separate systems, and a consumer can crash between them. The order in which it performs them determines what the system guarantees, and there are only 2 orders to choose from.
If the consumer does the work first and commits afterwards, a crash between the 2 leaves the work done and the offset not advanced.
The replacement consumer begins from the last committed offset and repeats work that was already done. No message is ever skipped, and some are processed more than once. This guarantee is called at least once.
If the consumer commits first and does the work afterwards, a crash between the 2 leaves the offset advanced and the work not done.
Now the replacement begins after the message that was never processed, and that message is lost for good. Nothing is processed twice, and some messages are not processed at all. This guarantee is called at most once.
There is no third order, and it follows that a queue cannot on its own deliver each message exactly once. One naturally asks whether the work and the commit could be made a single atomic step, and in general they cannot, because they affect different systems: the invoice is sent by a mail server, and the offset is stored by the queue. Making 2 independent systems change together or not at all is the hard problem of distributed transactions, and for something like sending an email it is impossible, since an email cannot be unsent.
What can be achieved, and what careful systems mean when they speak of exactly-once processing, is an effect that occurs exactly once. The queue delivers at least once, and the consumer is built so that processing a message a second time changes nothing. An operation with this property is idempotent, and there are 2 standard ways of obtaining it. Some operations are idempotent by nature: “set the status of order 7 to paid” may be applied any number of times with the same result, whereas “add 1 to the count of paid orders” may not. For the others, the consumer records the identifier of every message it has processed, in the same database transaction as the effect of the message, and on receiving a message it has already recorded it does nothing. The simulation for this chapter uses this second method: a consumer crashes after acting on 6 messages and before committing, its successor is given the same 6 messages again, recognises each of them, and the total number of actions taken remains 6.
The choice between the 2 guarantees should be made by asking which failure is the more tolerable. For most business events it is plainly better to risk a duplicate, which idempotency can neutralise, than a loss, which nothing can repair, and at least once with idempotent consumers is the standard combination. At most once is appropriate where a late or repeated message is worthless or harmful, such as a reading from a sensor that will be superseded by the next reading a second later.
The producer’s side
The same difficulty exists on the producing side, where it is less often noticed. A producer sends a message and waits for the queue to confirm that it has been stored. If no confirmation arrives within the expected time, the producer cannot tell whether the message was lost on the way to the queue or stored successfully with only the confirmation lost on the way back. If it does not send again, the message may be lost, and if it does, the message may be stored twice.
The solution again consists in making the repetition harmless. Each producer is given an identity by the queue, and numbers its messages to each partition consecutively. The partition remembers the highest number it has stored for each producer and discards any message whose number it has already seen. A producer can then resend without hesitation, and the log contains each message once.
The producer also decides how much durability to wait for, by choosing what constitutes a confirmation. It may send without waiting for any confirmation, which is fastest and loses messages whenever a machine fails. It may wait until the leader of the partition has written the message, in which case a message is lost if the leader fails before its followers have copied it. Or it may wait until the message is stored on a majority of the replicas, which is the rule of chapter 3 and the only setting under which a confirmed message survives the failure of a machine. Our design uses the third.
Durability and replication
Each partition is a log, and it is kept durable and available by precisely the means that chapter 3 applied to the log of a key-value shard. One of the machines that hold a copy of the partition, the machines being called brokers, is its leader and receives all appends. The others are followers, which copy the leader’s log, and a message counts as stored when a majority holds it. If the leader fails, a follower that holds every stored message is elected in its place, by the procedure of chapter 4.
Consumers are permitted to read a message only once it is stored on a majority. The reason is the one given in chapter 3 for not applying a record before it is committed. If a consumer were allowed to read a message that existed on the leader alone, and the leader then failed, the new leader’s log would not contain that message, and the consumer would have acted on something that, as far as the queue is concerned, never happened.
The committed offsets of consumer groups are themselves data that must survive failures, since losing them would cause every group to reprocess its entire topic. They are small and frequently updated, and are kept in a replicated store of their own, shown in the design as the offset store.
Difficulties in operation
Messages that cannot be processed
Suppose a message is malformed in a way the consumer does not anticipate, so that every attempt to process it raises an error. Under at least once delivery the consumer does not commit past it, and so it receives the same message again, fails again, and continues to do so indefinitely. A single such message, known as a poison message, halts its entire partition, since no later message in the partition can be reached.
The remedy is to limit the number of attempts. After a message has failed a set number of times, 5 for example, the consumer publishes it to a separate topic reserved for failures, called a dead-letter queue, commits past it, and continues. An alert draws an engineer’s attention to the dead-letter queue, where the message can be examined and, once the defect is corrected, published again. The cost is that the failed message has been overtaken by its successors, so that the order within its key is broken, and whether that is acceptable has to be decided for each topic.
Consumers that fall behind
The difference between the offset of the newest message in a partition and the offset that a group has committed is the group’s lag. It is the most informative single measurement in the operation of a queue, because it states directly how far a consumer is behind its producers. A lag that fluctuates around a small value indicates health. A lag that rises steadily indicates that messages arrive faster than they are consumed, and since messages are retained only for a limited period, a lag that continues to rise ends with the oldest unread messages being deleted before the consumer has reached them, which is a loss of data that no setting on the producer can prevent.
Publishing together with a database write
A service very often needs to do 2 things in response to 1 request: change its own database and publish a message announcing the change. The order service inserts an order and publishes “order 7 created”. Performed as 2 separate operations, these can come apart. If the service writes the database and crashes before publishing, an order exists of which no other service will ever hear. If it publishes first and the database write then fails, other services act on an order that does not exist.
The outbox pattern makes the 2 operations one. The service writes the message, as an ordinary row, into a table of its own database called the outbox, in the same transaction as the order itself, so that the database guarantees that both are written or neither is. A separate process then reads the outbox and publishes its rows to the queue.
The relay may crash between publishing a row and marking it as published, and will in that case publish it again on restarting. The pattern therefore delivers at least once, which returns us to the idempotent consumer, and it will be noticed how many of the questions in this chapter have ended at that same answer.
Keeping the latest value only
Retaining messages for a fixed period suits a stream of events. Some topics, however, carry the current state of things, each message being the latest value for its key, such as a customer’s current address. For these, old values are of no interest and the latest must be kept however old it is. A log can be compacted for this purpose: a background process rewrites it, keeping for every key only the most recent message. A new consumer reading a compacted topic from the beginning thereby obtains the current value of every key, and can construct a complete copy of the data from the topic alone.
Use cases
Producers publish, consumers fetch and commit, and operators decide how many partitions a topic has.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A producer can publish a message to a topic. Messages with the same key are delivered in the order they were published. |
| 1 | A consumer can fetch messages starting from an offset. |
| 2 | A group of consumers shares a topic: each message goes to 1 member of the group. |
| 3 | A consumer can commit an offset, and after a restart it continues from the committed offset. |
| 4 | Messages are kept for 7 days, whether or not they were read. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Durability | A published message survives the loss of 1 broker. |
| 1 | Consistency | Strong within a partition: every consumer sees the same messages in the same order. |
| 2 | Delivery | At least once. A message can be delivered again after a consumer crash. |
| 3 | Scale | 100000 messages per second of 1 KB each. |
The queue is strongly consistent within a partition, which means that all consumers see the same messages at the same offsets, and that a message which has been confirmed to its producer will be seen by every consumer. It makes no promise about the relative order of messages in different partitions. The system built from the queue and its consumers is eventually consistent, as the opening section explained, since a consumer acts on a message some time after it was published.
Core entities
Capacity estimate
Arithmetic ahead. 7 days of messages are 60 TB for 1 copy and 181 TB for 3, about 46 brokers with 4 TB disks.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Bytes per day | 100000 × 1 KB × 86400 s | 8.64 TB |
| 7 days, 1 copy | 8.64 × 7 | 60.5 TB |
| 3 copies | 60.5 × 3 | 181 TB |
| Brokers with 4 TB disks | 181 ÷ 4 | 45.4, so 46 |
The API
Publishing creates a message within a topic. Fetching reads from a partition, starting at an offset. Committing replaces the stored offset of a group.
Publish a message
POST /v1/topics/orders/messages HTTP/1.1
Host: queue.example
Content-Type: application/json
{ "key": "order-7", "body": "paid" }
HTTP/1.1 201 Created
Content-Type: application/json
{ "partition": 1, "offset": 2 }
Its key chooses the partition, and the response says where the message now lives.
Commit an offset
PUT /v1/topics/orders/partitions/1/offsets/billing HTTP/1.1
Host: queue.example
Content-Type: application/json
{ "committed": 6 }
HTTP/1.1 204 No Content
PUT is idempotent, so committing the same offset twice is harmless.
Consumers fetch messages, and the queue does not push them. This allows each consumer to proceed at its own pace and to ask for as many messages as it can handle, where a queue that pushed would have to guess at the capacity of every consumer. So that an idle consumer does not issue requests continuously, a fetch that finds no new messages is held open by the broker for a short time and answered as soon as a message arrives.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Broker | stores partitions as logs and serves fetches | the logs | a follower of each partition it led takes over |
| Consumer | fetches, acts, commits | none that must survive | another member of its group takes over its partitions |
| Offset store | remembers each group’s committed offsets | the offsets, replicated | consumers keep working and commit later |
One request, step by step
The steps follow the arrows of figure 11.8.
- A producer sends a message with the key
order-7. A hash of the key picks partition 0, and the request goes to the broker that leads it. - The leader appends the message to the partition’s log and copies it to a follower, as in chapter 3. Then it answers with the offset.
- A consumer asks the leader for messages from its committed offset onward.
- The consumer acts on each message. When the batch is done, it stores the new offset in the offset store.
The design, deployed
The 12 brokers are divided among 3 zones, and each partition has 1 replica in each zone. Leadership is spread evenly, so that every broker leads roughly a third of the partitions it holds, and the failure of a broker moves the leadership of only those. Producers and consumers hold a list of the brokers and connect directly to the leader of each partition they use, so that here too there is no load balancer on the path of the data. The offsets of the consumer groups are kept in a small replicated store of their own.
How real systems do it
The log-structured queue of this chapter is Kafka, which LinkedIn described in 2011. The partitioned log, the offset kept by the consumer, the consumer group and the retention of messages by age all come from that design, as do the compaction of a log by key and the producer that numbers its messages so that a retry is not stored twice, which was added in 2017 together with transactions.
In 1 respect Kafka differs from this chapter, and the difference is instructive. This chapter replicates a partition as chapter 3 does, confirming a message when a majority of replicas hold it. Kafka uses a different rule. The leader keeps a list of the replicas that are fully caught up, the in-sync replicas, and confirms a message when every one of them has it. A replica that falls behind is removed from the list, and the partition continues with those that remain. With this rule, f + 1 replicas tolerate f failures, where a majority rule needs 2f + 1, which matters for a system that stores a great deal of data. The cost is that the list itself must be agreed upon by some other means, for which Kafka relied on ZooKeeper and now on a Raft-based controller of its own, and that a setting for the smallest permitted size of the list has to be chosen with care, since a list that has shrunk to the leader alone confirms messages that exist on 1 machine.
The task queue described at the start of the chapter is the model of Amazon’s SQS, whose visibility timeout is the period after which an unacknowledged message is given to another consumer, and of RabbitMQ. Pulsar separates the brokers, which serve requests, from the storage of the log, which is delegated to a separate replicated service. And the outbox pattern is commonly implemented without a relay process, by reading the database’s own log of changes and publishing from that, a technique called change data capture.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Expecting each message to arrive exactly once | A consumer that crashes before committing receives its messages again and repeats their effects. | Deliver at least once and make the consumer idempotent. |
| Committing before the work | A crash after the commit loses the message. | Do the work, then commit, unless losing messages is acceptable. |
| Publishing without a key, or with a random one | Messages about the same thing land in different partitions and are processed out of order. | Use as the key the thing whose messages must stay in order. |
| Adding partitions to a topic in use | New messages for a key go to a different partition from the old ones. | Choose the number of partitions generously at the start. |
| More consumers than partitions | The extra consumers receive nothing. | Size the partitions for the parallelism that will be needed. |
| Retrying a failing message without limit | 1 message stops its whole partition. | After a fixed number of attempts, move it to a dead-letter queue. |
| Writing the database and publishing as 2 steps | A crash between them leaves the 2 in disagreement. | Write the message to an outbox in the same transaction. |
| Waiting only for the leader’s confirmation | A message is lost when the leader fails before its followers have it. | Wait for a majority. |
| Heartbeats from the thread that does the work | A slow batch is taken for a crash, and the group rebalances repeatedly. | Send heartbeats separately, and fetch small batches. |
| Not watching lag | A consumer falls so far behind that unread messages are deleted. | Raise an alert when lag grows steadily. |
| Processing 1 partition with several threads | Messages of the same key are handled out of order. | Use 1 thread per partition, or distribute within the consumer by key. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A consumer crashes before it commits | nothing, if the consumer ignores repeats | its messages are delivered again |
| A broker is lost | a pause of about 1 second for its partitions | followers are elected leaders, as in chapter 4 |
| A consumer is too slow | its messages wait longer | more partitions and more group members; the log holds the backlog |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Partitions | parallel consumers and more throughput | no order across partitions |
| Offsets kept by position | the broker stores 1 number per group | a consumer cannot skip 1 bad message without moving past it |
| At-least-once delivery | no message is lost | consumers must tolerate repeats |
The simulation
Simulation in the repository. The program code/queuesim simulates this design. To run it, clone the repository and type cargo run --release -p queuesim in the code directory.
References
- J. Kreps, N. Narkhede and J. Rao, “Kafka: a Distributed Messaging System for Log Processing”, NetDB 2011.
- J. Kreps, “The Log: What every software engineer should know about real-time data’s unifying abstraction”, LinkedIn engineering blog, 2013.
- Apache Kafka documentation, “Replication”, on in-sync replicas.
- Apache Kafka, KIP-98, “Exactly Once Delivery and Transactional Messaging”, 2017.
- Amazon SQS documentation, “Visibility timeout”.
- C. Richardson, Microservices Patterns, Manning, 2018, for the transactional outbox.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Message | a small record of something that happened, or of work to be done |
| Producer, consumer | the service that publishes a message, and a service that reads it |
| Task queue | a queue that gives each message to 1 consumer and deletes it when acknowledged |
| Log | a queue that appends messages and keeps them, read by offset |
| Topic | a named stream of messages |
| Partition | 1 of the logs into which a topic is divided |
| Offset | the position of a message in its partition |
| Key | the part of a message that decides its partition |
| Consumer group | the instances of 1 service, which share the partitions of a topic among themselves |
| Rebalance | redistributing partitions when a member joins or leaves a group |
| Heartbeat | a periodic message by which a consumer shows that it is alive |
| Commit | recording the offset up to which a group has processed a partition |
| At most once | a guarantee under which a message may be lost and is not repeated |
| At least once | a guarantee under which a message may be repeated and is not lost |
| Idempotent | having the same effect whether performed once or several times |
| Broker | a machine of the queue, which stores partitions |
| Poison message | a message whose processing always fails |
| Dead-letter queue | a topic to which messages are moved after repeated failure |
| Lag | how many messages a group is behind the end of a partition |
| Outbox | a database table of messages, written in the same transaction as the data they announce |
| Compaction | rewriting a log to keep only the latest message for each key |
Summary
- A queue separates services in time, absorbs bursts, and lets any number of services read the same messages, and the system becomes eventually consistent in return.
- A task queue deletes a message once it is processed, and a log keeps messages and lets each reader hold its own offset.
- Order exists within a partition only, and the key of a message decides which messages are ordered relative to each other.
- A consumer group gives each partition to 1 of its members, so the number of partitions limits parallelism.
- Working before committing gives at least once, committing before working gives at most once, and there is no other order.
- An effect that happens exactly once is obtained by delivering at least once to an idempotent consumer.
- A producer waits for a majority and numbers its messages, so that a confirmed message survives a failure and a retry is not stored twice.
- A database write and a message are made 1 step by writing the message to an outbox in the same transaction.
Job Scheduler: System Design
A good deal of the work that a system does is not done while anybody waits for it. A user uploads a video and receives an immediate confirmation, while the conversion of the video into several resolutions takes place over the following minutes. An account is closed today, and its data is to be erased in 30 days. A report on yesterday’s sales is to be compiled every night at 2 o’clock. In each case a piece of work, which we shall call a job, has to be remembered now and carried out later, by some machine other than the one that asked for it, and it has to be carried out even if that machine, or the one that accepted the request, fails in the meantime.
A job scheduler is the service that takes responsibility for such work. It accepts jobs, stores them durably, hands them to workers when their time has come, and keeps track of what became of them. The essential difficulty is that the scheduler can never observe a job being executed. It gives a job to a worker and thereafter knows only what the worker reports, and a worker that has stopped reporting may have crashed, may be slow, or may have completed the job and failed only in delivering the news. Everything in the design follows from the need to make progress in spite of this uncertainty without either losing jobs or running them more often than can be tolerated.
This chapter covers
- the life of a job, as a small set of states and transitions
- leases, by which a job is lent to a worker for a limited time
- why a job may run more than once, and how to make that harmless
- giving 1 job to 1 worker when many workers ask together
- retries, increasing pauses, and jobs that never succeed
- jobs that are due at a later time, and how to find them efficiently
- recurring jobs, and the hazards of firing twice or not at all
- priorities, fairness among customers, and long-running jobs
The life of a job
Every job in the scheduler is in exactly 1 of a small number of states, and the whole behaviour of the system can be described by the permitted movements between them.
A newly submitted job is waiting. When a worker takes it, the job becomes running, and from that state there are 3 ways out. If the worker reports success, the job has succeeded and nothing more will happen to it. If the worker reports failure, or reports nothing before a deadline, the job returns to waiting in order to be tried again. And if it has already been tried a fixed number of times without success, it becomes dead, which means that the scheduler has given up on it and that a person will have to look at it.
The value of describing the system in this way is that every question about its correctness becomes a question about a transition. Jobs are not lost if every job is always in 1 of the states and no transition discards a job. A job is not run by 2 workers at once if the transition from waiting to running can be made by 1 worker only. The 3 sections that follow take the transitions out of the running state one by one, beginning with the least obvious.
Lending a job: the lease
Suppose that a worker takes a job and the scheduler marks it as running and does nothing further. If the worker completes the job, all is well. If the worker crashes, the job remains marked as running indefinitely, and since only waiting jobs are handed out, it will never be given to anyone else. The job has in effect been lost, although it is still in the store.
The scheduler therefore does not give a job to a worker outright. It lends it for a limited period, and the loan is called a lease. The worker must report the result before the lease ends. If the period passes without a report, the scheduler concludes that the worker is not going to finish and returns the job to the waiting state, from which another worker will take it. Chapter 10 used a lease for the same reason, to recover a lock from a holder that has vanished.
The simulation for this chapter runs this scenario with 6 jobs and 3 workers, 1 of which crashes 2 seconds into its first job. All 6 jobs are completed. The job held by the crashed worker is recovered when its lease expires at 3 seconds and is finished by another worker at 6, and the record shows 2 attempts for that job and 1 for each of the others.
The length of the lease involves the compromise that chapter 10 described. If it is short, a crashed worker’s job is recovered quickly, and a worker that is merely slow has its job taken away and given to another while it is still working. If it is long, slow workers are safe and a crashed worker’s job sits idle for a long time before anyone notices. For jobs whose duration is predictable, the lease is set comfortably above the usual duration. For jobs that may take minutes or hours, a fixed lease of sufficient length would delay recovery intolerably, and the worker takes a short lease and renews it at intervals for as long as it is making progress, as with the renewal of a lock.
A job may run twice
The lease brings with it a consequence that has to be faced squarely. The expiry of a lease does not prove that the worker has stopped. The worker may be running slowly, or may have been paused in the manner described in chapter 10, or may have finished the job and been unable to report because of a fault in the network. In each of these cases the scheduler gives the job to a second worker while the first is still executing it or has already executed it, and the job is performed twice.
This is the same situation that chapter 11 called delivery at least once, and it is unavoidable for the same reason. The scheduler must choose between reissuing a job whose worker has fallen silent, which risks a second execution, and not reissuing it, which risks no execution at all, and since a job that is never run is nearly always the worse outcome, schedulers reissue. It follows that a scheduler guarantees that a job will be run at least once, and cannot guarantee that it will be run exactly once.
The responsibility for making this harmless lies with the author of the job. A job should be idempotent, which is to say that running it twice should leave the world in the same condition as running it once. Some jobs have this property without effort: resizing an image and storing the result under a fixed name can be repeated indefinitely. Others must be given it. A job that charges a customer has to carry a unique reference that the payment system uses to recognise a repeated charge, as chapter 19 describes, and a job that sends an email should record that it has done so and check the record before sending.
One further precaution limits the damage that a late worker can do. When a worker reports a result, it presents the number of the lease under which it was working, and the scheduler accepts the report only if that lease is still the current one for the job. The slow worker of the scenario above, reporting success under lease 771 after the job has been reissued under lease 772, is told that its lease is no longer valid and its report is ignored. The lease number plays the part of the fencing token of chapter 10: it cannot prevent the late worker from having done the work, and it does prevent the late worker from overwriting the state recorded by its successor.
Giving 1 job to 1 worker
The transition from waiting to running must be made by 1 worker only, and when many workers ask for jobs at the same time this requires care.
The natural way of writing the operation is in 2 steps: find a job that is waiting, then mark it as running. Between the 2 steps there is a gap, and a second worker’s request can fall into it.
The remedy, shown in the last step, is to make the transition a single conditional operation in the job store, of the form “set this job to running, under my lease, if it is still waiting”. The store performs each such operation in its entirety before beginning the next, so that when 2 workers attempt it on the same job, the second finds the condition no longer true and is refused. This is the compare-and-set of chapter 1 once more.
A conditional change is correct and, with many workers, not yet efficient. If 50 idle workers all identify the oldest waiting job and all attempt to take it, 1 succeeds and 49 have wasted a round trip and must try again, upon which they all converge on the next job. Databases used as job stores offer a remedy in the form of a query that selects waiting rows while passing over any row that another transaction has locked, so that each worker is given a different job at the first attempt. Where no such facility exists, workers can be made to choose at random among the first few waiting jobs, which reduces the collisions considerably.
Retries and jobs that never succeed
A job may fail for reasons that will have passed in a moment, such as a brief network fault or a service that was restarting, and for such failures the right response is to try again. The scheduler counts the attempts made on each job and, after a failure, returns the job to the queue.
It should not be returned for immediate execution. If a job has failed because some service it depends upon is overloaded, then retrying at once adds to the overload, and thousands of jobs doing the same can prevent the service from recovering at all. The job is therefore made to wait before its next attempt, and to wait longer after each successive failure: 1 second, then 2, then 4, and so on, which is called exponential backoff. A random amount is added to each pause, so that jobs which failed together do not all return together. Chapter 13 examines these pauses in detail.
Some jobs fail for reasons that will never pass. The input is malformed, or the code has a defect that this particular input exposes, and the hundredth attempt will fail exactly as the first did. Retrying such a job for ever would waste capacity and, worse, would conceal the problem. The number of attempts is therefore limited, and a job that exhausts its attempts is moved to the dead state, where it is kept with its last error for an engineer to examine. This is the dead-letter queue of chapter 11 under another name. An alert on the number of dead jobs is among the most useful that a scheduler can provide, since a sudden increase usually means that a defect has been deployed.
It is helpful if workers can distinguish the 2 kinds of failure where the cause is evident. A job that fails because its input cannot be parsed will not improve with repetition, and a worker that reports it as permanently failed spares the system the remaining attempts.
Jobs that are due later
A job may be submitted with a time before which it is not to run. The scheduler must then, at every moment, be able to find the jobs that have become due, and it must do so without examining all the jobs that are not.
The straightforward method is to store the due time of each job and to keep the waiting jobs in an index ordered by that time. The scheduler asks the store, every second or so, for waiting jobs whose time is not later than the present, and since the index is ordered, the store answers by reading from its beginning and stopping at the first job that is not yet due. The cost is proportional to the number of jobs that are due, however many millions are waiting for later.
2 limitations of this method should be understood. The first is precision: a job may start up to a polling interval late, which is of no importance for a job due in 30 days and matters for one that is meant to fire at a precise second. The second is that all scheduler nodes query the same index, which becomes a point of contention at high rates. A common refinement divides responsibility. Jobs due in the distant future remain in the durable store, and a scheduler node periodically loads those due within the next minute into its own memory, where it keeps them in a structure ordered by time and can release each at its exact moment. If that node fails, nothing is lost, since the jobs are still in the store and another node loads them.
Recurring jobs
A recurring job is a rule, such as “every day at 02:00”, from which a new job is to be created at each of the times the rule names. Creating those jobs is itself a piece of work that must be done exactly once per occasion, and with several scheduler nodes running for the sake of availability, the obvious implementation does it once per node.
One remedy is to elect 1 scheduler node as the only one that evaluates schedules, using the lock service of chapter 10. This works, and it inherits the weakness of every lock, namely that for a short time during a change of leader there may be 2 nodes that each believe themselves elected. The more dependable remedy does not rely on there being a single creator. Each run of a recurring job is given a name constructed from the rule and the scheduled time, and the job is created only if no job of that name exists. Any number of nodes may then attempt the creation, and the store admits the first and rejects the rest. The creation has been made idempotent, which, as so often in this book, is more reliable than trying to ensure that it is attempted only once.
The opposite hazard is the run that is missed. If every scheduler node is down from 01:55 to 02:10, the question arises of what should happen at 02:10 to the run that was due at 02:00. There is no universally right answer. A nightly report should probably be run late, since a late report is better than none. A job that sends a “good morning” notification should probably be skipped if it is already afternoon. And a job due every minute that has missed 3 hours should certainly not be run 180 times in succession. The policy for missed runs, whether to run once on recovery, to run every missed occasion, or to skip, has to be stated for each recurring job, and a scheduler that leaves it unstated has made the choice on its users’ behalf.
Schedules expressed in local time have a further peculiarity that causes a failure once or twice a year. On the night when clocks are set forward, the time 02:30 does not occur, and a job scheduled for it is either skipped or run at an unexpected hour. On the night when clocks are set back, 02:30 occurs twice, and the job may run twice. Schedules are best expressed in coordinated universal time, which has no such discontinuities, and converted for display only.
Priorities and fairness
When more jobs are waiting than the workers can take, the order in which jobs are handed out becomes a matter of policy. The simplest policy is first come, first served, and 2 situations call for something more.
Some jobs are more urgent than others. An email carrying a password reset should not wait behind 100000 jobs that regenerate thumbnails. Jobs are accordingly given a priority, and the scheduler hands out the highest priority first. Strict priority has a well-known defect, which is that a continuous supply of high-priority jobs prevents low-priority jobs from ever running, a condition called starvation. It is countered by letting the effective priority of a job rise with the time it has waited, or by reserving a share of the workers for each level.
Fairness among customers presents a similar problem. If 1 customer submits a million jobs and they are served in order of arrival, every other customer’s jobs wait behind all of them. The remedy is to keep a separate queue for each customer and to take from the queues in rotation, so that a customer who submits a great deal delays chiefly its own jobs. A limit on the number of jobs of 1 customer that may be running at once serves the same end.
Use cases
Developers submit jobs and ask about their state. Workers take jobs and report results.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A developer can submit a job, to run now or at a later time. |
| 1 | A worker can take a waiting job. A job is given to 1 worker at a time. |
| 2 | A worker reports success or failure. A failed job is tried again, up to 5 times. |
| 3 | A job whose worker stops responding is given to another worker. |
| 4 | A developer can read the state and the number of attempts of a job. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Durability | A submitted job is not lost when a scheduler node fails. |
| 1 | Consistency | Strong for job state: 2 workers are not given the same job at the same time. |
| 2 | Delivery | At least once. A job can be started a second time. |
| 3 | Scale | 10 million jobs per day. |
The state of a job has to be strongly consistent. Were 2 scheduler nodes to hold different views of whether a job is waiting or running, each could hand it to a worker, and the lease, on which all recovery depends, would mean nothing. The job store is therefore replicated in the manner of chapters 3 and 4, and a transition is acknowledged only when a majority has recorded it. What remains eventual is the relationship between the store and the world: a job recorded as running may in fact have finished a moment ago, and the design accepts this by allowing a job to run more than once.
Core entities
Capacity estimate
Arithmetic ahead. 10 million jobs per day are 116 per second on average, which 1 replicated job store handles.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Jobs per second, average | 10000000 ÷ 86400 | 116 |
| Jobs per second, at a peak of 10 times the average | 116 × 10 | 1160 |
| Storage for 30 days | 10000000 × 30 × 1 KB | 300 GB |
The API
A worker takes a job by creating a lease, which is why the request is a POST to the
collection of leases, and it reports by writing the result that belongs to its lease,
which is a PUT to an address that names the job and the lease.
Submit a job
POST /v1/jobs HTTP/1.1
Host: jobs.example
Content-Type: application/json
{ "kind": "resize-image", "payload": { "image": "cat.jpg" }, "run_at": "2026-10-14T09:00:00Z" }
HTTP/1.1 201 Created
Location: /v1/jobs/8231
Content-Type: application/json
{ "id": 8231, "state": "waiting", "attempts": 0 }
Location gives the URL of the new job, as HTTP expects after a 201.
Take a job: create a lease
POST /v1/leases HTTP/1.1
Host: jobs.example
Content-Type: application/json
{ "worker": "worker-2", "seconds": 30 }
HTTP/1.1 201 Created
Location: /v1/leases/771
Content-Type: application/json
{ "lease": 771, "job": { "id": 8231, "kind": "resize-image", "payload": { "image": "cat.jpg" } }, "expires_in": 30 }
A worker takes a job by creating a lease, so the path names a thing and the method says what happens to it. The scheduler picks the job. When no job is waiting, the answer is 204 No Content.
Report the result
PUT /v1/jobs/8231/results/771 HTTP/1.1
Host: jobs.example
Content-Type: application/json
{ "outcome": "succeeded" }
HTTP/1.1 204 No Content
The lease number proves that this worker still holds the job. A report with an expired lease is answered 409 Conflict.
Workers ask the scheduler for jobs, and the scheduler does not push jobs to workers. A worker that asks is by that fact ready to work, and one that is overloaded or shutting down merely stops asking, so that no separate mechanism is needed for the scheduler to learn the capacity of each worker.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Scheduler | accepts jobs and hands out leases | none of its own | another scheduler node serves the API |
| Job store | holds every job and its state | the jobs, replicated | a follower takes over, as in chapter 4 |
| Worker | takes a job, runs it, reports | none that must survive | its jobs return to the queue when their leases end |
| Reclaimer | returns jobs with expired leases to the queue | none | leases are reclaimed late, by the next reclaimer run |
One request, step by step
The steps follow the arrows of figure 12.7.
- A developer sends
POST /v1/jobs. The scheduler checks the request and gives the job an id. - The scheduler writes the job to the job store in the state waiting. The store keeps it on a majority of its nodes, as in chapter 3.
- A worker sends
POST /v1/leases. The scheduler picks a waiting job, marks it running with this worker and a deadline, and returns it. - Every few seconds the reclaimer looks for running jobs whose deadline has passed and sets them back to waiting.
The design, deployed
The scheduler nodes hold no state and stand behind a pair of load balancers, through which both submitters and workers reach them. All state is in the job store, a leader with 2 followers in 3 zones. The reclaimer, which returns jobs with expired leases to the queue, runs as 2 instances of which 1 is active, chosen through the lock service of chapter 10, and since reclaiming is itself a conditional write, a moment in which both believe themselves active does no harm. The 200 workers are the only tier whose size follows the amount of work, and it can be changed freely.
How real systems do it
Google has published an account of its distributed cron service that agrees with this chapter on the essentials and is frank about the same limits. The service keeps its state in a store replicated by Paxos, and only the leader launches jobs. The authors state that the system cannot guarantee that a job is launched exactly once, since the leader may fail between launching and recording the launch, and that the owners of each job must therefore decide whether it is worse for the job to be skipped or to run twice. They recommend making launches idempotent, and naming each launch after its scheduled time so that a repeat can be recognised, as this chapter does.
The remedy for many workers competing for the same waiting job, a query that passes
over rows locked by other transactions, is the clause SKIP LOCKED, which
PostgreSQL has offered since version 9.5 and which is the basis of several job
queues built on that database. For keeping very large numbers of timers in memory,
the classical structure is the timing wheel, in which timers are placed in slots by
their expiry time and a pointer advances 1 slot at each tick, so that starting and
stopping a timer take constant time.
A more recent development goes beyond anything in this chapter. Systems such as Temporal, which descends from Uber’s Cadence, record every step of a long-running piece of work in a durable history, and recover a failed worker by replaying that history to the point it had reached. A job in such a system is a program that appears to run for days without interruption, and the leases, retries and idempotency of this chapter are handled beneath it. The underlying guarantee is unchanged: each step runs at least once, and the steps must tolerate repetition.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Marking a job as running with no deadline | A worker that crashes keeps the job for ever. | Lend the job under a lease, and requeue it when the lease runs out. |
| Assuming a job runs exactly once | A slow or disconnected worker and its replacement both run it. | Write jobs to be idempotent. |
| Finding a waiting job and marking it in 2 steps | 2 workers take the same job. | Make the change conditional on the job still being waiting. |
| Accepting a result from any worker | A late worker overwrites the state set by its successor. | Accept a result only under the current lease. |
| Retrying at once | Jobs that failed because a service is overloaded keep it overloaded. | Wait longer after each failure, with a random addition. |
| Retrying without limit | A job that can never succeed occupies workers and hides a defect. | Move it to the dead state after a fixed number of attempts, and raise an alert. |
| A fixed lease shorter than the job | The job is reissued while it is still running, every time. | Renew the lease while the job makes progress. |
| Each scheduler node creating recurring jobs | Every run is created once per node. | Name each run after its scheduled time and create it only if absent. |
| No stated policy for missed runs | After an outage, runs are silently skipped or all fire together. | Decide for each recurring job whether to run once, run all, or skip. |
| Schedules in local time | A run is skipped or doubled when the clocks change. | Use coordinated universal time. |
| Strict priorities | Low-priority jobs never run. | Let waiting raise a job’s priority, or reserve workers for each level. |
| 1 queue for all customers | A large customer delays everyone. | Keep a queue per customer and serve them in rotation. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A worker crashes during a job | the job finishes later | its lease ends and another worker takes the job |
| A worker stalls and finishes late | nothing, if the job is safe to repeat | its late report is refused |
| A job fails every time | the job stops after 5 attempts | it is marked failed for a person to inspect |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Leases with deadlines | no job is stuck with a dead worker | a slow worker can cause a second run |
| Workers pull jobs | a worker takes work only when it has room | a job waits until some worker asks |
| At-least-once execution | no job is lost | jobs must be safe to repeat |
The simulation
Simulation in the repository. The program code/jobsim simulates this design. To run it, clone the repository and type cargo run --release -p jobsim in the code directory.
References
- S. Davidovic and K. Guliani, “Reliable Cron across the Planet”, ACM Queue, 2015.
- PostgreSQL documentation, “SELECT”, on
FOR UPDATE SKIP LOCKED. - G. Varghese and A. Lauck, “Hashed and Hierarchical Timing Wheels”, SOSP 1987.
- Temporal documentation, “Workflow execution” and “Event history”.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Job | a piece of work that is recorded now and carried out later |
| Worker | a process that takes jobs and runs them |
| Lease | the loan of a job to a worker until a deadline |
| Attempt | 1 run of a job by a worker |
| Idempotent | having the same effect whether performed once or several times |
| Exponential backoff | waiting longer after each successive failure |
| Dead job | a job that has used up its attempts and is kept for inspection |
| Delayed job | a job that is not to run before a given time |
| Recurring job | a rule from which a job is created at each scheduled time |
| Priority | a rank that decides which waiting job is handed out first |
| Starvation | low-priority jobs never running because higher ones keep arriving |
Summary
- A job is always in 1 of 4 states, and the design consists in making each transition between them safe.
- A job is lent to a worker under a lease, so that the job of a crashed worker returns to the queue.
- A lease can run out while its worker is still running the job, so a job may be executed more than once and has to be idempotent.
- A job is taken with a single conditional change, and a result is accepted only under the current lease.
- Failed jobs are retried after increasing pauses, and jobs that keep failing are set aside as dead.
- Due jobs are found through an index on their time, and recurring jobs are created under a name that makes a second creation fail.
- Priorities need protection against starvation, and customers need separate queues to be treated fairly.
Notification Service: System Design
Nearly every service in a large system has occasion to tell a person something. The order service wishes to say that a parcel has been shipped, the security service that a login was attempted from a new device, the billing service that a payment has failed. If each of these services dealt with email, text messages and push notifications for itself, each would need to know the user’s preferred channel and language, to hold credentials for the outside companies that carry the messages, to handle their failures, and to refrain from disturbing the user at 3 in the morning. A notification service gathers all of this into 1 place. Other services tell it what should be said and to whom, and it sees to the saying.
The peculiar character of this system comes from the fact that its most important work is done by parties it does not control. An email is delivered by an email provider, a text message by a telephone network, a push notification by the company that makes the phone’s operating system. These providers are slow at some times and unavailable at others, they impose limits on how fast they may be called, and they reply to a request in ways that leave real doubt about what has happened. The design of a notification service is therefore, to a large extent, a study in calling an unreliable dependency well: when to try again and how long to wait, when to stop trying, how to avoid sending the same message twice, and how to avoid making a struggling provider’s condition worse.
This chapter covers
- the path of a notification, from a request to a phone
- why a request is accepted first and sent later
- repeated requests, and the key that identifies them
- failures that will pass and failures that will not
- retries with pauses that double, and why the pauses are made random
- the circuit breaker, which stops calling a provider that is down
- the limits that providers impose, and a second provider as a fallback
- the difference between sent and delivered
- preferences, quiet hours, priorities, and sending to millions at once
The path of a notification
A notification passes through a sequence of decisions before anything is sent, and laying them out in order shows where the service’s responsibilities lie.
A calling service submits a request that names a user, a kind of message and the data to be inserted into it, for example “tell user 42 that order 7 has shipped”. The notification service first establishes that it has not already received this request. It then consults the user’s preferences to learn whether the user wishes to receive messages of this kind at all and by which channel. It finds the address for that channel, which may be an email address, a telephone number, or the device token by which a push provider identifies a particular installation of an app on a particular phone. It selects the template for this kind of message in the user’s language and fills in the data. Only then is there something to send, and it is placed in a queue from which a pool of senders takes messages and hands them to the appropriate provider.
The division into a part that accepts requests and a part that sends them, with a
queue between, is the central structural decision, and it follows from the first
section of chapter 11. A provider may take several seconds to answer, or may not
answer at all. If the calling service had to wait for the provider, an outage at a
text message provider would make the order service slow, which is exactly the coupling
a notification service exists to remove. The service therefore answers the caller as
soon as the request has been stored durably, with the status 202 Accepted, whose
meaning in HTTP is precisely “received, and not yet acted upon”, and does the sending
afterwards.
The same request twice
The calling service is itself a distributed program and will sometimes send the same
request more than once. It may time out while waiting for the 202 and try again, or
it may be a consumer of a message queue that, as chapter 11 explained, delivers its
messages at least once. From the user’s side the result of treating both requests as
new would be 2 identical text messages, which is a visible defect and, for some kinds
of message, an alarming one.
The notification service cannot recognise a repeat by comparing contents, since 2
separate and legitimate notifications may have identical text. The caller must
therefore say which requests are the same, and does so by attaching an idempotency
key, a name of the caller’s own choosing that identifies the event being reported,
such as order-7-shipped. The service records every key it has accepted, and when a
request arrives with a key that is already recorded, it returns the original response
and sends nothing.
The recording of the key and the placing of the message in the queue must succeed or fail together. If the key were recorded and the service then crashed before queueing the message, the caller’s retry would be dismissed as a repeat and the notification would never be sent. This is the problem of writing to a database and publishing a message as 1 step, and the outbox of chapter 11 solves it here as it did there. Keys need not be kept for ever. A retry arrives within seconds or minutes of the original, and a key that is retained for a day covers every realistic case.
Failures that pass and failures that do not
When a sender hands a message to a provider, the provider’s reply falls into 1 of 3 classes, and the proper response is different for each.
The provider may accept the message. The sender records the fact and is finished.
The provider may refuse the message for a reason that is permanent. The telephone number does not exist, the email address is malformed, or the device token belongs to an app that the user has since removed. Sending the same request again will yield the same refusal, so the sender must not retry. It should record the failure and, in the case of an invalid address or token, mark it so that no further notifications are attempted there. Continuing to send to dead addresses is more than wasteful: providers of email in particular observe what proportion of a sender’s messages are undeliverable and begin to treat a sender with a high proportion as a source of unwanted mail, to the detriment of its deliverable messages.
Or the provider may fail in a way that is probably temporary. It answers that it is overloaded, or that the sender has exceeded its permitted rate, or it does not answer within the time allowed. These are the cases for trying again, and they include the 1 that is most awkward. When a request times out, the sender does not know whether the provider never received it or received it, sent the message, and lost only the reply. A retry in the second case sends the user a duplicate. Where the provider’s API accepts an idempotency key of its own, the sender should supply one, so that the provider can recognise the retry, and where it does not, an occasional duplicate after a timeout is a risk that has to be accepted, since the alternative is an occasional message that is never sent.
| The provider’s reply | What it means | What the sender does |
|---|---|---|
| Accepted | the provider has taken the message | records success |
| Refused: invalid address, unknown token, malformed request | repeating the request cannot help | records failure, marks the address, does not retry |
| Overloaded, rate exceeded, internal error | the condition will probably pass | retries after a pause |
| No reply in time | unknown: the message may or may not have gone | retries after a pause, with the provider’s own idempotency key if it has one |
Retrying with pauses that double
Having decided to retry, the sender must decide when. The answer that comes first to mind, immediately, is the worst available.
A provider that reports overload is receiving more requests than it can handle. A sender that responds by repeating its request at once has increased the load on a system that has just said it has too much, and if a thousand senders do the same, the provider is held in its overloaded condition by the very traffic that its failures generate. Systems have been kept down for hours in this way after the original cause of the trouble had gone.
The sender should therefore wait, and the question of how long is settled by the observation that each successive failure is evidence that the previous wait was too short. The wait is accordingly doubled after every failure: 1 second, then 2, then 4, then 8. This is exponential backoff. It has the property that a brief disturbance is overcome quickly, by the first or second retry, while in a prolonged outage the sender’s requests become rapidly less frequent and soon impose almost no load.
The simulation for this chapter sends 5 notifications to a provider that fails a set number of times for each. One succeeds at the first try. One fails twice and is sent on the third try, 3 seconds after the first, and one fails 4 times and is sent on the fifth, after 15 seconds, as in the picture. One fails every time and is set aside after 5 tries. The remaining request carries a key that has been seen before and causes nothing to be sent.
2 limits complete the scheme. The pause is capped at some maximum, a minute for instance, so that it does not grow to absurd lengths. And the number of tries is bounded, so that a notification which can never be sent leaves the system. What should be done with a notification that has exhausted its tries depends on its kind. A code for logging in is worthless after a few minutes and may be dropped, whereas a notice that a payment has failed should be set aside, in the manner of the dead-letter queue of chapter 11, and looked at by a person.
Why the pauses are made random
Doubling the pause protects a provider from a single sender. It does not protect the provider from many senders that failed at the same moment, which is the usual situation, since a provider’s difficulty affects all of its callers together.
Suppose that 1000 notifications fail in the same second. All 1000 wait 1 second and return together, and if the provider has not recovered they fail together, wait 2 seconds, and return together again. The doubling has spaced the waves further apart and has done nothing to diminish them: each wave strikes the provider with the full 1000 requests, and a provider that is on the point of recovering is knocked down afresh by each.
The remedy is to make each sender’s pause random, which is called jitter. In the simplest effective form, where the plain scheme would wait 4 seconds, the sender waits for a time chosen at random between 0 and 4 seconds. The 1000 retries are then spread evenly over the interval and reach the provider as a steady flow of about 250 per second. The average wait has been halved, and the peak load on the provider has been reduced by a far larger factor. This thundering herd and its cure appeared in chapter 7 from the other side, as the advice a rate limiter gives its clients.
The circuit breaker
Backoff governs the retries of a single notification. It leaves untouched a further problem, which arises when a provider is not merely slow but down. Every new notification that arrives is then tried for the first time, and each such first try waits for the full length of the timeout before failing. Senders are occupied with calls that cannot succeed, the queue lengthens behind them, and notifications for other channels, whose providers are perfectly healthy, are delayed because all the senders are waiting on the provider that is not.
A circuit breaker is a small mechanism that remembers the recent behaviour of a dependency and, when the dependency is plainly failing, stops calling it for a time. It takes its name from the electrical device that cuts off a circuit in which too much current is flowing.
The breaker has 3 states. While it is closed, calls pass through to the provider and the breaker counts consecutive failures. When the count reaches a threshold, the breaker becomes open, and in that state it rejects every call at once without contacting the provider. After a set interval it becomes half open and admits a single trial call. If the trial succeeds, the breaker closes and normal operation resumes, and if the trial fails, the breaker opens again for another interval.
2 benefits follow. The senders fail fast, in microseconds, where they would otherwise have waited out a timeout of many seconds, and so remain free to serve the other channels. And the failing provider is relieved of all traffic but the occasional trial, which gives it the best chance of recovering. A notification rejected by an open breaker has not been lost. It returns to the queue to be tried later, or is directed to another provider.
Providers’ limits and a second provider
Providers restrict the rate at which they may be called, and a sender that exceeds the
permitted rate is refused, usually with the status 429 that chapter 7 described. A
well-behaved notification service does not discover the limit by running into it. It
paces its own calls to each provider slightly below that provider’s limit, using the
leaky bucket of chapter 7, which is the algorithm designed for exactly this purpose of
feeding a consumer at a steady rate. Notifications that arrive faster than the
provider will take them wait in the queue, which is what the queue is for.
For channels on which delivery matters greatly, such as the text messages that carry login codes, a service commonly holds accounts with 2 providers. When the circuit breaker for the first is open, messages are directed to the second. This raises the availability of the channel considerably and brings a small complication with it: a message that timed out at the first provider and was then sent through the second may arrive twice, since the 2 providers know nothing of each other and cannot recognise the duplicate.
Sent is not delivered
When a provider accepts a message, it has undertaken to try to deliver it, and nothing more. The email may yet be rejected by the recipient’s mail server, the text message may wait for a phone that is switched off, and the push notification may be addressed to an app that was removed last week. None of this is known at the moment the provider replies to the sender.
Providers report the later fate of a message after the event, by calling an address that the notification service has registered with them. Such a call from a provider back to its customer is known as a webhook. The service must be prepared to receive these reports at any time, in any order, and more than once, and uses them to bring the recorded state of each notification up to date. They are also the principal means by which the service learns that an address has gone bad, so that it can stop sending to it.
The practical consequence is that a notification service should be careful in what it claims. It can truthfully state that a message was handed to a provider. Whether the message was delivered is known only later and only for some channels, and whether it was read is hardly ever known. A system that requires certainty that a person has seen something, such as an alert to an engineer on call, cannot rely on a notification alone. It must require an explicit acknowledgement from the person and escalate to someone else when none arrives.
Preferences, quiet hours and priorities
Not every notification that is requested ought to be sent, and the service is the place where this is decided.
Users choose which kinds of message they wish to receive and by which channels, and in many countries the law requires that every promotional message offer a means of refusing further ones and that the refusal be honoured. The check against preferences belongs in the notification service itself, where no calling service can omit it by oversight. Users may also set hours during which they are not to be disturbed, and a message that falls within them is held and released when they end, unless it is of a kind, such as a security alert, that overrides them.
A limit on frequency protects users from the combined enthusiasm of many calling services, each of which considers its own messages important. A user who receives 30 notifications in a day will turn all of them off, the important ones included, and so the service limits the number of non-urgent notifications per user per day and discards or combines the excess.
Notifications differ enormously in urgency. A login code is useless unless it arrives within seconds, and a weekly digest may arrive at any time today. If both wait in the same queue, a campaign that addresses 10 million users will delay every login code until it has been worked through. The service therefore keeps separate queues for separate priorities, each with senders of its own, so that the bulk traffic cannot occupy the capacity on which urgent traffic depends.
Sending to very many users
A single request sometimes asks that millions of users be notified, of a change to the terms of service for example. Treated naively as 1 unit of work, it would occupy a sender for hours and would have to begin again from the start if that sender failed.
The request is fanned out. A first stage divides the list of recipients into batches of perhaps 1000 and places 1 message per batch in the queue, and a second stage expands each batch into individual notifications. The work is thereby spread across all the senders, a failure affects 1 batch only, and the progress of the whole is the number of batches completed. Each individual notification is given an idempotency key derived from the campaign and the user, so that a batch which is processed twice does not send anything twice. Such campaigns are sent at low priority and paced over minutes or hours, both to respect the providers’ limits and because a notification that brings a million users to the app within the same minute is an overload of one’s own making.
Use cases
Other services ask for notifications to be sent. Users decide which they are willing to receive.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A service can ask for a notification to a user on a channel: email, text message or push. |
| 1 | A request carries a key. A second request with the same key sends nothing. |
| 2 | A failed send is tried again, up to 5 times, with a longer pause each time. |
| 3 | A notification that fails 5 times is set aside and reported. |
| 4 | A user’s choice of channels is respected. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Delivery | At least once to the provider, and once per key from the caller’s point of view. |
| 1 | Latency | A notification leaves within 5 seconds of the request when the provider is healthy. |
| 2 | Consistency | Strong for the set of keys already seen. Eventual for delivery state. |
| 3 | Scale | 50 million notifications per day. |
The guarantee offered to callers is that an accepted request leads to at least 1 attempt to hand the message to a provider, and that a request repeated with the same key leads to no more than the first one did. The record of keys must be strongly consistent, since 2 nodes that disagreed about whether a key had been seen would between them send a duplicate. The state of a notification, by contrast, is eventually consistent with reality, since delivery is learned of after the fact, from the provider’s reports.
Core entities
Capacity estimate
Arithmetic ahead. 50 million notifications per day are 580 per second on average, and the queue absorbs peaks of 10 times that.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Per second, average | 50000000 ÷ 86400 | 579 |
| Per second, peak | 579 × 10 | 5790 |
| Senders, at 50 sends per second each | 5790 ÷ 50 | 116 |
The API
A request creates a notification. The reply does not say that anything was sent, only that the request has been stored and will be acted upon.
Send a notification
POST /v1/notifications HTTP/1.1
Host: notify.example
Idempotency-Key: order-7-shipped
Content-Type: application/json
{ "user": "user-42", "channel": "email", "body": "Your order has shipped." }
HTTP/1.1 202 Accepted
Location: /v1/notifications/5512
Content-Type: application/json
{ "id": 5512, "state": "queued" }
202 Accepted says that the request was taken and the work will happen later.
The same request again
POST /v1/notifications HTTP/1.1
Host: notify.example
Idempotency-Key: order-7-shipped
Content-Type: application/json
{ "user": "user-42", "channel": "email", "body": "Your order has shipped." }
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 5512, "state": "sent" }
Our service returns the same id and sends nothing.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| API | accepts requests and drops repeats by key | the keys seen, replicated | another API node serves requests |
| Queue | holds notifications until a sender takes them | the messages, as in chapter 11 | a follower broker takes over |
| Sender | calls the provider and retries | none | its message is delivered again to another sender |
| Provider | an outside email, text or push service | outside our control | senders retry, then set the message aside |
One request, step by step
The steps follow the arrows of figure 13.7.
- A service sends
POST /v1/notificationswith the headerIdempotency-Key: order-7-shipped. - The API looks the key up. A key it has seen gets the earlier answer and nothing else happens. A new key is recorded, and the notification goes into the queue.
- A sender takes the notification from the queue.
- The sender calls the provider. On failure it waits and tries again, with a pause that doubles.
- After 5 failed tries the sender moves the notification to a separate list for a person to look at.
The design, deployed
The deployment falls into 2 halves joined by queues. The upper half, the API nodes with their key store, must always be available, since it is what the calling services depend on, and it does very little. The lower half, the senders, is where the slow and uncertain work is done, and it is divided into separate pools for each channel and each priority, so that a pool blocked on a failing provider cannot starve the others. The providers at the bottom are outside the system, and for the channel that matters most there are 2 of them.
How real systems do it
The treatment of retries in this chapter follows an article from Amazon that has become the standard reference. It simulates many clients contending for a resource and compares several ways of randomising the pause. The form used in this chapter, a random time between 0 and the full exponential value, is the one the article calls full jitter, and it found that this did the least total work, with a variant that keeps half of the pause fixed close behind and a third variant, in which each pause is derived from the previous one, somewhat worse. The author has since added the caution that backoff and jitter spread a burst of load out and do not reduce a sustained overload, for which the load itself must be reduced.
That further step is the retry budget. Google’s book on site reliability engineering recommends that a client limit its retries to a fixed proportion of its ordinary requests, 10% for example, in addition to a limit for each request, so that when a dependency is failing completely, the retries add a tenth to the load, where unlimited retries would multiply it. A circuit breaker achieves a similar end by different means.
The circuit breaker was described by Michael Nygard in his book on production failures and was made widely known by a library from Netflix. The same book treats its companions, the timeout and the bulkhead, which is the separation of pools used in the deployment above.
For push notifications, the providers are Apple’s and Google’s services, each of which reports a token that is no longer valid in its reply to a send, so that sending is also the means by which dead tokens are discovered. Most providers of email and text messages report delivery by webhook, and they retry their webhooks for hours when the receiver is down, so that the receiver must be idempotent, in a reversal of the usual roles.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Calling the provider while the caller waits | A slow provider makes every calling service slow. | Store the request, answer 202, and send from a queue. |
| No idempotency key | A caller’s retry sends the user the same message twice. | Require a key and record the keys accepted. |
| Recording the key and queueing the message as 2 steps | A crash between them loses the notification, and the retry is dismissed as a repeat. | Do both in 1 transaction, through an outbox. |
| Retrying every failure | Invalid addresses are tried again and again, and the sender’s reputation suffers. | Retry only failures that may pass, and mark addresses that are permanently refused. |
| Retrying at once | Retries keep an overloaded provider overloaded. | Double the pause after each failure. |
| The same pauses for everyone | Senders that failed together return together, in waves. | Choose each pause at random within its range. |
| Retrying without limit | A notification that can never be sent circulates for ever. | Stop after a fixed number of tries and set it aside. |
| No circuit breaker | Senders wait out timeouts on a dead provider, and healthy channels are delayed. | Stop calling a provider after repeated failures, and test it with single trial calls. |
| Reporting “delivered” when the provider accepts | Users are told of messages that never arrived. | Record “handed to the provider”, and update from the provider’s reports. |
| 1 queue for everything | A large campaign delays login codes. | Keep separate queues and senders for each priority. |
| Checking preferences in each calling service | A service forgets, and a user who refused messages receives them. | Check preferences in the notification service. |
| Sending a campaign as fast as possible | The providers refuse the excess, and the users who respond overload the app. | Pace campaigns over time at low priority. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| The provider is down | notifications arrive late | senders retry with growing pauses; the queue holds the backlog |
| A sender crashes after the provider accepted a message | the user may get that message twice | the queue redelivers; providers that accept a key drop the repeat |
| A caller retries its request | nothing | the idempotency key identifies the repeat |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| A queue between API and provider | callers are not slowed by the provider | delivery is no longer immediate |
| Retries | brief failures are invisible | a message can be sent twice without a key on the provider’s side |
| Setting aside after 5 tries | a bad message does not block the others | someone has to look at what was set aside |
The simulation
Simulation in the repository. The program code/notifysim simulates this design. To run it, clone the repository and type cargo run --release -p notifysim in the code directory.
References
- M. Brooker, “Exponential Backoff And Jitter”, AWS Architecture Blog, 2015.
- M. Brooker, “Timeouts, retries, and backoff with jitter”, Amazon Builders’ Library.
- B. Beyer and others (editors), Site Reliability Engineering, O’Reilly, 2016, chapter 22, “Addressing Cascading Failures”.
- M. Nygard, Release It!, Pragmatic Bookshelf, second edition, 2018.
- RFC 9110, HTTP Semantics, 2022, for status 202.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Notification | a message from a system to a person |
| Channel | the means of delivery: email, text message or push notification |
| Provider | an outside company that carries messages on a channel |
| Device token | the identifier by which a push provider addresses 1 installation of an app |
| Template | the text of a kind of message, with places for data to be inserted |
202 Accepted | the HTTP status for a request that has been received and not yet acted upon |
| Idempotency key | a name the caller gives a request, so that a repeat is recognised |
| Exponential backoff | waiting twice as long after each successive failure |
| Jitter | making each pause random, so that retries do not coincide |
| Circuit breaker | a mechanism that stops calls to a failing dependency for a time |
| Closed, open, half open | the breaker’s states: passing calls, refusing calls, and admitting 1 trial |
| Webhook | a call from a provider back to its customer to report an event |
| Fan-out | turning 1 request into many individual messages |
| Quiet hours | a period in which a user is not to be disturbed |
Summary
- A notification service accepts a request, stores it, answers
202, and sends from a queue, so that slow providers do not slow down the callers. - A caller identifies each request with an idempotency key, and a repeated key sends nothing.
- A provider’s refusal is either permanent, in which case retrying is harmful, or temporary, in which case the sender tries again.
- The pause before each retry doubles, so that a failing provider receives less and less traffic.
- The pauses are random, so that senders which failed together do not return together.
- A circuit breaker stops calls to a provider that is down and tests it with single trial calls.
- Acceptance by a provider is not delivery, which is learned of later from the provider’s reports.
- Preferences, limits on frequency and priorities are enforced in the service, and very large sends are fanned out in batches and paced.
URL Shortener: System Design
A URL shortener accepts a long web address and returns a short one, such as
sho.rt/4C92, which leads to the same place. Anyone who opens the short address is
redirected to the long one. The service does 2 things only, creating a link and
following a link, and its data is a single table with 2 columns. It is nevertheless a
standard exercise in system design, and deservedly so, because within that small
compass it raises several questions that recur in much larger systems: how to generate
identifiers that are short, unique and hard to guess; how to serve a load that
consists almost entirely of reads; how to count events without slowing down the
operation being counted; and how to run a public service that people will certainly
try to misuse.
The quantities involved shape every decision. Links are created at a modest rate and followed at a far higher one, typically a hundred times higher, and a link once created is never changed. A system with these characteristics is designed around its read path, which must be fast and must stay available, while the write path may be slower and may, if necessary, be briefly unavailable without great harm. A link that fails to open is a failure visible to everyone who was sent it, and a link that cannot be created for a minute inconveniences 1 person.
This chapter covers
- how long a code has to be
- 3 ways of making a code: from a hash, at random, and from a counter
- writing a number in base 62, step by step
- why codes from a counter can be guessed, and how to prevent it
- the 2 kinds of redirect, and what each means for speed and for counting
- a read path built from a cache and a key-value store
- counting clicks without writing on every redirect
- custom names, expiry, and the same address submitted twice
- the misuse of a public redirect service
How long a code must be
The code is the part of the short address that follows the domain, and its length is the first thing to be settled, since shortness is the product.
A code is a string over some alphabet, and the number of distinct codes of a given length is the size of the alphabet raised to that length. The customary alphabet consists of the 10 digits, the 26 capital letters and the 26 small letters, 62 characters in all, each of which may appear in a web address without special encoding. With 62 characters, codes of length 6 number about 57 billion and codes of length 7 about 3.5 trillion.
The service of this chapter expects 100 million new links per month, or 6 billion in 5 years. Codes of 6 characters would suffice for that in principle, with about 10 times as many codes as links. We choose 7, which provides nearly 600 times as many, for 2 reasons. The service should not approach exhaustion within its lifetime, since lengthening the codes later is disruptive. And, as will appear below, a code space that is sparsely occupied is considerably harder to explore by guessing.
Some alphabets omit characters that are easily confused in print or over the telephone, such as the digit 0 and the capital O, or the digit 1 and the small l. This reduces the alphabet to 58 or fewer characters and the number of codes accordingly. It is worth doing where links are likely to be read aloud or copied by hand.
Making a code
A code must be unique, because a code that referred to 2 addresses would send some visitors to the wrong one. There are 3 established ways of producing codes, and they differ in how uniqueness is obtained and in what the codes reveal.
From a hash of the address
The first idea is to compute a hash of the long address and take its first 7 characters. The method has the attraction of needing no state at all: the code is a function of the address, and any server can compute it.
Its defect is that 2 different addresses can produce the same 7 characters. This is the collision that chapter 8 calculated for random identifiers, and the same arithmetic applies: among n codes drawn from N possibilities, the expected number of colliding pairs is about n squared divided by twice N. The simulation for this chapter shortened 2 million addresses to 6 characters of a hash, a form that allows about 20 billion different codes, and 97 of the addresses received a code that another address already had.
A collision rate of 97 in 2 million sounds small, and a service cannot send even 1 visitor in 20000 to the wrong destination. Every newly computed code must therefore be checked against the store, and when it is found to be taken by a different address, the service must derive another, for instance by appending a counter to the address and hashing again. The method thus requires a read before every write and a loop for retries, and the collisions become more frequent as the store fills.
Deriving the code from the address has a further consequence that is sometimes wanted and sometimes not. The same address always yields the same code, so that 2 people who shorten the same address receive the same link, and neither can have statistics or an expiry date of their own.
At random
The second method draws 7 characters at random and checks whether the resulting code is already in use, drawing again if it is. As with the hash, a check before each write is unavoidable. With 6 billion links among 3.5 trillion codes, about 1 draw in 600 finds its code taken, so the retries are rare and remain rare for the life of the service.
Random codes have an important virtue, which is that they disclose nothing. A code says nothing about when its link was created or how many others exist, and knowing 1 valid code is of no help in finding another. The cost is the check, which on a replicated store must be an atomic “create if absent” of the kind described in chapter 10, since 2 servers might otherwise draw the same code at the same moment and both find it free.
From a counter
The third method abandons chance. Each new link is assigned the next number from a counter, and the code is that number written in a compact notation. Since no 2 links are given the same number, no 2 receive the same code, and uniqueness requires no check and no retry.
The compact notation is base 62, which uses the 62 characters of the alphabet as digits in the way that decimal notation uses 10.
The simulation confirms the round trip for a selection of numbers: 0 is written as
0, 61 as z, 62 as 10, a million as 4C92, and the largest number that fits in 7
characters, 3521614606207, as zzzzzzz, and each code reads back as the number from
which it came. It also shortens 2 million addresses by counter and finds no collision
among them, as must be the case.
The counter itself must be shared by all the servers that create links, and chapter 8 was devoted to that problem. The method of blocks suits this service well. Each web server obtains a block of, say, 1000 consecutive numbers from the counter and assigns them from memory, returning for another block when the first is used up. The counter is consulted once per 1000 links, and the numbers lost when a server restarts in the middle of a block are of no consequence when 3.5 trillion are available.
What a counter gives away
Codes that are consecutive numbers have a property that is unacceptable in a public service. They can be guessed.
Anyone who holds 1 link can obtain others by adding or subtracting 1, and can walk through the entire collection in order. Short links are routinely created for addresses that carry a secret within them, such as a shared document that anyone with the link may read or a page for resetting a password, on the assumption that the address cannot be found by a stranger. Sequential codes destroy that assumption. They also reveal the volume of the business, since the difference between a code created today and one created a week ago is the number of links made in the week.
The remedy keeps the counter and conceals its order. Before the number is written in base 62, it is passed through a fixed scrambling function that maps each number below 62 to the power 7 to a different number in the same range. Because the mapping sends distinct inputs to distinct outputs, the codes remain unique without any check, and because it is designed so that neighbouring inputs give unrelated outputs, consecutive links receive codes with no visible relation to one another. Such a function is in effect a small cipher with a secret key, and anyone without the key cannot recover the sequence.
Even with scrambled or random codes, a stranger can still try codes blindly, and the fraction of attempts that succeed equals the fraction of the code space in use. This is the second reason for preferring 7 characters to 6. With 6 billion links in 57 billion codes, 1 guess in 10 would find a link. With 3.5 trillion codes, 1 guess in 600 does, and a rate limit on requests for codes that do not exist, of the kind described in chapter 7, makes systematic exploration impractical.
| From a hash | At random | From a counter, scrambled | |
|---|---|---|---|
| Uniqueness | by check and retry | by check and retry | by construction |
| A read before each write | yes | yes | no |
| Shared state | none | none | a counter, consulted once per block |
| The same address twice | the same code | different codes | different codes |
| Can codes be guessed | no | no | no, while the key is secret |
The design of this chapter uses a counter with blocks and a scrambling step. It avoids the read before each write, and it never has to retry.
Following a link
When a visitor’s browser requests sho.rt/4C92, the service looks up the code and
replies with a redirect, a response that carries no page and names another
address to which the browser should go. HTTP provides 2 principal kinds, and the
choice between them is a real design decision with consequences for load and for
measurement.
A reply with status 301 Moved Permanently tells the browser that the short address
has been replaced by the long one for good. Browsers are entitled to remember this,
and they do: the next time the same person opens the same short link, the browser goes
directly to the long address without contacting the shortener at all. A reply with
status 302 Found states that the redirect is temporary, and the browser asks the
shortener again on each occasion.
301 Moved Permanently | 302 Found | |
|---|---|---|
| Repeat visits by the same browser | go straight to the destination | return to the shortener each time |
| Load on the shortener | lower | higher |
| Click counts | count each browser once | count every visit |
| Changing or disabling the link later | does not reach browsers that remembered it | takes effect immediately |
The last row is the one most often overlooked. A link that has been served with 301
cannot reliably be withdrawn, because browsers that have stored the redirect will
continue to follow it. A service that must be able to disable links, which, as the
section on misuse explains, every public shortener must, should prefer 302, or
should send 301 with a short limit on how long it may be remembered. Our design
sends 301 together with a header that limits the browser’s memory of it to a day,
which removes most repeat requests while ensuring that a disabled link stops working
within 24 hours.
The read path
Redirects outnumber creations by a hundred to 1 and must be answered in a few milliseconds, so the read path is built for speed in layers.
The mapping from code to address is a pure lookup by key, with no queries by any other attribute, and it is therefore kept in a key-value store of the kind built in chapters 1 to 6, divided among shards by a hash of the code. At 6 billion links of about 500 bytes each, the data amounts to 3 TB, which is too much for 1 machine and entirely ordinary for a sharded store.
In front of the store stands a cache, as described in chapter 9. Traffic to short links is extremely uneven: a link shared by a popular account may be opened a million times in an hour, while most links are opened a few times in their whole existence. A cache holding a small fraction of the links therefore answers the great majority of requests. Links are also the ideal content for a cache, because a link is never modified after creation, so that the problem of stale entries, which occupied much of chapter 9, scarcely arises. The 1 change that can occur is the disabling of a link, and it is handled by deleting the cache entry at that moment.
Requests for codes that do not exist deserve attention, since they are what an exploring stranger or a mistyped address produces. As chapter 9 explained under the name of cache penetration, such requests pass straight through an ordinary cache to the store. The service therefore caches the fact that a code is absent, for a short time, and limits the rate at which any 1 client may request absent codes.
The web servers themselves hold no data. Each request can be served by any of them, and the load balancer in front distributes requests without regard to who is asking. Servers can accordingly be added and removed freely, and the failure of 1 is of no consequence to visitors.
Counting clicks
Authors wish to know how often their links are followed. The obvious implementation, incrementing a counter in the database on every redirect, would convert the read path into a write path. Every redirect would cost a durable write, a hundred times more writes than link creation produces, all contending on the rows of the popular links.
The redirect and the count are therefore separated. On each redirect the web server appends a small record, consisting of the code and the time, to a message queue of the kind described in chapter 11, and replies to the visitor without waiting for anything further. A separate consumer reads these records and maintains the counts, typically adding up the clicks for each link over a minute and applying the sum in a single write. The visitor’s redirect is delayed by nothing, the store receives 1 write per link per minute in place of 1 per click, and if the counting consumer fails, redirects continue undisturbed and the counts are brought up to date when it returns.
The price is that the counts are eventually consistent. They lag behind reality by up to a minute, and since the queue delivers at least once, a consumer that crashes and repeats a batch may count some clicks twice unless it records which batches it has applied. For a statistic shown on a dashboard this is entirely acceptable. It would not be acceptable if clicks were being billed for, in which case each click record would need an identifier and the counting would have to be made idempotent in the manner of chapter 11.
Further features
Authors sometimes wish to choose the code themselves, as in sho.rt/spring-sale. A
custom name is stored in the same table as a generated code and must be reserved
with an atomic “create if absent”, since 2 authors may ask for the same name at the
same moment. Custom names and generated codes have to be kept from colliding, which is
most arranged by making them differ in form: generated codes are exactly 7
characters, and custom names are required to be longer or to contain a hyphen.
A link may be given an expiry, after which it ceases to work. The expiry time is stored with the link, and the web server compares it with the clock on each redirect, so that an expired link is refused from the correct moment even though its row has not yet been removed. A periodic job deletes expired rows later, at leisure. Codes of expired links should not be given to new links, since old messages and documents containing the code would then lead to an unrelated destination.
When the same long address is submitted twice, the service may return the existing link or create a new one. Returning the existing link saves storage and requires an additional index from addresses to codes, and it means that the 2 submitters share 1 link, with shared statistics. Most services create a new link each time, on the grounds that links belong to their authors and that storage is the cheaper resource.
Misuse
A service that will redirect anyone to anywhere is useful to people with bad intentions, and a design that ignores this will not survive contact with the public.
The principal abuse is concealment. A short link hides its destination, and so it is
used to disguise addresses that the recipient would not knowingly open: pages that
imitate a bank’s login form, or downloads of harmful software. Mail filters and
messaging services respond by treating all links of a shortener that carries much of
this as suspect, at which point the legitimate links of the service stop working in
practice. A public shortener therefore checks each submitted address against lists of
known harmful sites when the link is created, checks again periodically, since a
destination may turn harmful after the link was made, and provides a means of
reporting links. A link found to be harmful is disabled, and this is the reason the
service must retain the ability to disable a link promptly, which the earlier
discussion of 301 and 302 turned upon.
Creation must be rate limited, per account and per network address, to prevent a single party from generating links by the million. And the service must refuse to shorten its own short links, since chains of redirects are used to defeat the checks just described and a link that redirects to itself would loop.
Use cases
Authors create links and read their statistics. Visitors follow links, in far greater numbers.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | An author can create a short link for a URL and receives a code of 7 characters. |
| 1 | Opening a short link redirects the visitor to the URL. |
| 2 | A code, once given out, always leads to the same URL. |
| 3 | An author can read how often a link was opened. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Availability | 99.99% for redirects. A link that does not open is the worst failure. |
| 1 | Latency | A redirect takes under 10 ms. |
| 2 | Consistency | Strong for creating a link: each code belongs to exactly 1 URL. Eventual for click counts. |
| 3 | Scale | 100 million new links per month, and 100 redirects for every new link. |
The mapping from a code to its address must be strongly consistent in 1 specific respect: once a link has been created and its code returned to the author, every subsequent lookup must find it, since the author will share the code immediately. The key-value store provides this by confirming a write only when a majority holds it. Click counts, by contrast, are eventually consistent by design, as the section on counting explained.
Core entities
Capacity estimate
Arithmetic ahead. 5 years of links are 6 billion codes and 3 TB, and redirects average 3900 per second.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Links in 5 years | 100000000 × 12 × 5 | 6 billion |
| Codes of 7 characters | 62 to the power 7 | 3.5 trillion |
| Storage for links | 6 billion × 500 bytes | 3 TB |
| New links per second | 100000000 ÷ (30 × 86400) | 39 |
| Redirects per second | 39 × 100 | 3900 |
The API
Creating a link is an ordinary POST to the collection of links. Following one is a
GET on the shortest path the service can offer, the code itself.
Create a link
POST /v1/links HTTP/1.1
Host: sho.rt
Content-Type: application/json
{ "url": "https://example.com/a/very/long/address?with=parameters" }
HTTP/1.1 201 Created
Location: /v1/links/4C92
Content-Type: application/json
{ "code": "4C92", "short_url": "https://sho.rt/4C92" }
Its code is the base 62 form of the link’s id.
Open a link
GET /4C92 HTTP/1.1
Host: sho.rt
HTTP/1.1 301 Moved Permanently
Location: https://example.com/a/very/long/address?with=parameters
Cache-Control: max-age=86400
Browsers follow Location by themselves.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Web server | creates links and answers redirects | none | the load balancer uses the others |
| Cache | holds the most opened links | copies, in memory | its links are read from the store once more |
| Key-value store | maps a code to its URL | every link, replicated | a follower takes over, as in chapter 4 |
| Id generator | gives each new link a number | see chapter 8 | another id node is asked |
One request, step by step
The steps follow the arrows of figure 14.6.
- A visitor’s browser sends
GET /4C92. - The load balancer passes the request to a web server.
- The web server asks the cache for
4C92. Popular links are found here. - On a miss, it reads
4C92from the key-value store and puts the answer in the cache. - When an author creates a link, the web server takes the next number from the id generator and writes it in base 62.
- The visitor receives
301with the long address inLocation, and the browser goes there.
The design, deployed
Visitors reach a pair of load balancers in front of 12 web servers in 3 zones, any of which can answer any request. A redirect reads the cache and, on a miss, 1 of the 8 shards of the key-value store, each of which has a replica in every zone. The counter that supplies numbers for new links is consulted once for each block of 1000 and stands to 1 side. Below the line, the click queue and its counters form a separate tier, which a redirect writes to and never waits for. In front of all this, a service of this kind normally places the edge servers of a content delivery network, which can answer a popular redirect without reaching the origin at all.
How real systems do it
The published material on URL shorteners is thinner than on most systems in this book, since their operators have written little about them, and this section is accordingly short and confined to what can be supported.
The semantics of the redirects are fixed by the HTTP standard, which also defines 2
further status codes, 307 and 308. These are the temporary and permanent
redirects that additionally require the browser to repeat the request with the same
method, where 301 and 302 allow a POST to be turned into a GET. For a link
that is only ever followed with GET, the difference does not arise.
The scrambling of a counter described above is an instance of format-preserving encryption, a cipher whose output lies in the same range as its input. The construction generally used for small ranges is a Feistel network of a few rounds, and it should be distinguished from libraries that merely obfuscate numbers by a fixed shuffle of the alphabet, which resist casual inspection and do not resist analysis.
The alphabet of 58 characters that omits the easily confused ones is known as Base58, and Flickr has described using it for its short addresses. The checking of submitted addresses against lists of harmful sites is offered as a public service by the makers of the major browsers.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Codes cut from a hash, without a check | 2 addresses receive the same code, and some visitors reach the wrong page. | Check every code against the store, or use a counter. |
| Checking for a free code and then writing it | 2 servers find the same code free and both take it. | Use an atomic create-if-absent. |
| Codes that are consecutive numbers | Anyone can walk through all links by counting. | Scramble the number before encoding it, or use random codes. |
| Codes only just long enough | Blind guesses find real links often, and the codes run out. | Choose a length that leaves the code space sparsely used. |
301 with unlimited caching | A link that has been disabled keeps working in browsers that remembered it. | Use 302, or limit how long 301 may be remembered. |
| Incrementing a counter on every redirect | Every read becomes a write, and popular links contend. | Record clicks in a queue and count them in batches. |
| Not caching absent codes | Requests for nonexistent codes all reach the store. | Cache absence briefly, and rate limit such requests. |
| Giving the code of an expired link to a new link | Old references lead to an unrelated destination. | Do not reuse codes. |
| Accepting any address | The service carries links to harmful pages and is blocked by mail filters. | Check addresses against lists of harmful sites, and be able to disable a link. |
| Shortening the service’s own links | Redirects chain or loop. | Refuse addresses on the service’s own domain. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| The cache is lost | redirects are slower for a few minutes | links are read from the store and cached again |
| The id generator is unreachable | new links cannot be created; redirects work | another id node is used |
| A store leader fails | about 1 second without new links for its shard | a follower is elected |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Codes from a counter | no collisions and no retry | codes can be guessed in order |
| Cache in front of the store | most redirects avoid the store | more memory |
301 with a time limit | fewer requests reach us | click counts are approximate |
The simulation
Simulation in the repository. The program code/shortener simulates this design. To run it, clone the repository and type cargo run --release -p shortener in the code directory.
References
- RFC 9110, HTTP Semantics, 2022, sections on status codes 301, 302, 307 and 308.
- M. Bellare and others, “Format-Preserving Encryption”, Selected Areas in Cryptography, 2009.
- J. Black and P. Rogaway, “Ciphers with Arbitrary Finite Domains”, CT-RSA 2002.
- Flickr API documentation, “URLs”, on short addresses in base 58.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Short link | a short address that redirects to a longer one |
| Code | the part of a short link that identifies it |
| Base 62 | a way of writing numbers with 62 digits: 0 to 9, A to Z and a to z |
| Collision | 2 addresses receiving the same code |
| Redirect | a response that tells the browser to go to another address |
301 Moved Permanently | a redirect that the browser may remember |
302 Found | a redirect that the browser asks for again each time |
| Custom name | a code chosen by the author |
| Expiry | the time after which a link no longer works |
| Enumeration | finding valid codes by trying them in order |
Summary
- Codes of 7 characters over 62 symbols give 3.5 trillion possibilities, far more than the links expected, which keeps guessing unrewarding.
- Codes cut from a hash or drawn at random must be checked against the store before use, while codes from a counter are unique by construction.
- A counter’s numbers are written in base 62 after a scrambling step, since consecutive codes could be guessed.
301lets browsers skip the shortener on repeat visits, and302keeps every visit visible and every link revocable.- Redirects are served from a cache in front of a sharded key-value store, by web servers that hold no data.
- Clicks are written to a queue and counted in batches, so that a redirect never waits for a write.
- A public shortener checks destinations, limits creation, and can disable a link quickly.
News Feed: System Design
A news feed is the page that a member of a social service sees on opening it: the recent posts of the accounts that the member follows, gathered into a single list with the newest at the top. Described as a query, it is brief. Take the accounts this member follows, take their posts, sort by time, and return the first 20.
The difficulty is that this query must be answered for every member, at every visit, within a fraction of a second, and that its cost grows with numbers which have no natural bound. A member may follow thousands of accounts, an account may be followed by millions of members, and a busy service receives far more requests to read feeds than to write posts. A naive evaluation of the query touches the posts of every followed account on every visit and cannot meet these demands. The standard remedy is to do the work in advance, at the moment a post is written, and this in turn becomes ruinously expensive for the small number of accounts that have a very large following. The design of a feed is a matter of choosing, for each post, when the work of delivering it should be done, and this chapter develops the 2 pure answers and the mixture that practical systems adopt.
This chapter covers
- the feed as a query, and why it cannot be evaluated as written
- copying a post to every follower’s list when it is written
- collecting posts from every followed account when the feed is read
- the account with millions of followers, and the mixture of both schemes
- what a feed list contains, and how much of it is kept
- reading a feed in pages, with a cursor
- the delays and anomalies that members can see, and which of them matter
- following, unfollowing, deleting, and ranking by something other than time
The feed as a query
Suppose that posts are kept in 1 table and the relation of who follows whom in another. The feed of a member is then the result of joining the 2: all posts whose author is among the accounts the member follows, ordered by time, newest first, limited to the first page.
On a single database with a modest number of members, this query can be executed, and a service in its early days should do exactly that. Its cost is dominated by the number of accounts the member follows, since the recent posts of each have to be located before the newest overall can be chosen. For a member who follows 200 accounts this means 200 separate lookups in an index, or a scan that is equivalent to them, on every visit.
The arrangement breaks down as the service grows, for 2 reasons that reinforce each other. The data no longer fits on 1 machine, so that the posts of 200 authors lie on many different shards, and the query becomes 200 network requests whose results must be merged. And feeds are read very often. A member may publish 2 posts a day and open the feed 20 times, so that the expensive operation is also the frequent one. A design in which the common operation is costly and the rare one cheap has its costs the wrong way round, and the natural response is to move work from reading to writing.
Copying when a post is written
In the first scheme, every member has a feed list that is stored and kept ready. The list contains references to the posts the member should see, in order, and reading the feed consists of nothing more than fetching this list. The work is done at the time of writing. When an author publishes a post, the service looks up the author’s followers and adds a reference to the new post at the front of each follower’s list. Delivering 1 post to many lists in this way is called fan-out, and this scheme is fan-out on write, or informally the push model.
Reading under this scheme is as inexpensive as reading can be. A single lookup by the member’s identifier returns a list that is already assembled and already sorted, and the feed service has only to fetch the posts it refers to. The feed can be served in a few milliseconds regardless of how many accounts the member follows.
The cost has been transferred to publishing, where it is proportional to the number of followers. For an author with 200 followers, a post gives rise to 200 small writes, which is perfectly acceptable. The copying is not done while the author waits. The post is stored, the author receives a reply, and a task describing the copying is placed in a message queue, from which workers perform it in the background, in the manner of chapters 11 and 12. A follower’s list therefore receives the post some moments after it was published, and the feed is eventually consistent by construction.
2 further costs are less conspicuous. Lists are maintained for all members, including those who have not opened the service in months, so that much of the copying is done for readers who will never see its result. And the lists occupy memory in proportion to the number of members, which obliges the service to bound the length of each.
Collecting when the feed is read
The second scheme stores no feed lists at all. Each author’s posts are kept together, indexed by author and time, and publishing a post is a single write to the author’s own collection. The feed is assembled when it is requested: the service looks up the accounts the reader follows, fetches the recent posts of each, and merges the results by time. This is fan-out on read, or the pull model, and it is the query of the first section carried out against data arranged to make each of its parts efficient.
Publishing is now trivially cheap and its cost is independent of the size of the author’s following. No work is done on behalf of members who never read. A post is visible to its readers the instant it is stored, there being no copying to wait for. And when a member follows a new account or stops following one, the feed reflects the change immediately and without any repair, since it is computed afresh from the current list of followed accounts.
The cost is borne by every read, and is proportional to the number of accounts the reader follows. With 200 followed accounts, a visit to the feed requires 200 fetches, which can be issued in parallel and must nevertheless all be awaited, so that the response is as slow as the slowest of them. Since reads are far more numerous than writes, the total work is much greater than under the first scheme for a typical population of accounts.
The account with millions of followers
The choice between the 2 schemes would be straightforward, in favour of copying on write, if all accounts resembled one another. They do not. In every social service the number of followers is distributed with extreme inequality: the great majority of accounts have a few hundred followers or fewer, and a small number have millions.
For such an account, copying on write is a very different proposition. A single post by an account with 2 million followers gives rise to 2 million writes. Performed by a pool of workers at 10000 writes per second, this takes more than 3 minutes, during which some followers have the post and others do not, and the queue of copying tasks is occupied to the detriment of everybody else’s posts. If several such accounts post at about the same time, as they tend to during events of general interest, the backlog grows to many minutes.
The simulation for this chapter makes the imbalance concrete. It models 1000 ordinary accounts, each with 200 followers, publishing 2 posts and reading the feed 20 times a day, together with a single account that has 2 million followers and publishes 10 posts a day.
Under copying on write, the 1000 ordinary accounts cause 400000 copies a day between them, and the 1 large account causes 20 million. A single account is responsible for 98% of all the work in the system. Under collecting on read, the total is about 4 million fetches, nearly all of them attributable to the ordinary accounts’ 20000 daily reads of 200 lists each.
The figures suggest the remedy, which is to treat the 2 kinds of account differently. Posts by ordinary accounts are copied to their followers’ lists on writing, as in the first scheme. Posts by accounts whose following exceeds some threshold, 10000 in the simulation, are not copied at all. When a member’s feed is read, the service takes the member’s stored list and merges into it the recent posts of the few large accounts that the member follows, which it fetches at that moment as in the second scheme. In the simulation this mixture requires 400000 copies and 20005 fetches a day, about a fiftieth of the work of either pure scheme.
The mixture succeeds because it assigns each scheme to the case in which it is cheap. Copying on write costs in proportion to an author’s followers, and is applied where followers are few. Collecting on read costs in proportion to the number of accounts fetched, and is applied to the handful of large accounts that any 1 member follows. It helps, too, that the recent posts of a very large account are requested by so many readers that they are almost always in a cache, so that the additional fetches at reading time are inexpensive.
| Copy on write | Collect on read | Mixture | |
|---|---|---|---|
| Cost of publishing | 1 write per follower | 1 write | 1 write per follower, for ordinary authors only |
| Cost of reading | 1 list | 1 fetch per followed account | 1 list, and 1 fetch per large account followed |
| Delay before a post is visible | until the copying is done | none | none for large accounts, brief for others |
| Work for inactive members | wasted | none | wasted for ordinary authors’ posts only |
| After following or unfollowing | the list has to be repaired | correct at once | partly repaired, partly correct at once |
What a feed list contains
A feed list could in principle hold complete posts. It holds only references, each consisting of the identifier of a post and its time, about 16 bytes in all, and the posts themselves are stored once and fetched by identifier when a page of the feed is displayed. The reasons are those of any normalised design. A post with a million readers would otherwise exist in a million copies, and an edit or deletion would have to find and alter every one of them. With references, an edited post is changed in 1 place, and a deleted post is found to be absent when the feed service tries to fetch it and is omitted from the page.
The length of each list is bounded, commonly at a few hundred entries, the oldest being discarded as new ones arrive. Hardly any member scrolls back further than this, and for the rare one who does, the service falls back on collecting on read for the older portion. With 500 entries of 16 bytes for each of 10 million members, the lists occupy 80 GB, which is held in a cache spread over several nodes as described in chapter 9.
Because the lists are held in a cache, outside durable storage, a list can be lost, through the failure of a cache node or through eviction. This is acceptable only because a list can always be reconstructed from the durable data, by running the collect-on-read procedure once for that member and storing the result. The lists of members who have been inactive for some weeks may deliberately be allowed to lapse and be rebuilt in this way on their return, which recovers much of the wasted work noted above.
Reading a feed in pages
A feed is delivered in pages, and the method by which the client asks for the next page deserves more attention than it usually receives.
The obvious method is by position: the first request asks for items 1 to 20 and the second for items 21 to 40. This fails in a characteristic way on any list to which items are being added at the front, which a feed always is.
Every insertion at the front shifts all existing items down by 1 position, so that the boundary between pages moves under the reader’s feet and the second page begins with items from the end of the first. Deletions shift items the other way and cause items to be missed. The remedy is to ask for the next page by reference to an item, in the form “20 items older than the last one I have”. The identifier of that last item, returned by the service with each page for use in the next request, is called a cursor. Since it denotes a fixed point in the sequence, insertions before it have no effect on what follows it. A request by cursor is also cheaper for the store to answer, since an ordered index can be entered directly at the cursor, whereas a request for position 10000 obliges the store to count past 10000 entries. Chapter 1 used the same technique for listing keys.
What members can observe
Since the feed is eventually consistent, members can observe states of affairs that a strongly consistent system would exclude, and it has to be decided which of these are tolerable.
After publishing a post, an author expects to see it at once. Under copying on write, the author’s own list is updated by the same background process as everyone else’s, and a quick reload might not yet show the post, which authors interpret as its having been lost. The property that is wanted is called read your own writes, and it is usually secured for this 1 case by having the feed service add the author’s own most recent posts to the author’s feed at reading time, without waiting for the copying.
Followers receive a post at slightly different moments, as the copying proceeds. This passes unnoticed in itself and becomes visible through replies: a member may be shown a reply to a post that has not yet reached the member’s own list. Services deal with this by fetching the original together with any reply to it, so that the pair is always displayed together.
A subtler requirement is that a member who has seen a post should not find it gone on the next reload. This can occur when 2 successive requests are answered by 2 cache replicas of which the second is behind the first. Directing all of a member’s requests within a session to the same replica is generally sufficient to prevent it.
None of these anomalies does lasting harm, and that is the justification for choosing eventual consistency here. A feed that is a few seconds stale is still a perfectly good feed, whereas a feed service that refused to respond until every replica agreed would be unavailable on many occasions when a slightly stale answer would have served.
Following, deleting and ranking
Changes in who follows whom require the stored lists to be repaired. When a member follows a new account, that account’s recent posts should appear in the member’s feed, and so the service merges them into the member’s list as a background task. When a member stops following an account, the posts of that account should disappear, and the simplest method is not to remove them from the list and to filter them out at reading time, by checking each entry against the member’s current list of followed accounts.
The same filtering at reading time disposes of several other cases at once: posts that have been deleted, authors whom the reader has blocked, and posts withdrawn by moderators. A design that depended on locating and removing every copy of a reference from millions of lists would be slow and unreliable, while a check at the moment of display is immediate and certain. The general principle is that anything which must take effect at once should be enforced when the feed is read, and the stored lists should be regarded as a hint about what to show, subject to verification.
Finally, many services order the feed by an estimate of what the member will find interesting, and no longer strictly by time. This alters the pipeline less than might be supposed. The stored list becomes a pool of candidates, perhaps the 500 most recent references, and at reading time a ranking function assigns a score to each candidate and the highest are shown. Fan-out, storage and the treatment of large accounts are unchanged. What changes is that paging by cursor becomes harder, since the order is no longer fixed, and services commonly rank a batch of candidates once, at the start of a session, and page through that fixed result.
Use cases
A member publishes posts, reads the feed, and chooses whom to follow.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A member can publish a post. |
| 1 | A member can follow and unfollow an account. |
| 2 | A member can read a feed of recent posts from followed accounts, newest first, in pages. |
| 3 | A new post appears in followers’ feeds within 5 seconds. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Latency | Reading a feed takes under 200 ms. |
| 1 | Availability | 99.9% for reading feeds. |
| 2 | Consistency | Eventual. Followers may see a post a few seconds apart, and in slightly different order. |
| 3 | Scale | 10 million members, 20 feed reads for every post. |
The feed is eventually consistent, and deliberately so. A post may take some seconds to reach all of its author’s followers, and 2 followers may see it at different moments. The posts themselves and the relation of who follows whom are stored durably and consistently in a replicated store, and the feed lists are a derived and disposable view of that data, which can be rebuilt whenever they are lost.
Core entities
Capacity estimate
Arithmetic ahead. A feed entry is 16 bytes, so the newest 500 entries for 10 million members are 80 GB of cache.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Feed entry | post id 8 bytes + time 8 bytes | 16 bytes |
| Entries kept per member | the newest 500 | 500 |
| Feed cache | 10000000 × 500 × 16 bytes | 80 GB |
| Posts per day | 10000000 members × 2 | 20 million |
The API
Publishing creates a post in the collection of posts. Every member has a news feed,
so the feeds form a collection too, /v1/newsfeeds, in which a feed is identified
by its owner, and reading a feed returns a page of its posts together with the cursor
for the page that follows.
Publish a post
POST /v1/posts HTTP/1.1
Host: feed.example
Content-Type: application/json
{ "body": "Shipped the new release." }
HTTP/1.1 201 Created
Location: /v1/posts/90210
Content-Type: application/json
{ "id": 90210, "time": "2026-10-14T09:00:00Z" }
We store the post before the response and copy it to followers afterwards.
Read a page of the feed
GET /v1/newsfeeds/ada/posts?limit=2 HTTP/1.1
Host: feed.example
HTTP/1.1 200 OK
Content-Type: application/json
{
"posts": [
{ "id": 90210, "author": "ada", "body": "Shipped the new release." },
{ "id": 90188, "author": "grace", "body": "Found the bug." }
],
"next_after": "90188"
}
next_after is the id of the last post on the page.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Post service | stores a post and queues the copying | posts, in the key-value store | another instance serves requests |
| Fan-out workers | add a post to each follower’s list | none | their tasks are delivered again, as in chapter 11 |
| Feed cache | 1 list of recent post ids per member | lists, in memory | lists are rebuilt from the posts on the next read |
| Feed service | reads a member’s list and fetches the posts | none | another instance serves requests |
One request, step by step
The steps follow the arrows of figure 15.7.
- An author sends
POST /v1/posts. - The post service stores the post once and puts a small task on the queue: “post 90210 by ada”.
- A fan-out worker takes the task and looks up ada’s followers.
- For each follower, the worker adds the post’s id to that follower’s list in the feed cache.
- A reader sends
GET /v1/newsfeeds/{user}/posts. - The feed service reads the reader’s list, merges in recent posts of any very large accounts the reader follows, and fetches the posts themselves.
For a reader who follows a very large account, the feed service adds 1 step to this path. Having read the stored list, it fetches the recent posts of each large account the reader follows, from a cache in which they are nearly always present, and merges them into the list by time before returning the page.
The design, deployed
The feed service is the largest stateless tier, since reads dominate, and it stands with the post service behind load balancers in 3 zones. Below them are 3 stores of different kinds: the feed cache, in memory, holding 1 list for each member, and the post store and follow store, both sharded and replicated across the zones. The fan-out workers at the bottom take 1 task for each post from the queue and are the only component that writes to the feed cache, which keeps the read path free of any concern with copying.
How real systems do it
The best documented feed is Twitter’s home timeline, described in a talk of 2012 that is the source of the arrangement in this chapter. Twitter then had about 150 million active users. Timelines were read some 300000 times per second and written a few thousand times per second, a ratio that settled the choice in favour of doing the work on writing. Every tweet was inserted, by its identifier, into a list held in an in-memory store for each follower, exactly as the feed lists here. The talk gives the aim of delivering a tweet to all followers within 5 seconds, and acknowledges that for accounts with tens of millions of followers this was not always achieved, and that the company was therefore moving to merge the tweets of such accounts at reading time, which is the mixture this chapter recommends.
Facebook’s news feed has taken the other course from the beginning. Its feed is assembled when it is requested, by aggregator servers that query a set of leaf servers holding recent activity and rank what they collect, which suits a feed that is ordered by predicted interest and drawn from many kinds of story. The social graph beneath it, who is connected to whom, is served by a system called TAO, a read-optimised cache over a sharded database, whose paper is the standard account of storing a graph of followers at scale.
The 2 examples show that the choice between the schemes is not settled by size alone. Twitter’s feed, chronological and with extremely skewed followings, favoured copying with an exception for the largest accounts. Facebook’s, ranked and with bounded numbers of friends, favoured collecting. LinkedIn has described a feed for its own service that is likewise assembled at reading time.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Evaluating the feed query on every visit | Each read touches every followed account and is slow at scale. | Keep a feed list per member, prepared when posts are written. |
| Copying every post to every follower | 1 post by an account with millions of followers produces millions of writes and delays all other posts. | Above a threshold of followers, collect that author’s posts at reading time. |
| Copying while the author waits | Publishing takes as long as the copying. | Store the post, reply, and copy from a queue. |
| Storing whole posts in the lists | Edits and deletions must find every copy. | Store references, and fetch posts by identifier. |
| Lists of unlimited length | Memory grows without bound for entries nobody reads. | Keep the newest few hundred, and collect on read beyond that. |
| Treating the lists as the only copy | A lost cache node loses feeds. | Rebuild a list from the durable posts and follows. |
| Paging by position | A new post shifts everything, and the next page repeats an item. | Page with a cursor that names the last item seen. |
| Removing references when a post is deleted or an account unfollowed | Millions of lists must be searched, slowly and unreliably. | Filter at reading time. |
| The author’s own post arriving with everyone else’s | The author reloads and concludes the post was lost. | Add the author’s recent posts to the author’s own feed at reading time. |
| Maintaining lists for members who never return | Most copying is done for nobody. | Let inactive members’ lists lapse, and rebuild on return. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| Fan-out workers fall behind | posts appear in feeds late | the queue holds the tasks; more workers are added |
| The feed cache is lost | the first read of each feed is slow | lists are rebuilt by collecting on read |
| A member unfollows an account | old posts of that account may remain in the list briefly | they are filtered out when the feed is read |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Copy on write | reads are 1 lookup | an account with many followers makes writes expensive |
| Collect on read | writes cost nothing extra | every read fetches from many accounts |
| A mixture with a threshold | both costs stay small | 2 code paths, and a threshold to tune |
The simulation
Simulation in the repository. The program code/feedsim simulates this design. To run it, clone the repository and type cargo run --release -p feedsim in the code directory.
References
- R. Krikorian, “Timelines at Scale”, QCon San Francisco, 2012.
- N. Bronson and others, “TAO: Facebook’s Distributed Data Store for the Social Graph”, USENIX ATC 2013.
- Facebook engineering blog, “Serving Facebook Multifeed: Efficiency, performance gains through redesign”, 2015.
- LinkedIn engineering blog, “FollowFeed: LinkedIn’s Feed Made Faster and Smarter”, 2016.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Feed | the recent posts of the accounts a member follows, newest first |
| Follow | the relation between a member and an account whose posts the member wishes to see |
| Feed list | a stored list of references to the posts 1 member should see |
| Fan-out | delivering 1 post to many readers |
| Fan-out on write | copying a reference to each follower’s list when a post is published |
| Fan-out on read | collecting posts from each followed account when the feed is requested |
| Cursor | a reference to the last item seen, from which the next page continues |
| Read your own writes | the property that a member sees the effect of their own actions at once |
| Ranking | ordering the feed by an estimate of interest in place of time |
Summary
- A feed is a join of posts and follows that is too costly to evaluate on every visit once the data is spread over many machines.
- Copying on write prepares a list for every member, so that reading is 1 lookup and publishing costs 1 write per follower.
- Collecting on read prepares nothing, so that publishing is 1 write and reading costs 1 fetch per followed account.
- An account with millions of followers makes copying on write impractical, and such accounts are few.
- The mixture copies the posts of ordinary accounts and collects those of large accounts at reading time, at about a fiftieth of the work in the measured example.
- Feed lists hold references, are bounded in length, live in a cache, and can be rebuilt from durable data.
- Pages are requested by cursor, and deletions, blocks and unfollows are enforced by filtering when the feed is read.
Object Store: System Design
An object store keeps files of any size, from a thumbnail of a few kilobytes to a video of many gigabytes, and returns them on request by name. Photographs, backups, logs, the trained weights of machine learning models and the videos of chapter 17 all end up in a system of this kind, and a large one holds hundreds of petabytes on tens of thousands of disks.
At that scale the governing fact is that hardware fails continuously. A disk drive has perhaps a 2% chance of failing in any given year, so that among 20000 disks about 1 fails every day, as a matter of routine. Disks also fail in a quieter and more dangerous manner, by returning data that differs from what was written without reporting any error. A system that is to keep data for decades on such components cannot treat failure as an exceptional event to be handled when it occurs. It has to be built so that the loss of any disk, or any machine, or any rack of machines, at any moment, destroys nothing, and so that the damage is repaired automatically before a second failure can compound the first. This chapter is chiefly about how that is arranged, and about the related question of how to keep track of where everything is.
This chapter covers
- what distinguishes an object store from a file system
- keeping the names of objects apart from their bytes
- cutting objects into chunks, and naming a chunk by its contents
- surviving lost disks with copies, and with parity at less than half the space
- where copies are placed, and why it matters as much as how many there are
- silent damage, checksums, and the scrubber that looks for it
- repair, and why its speed decides how durable the store is
- uploading very large objects in parts
- deleting, and collecting the chunks that nothing uses any more
What an object store is
It is easiest to say what an object store is by contrast with the file system that every reader has used. A file system offers a tree of directories, files that can be opened and modified at any position, appended to, renamed and locked, and it is expected to make all of these operations consistent with one another. These features are costly to provide across many machines, and most large collections of data do not need them.
An object store offers much less and can therefore scale much further. An object is
a sequence of bytes with a name, held in a container called a bucket. The name is a
plain string. It may contain slashes, as in 2026/10/holiday.mp4, and tools display
such names as if they were folders, yet the store itself knows of no directories and
treats the name as a single key. An object is written whole and read whole or in
ranges, and it is never modified in place: to change an object, one uploads a
replacement under the same name. There is no appending, no partial overwriting and no
renaming other than by copying.
| File system | Object store | |
|---|---|---|
| Names | a tree of directories | a flat key within a bucket |
| Changing part of a file | supported | not possible: the whole object is replaced |
| Renaming a directory | 1 operation | a copy of every object beneath it |
| Typical size of a deployment | 1 machine, or a few | thousands of machines |
| Access | system calls on an open file | HTTP requests for a whole object or a range |
The restriction to whole, immutable objects is what makes the rest of the design tractable. Data that never changes after it is written can be copied freely to several places without any risk of the copies diverging, can be verified against a checksum computed once, and can be cached anywhere.
Names in one place, bytes in another
An object store has 2 quite different things to remember about every object: what it is called and where its bytes are, which together are its metadata, and the bytes themselves. The 2 kinds of information have such different characteristics that they are kept in separate systems.
Metadata is small, a few hundred bytes per object, and is needed for every operation. It is updated when objects are created and deleted, it must be listed in the order of names, and it has to be strongly consistent, since an application that has just uploaded an object and been told so must find it when it asks. These are the requirements of a database, and the metadata is kept in a replicated, sharded key-value store of the kind built in chapters 1 to 6, keyed by bucket and object name.
The bytes are large, are written once and never altered, and are read sequentially. They need no ordering, no transactions and no queries. What they require is a great deal of cheap disk space and protection against the loss of disks. They are kept on storage nodes, which are machines with many disks and very simple software that stores and returns pieces of data by identifier.
This separation has a practical consequence for every request. A download first asks the metadata store where the pieces of the object are, and then fetches the pieces from storage nodes. An upload proceeds in the opposite order, writing the pieces to storage nodes first and recording the object in the metadata store last. The order of an upload is deliberate. The metadata entry is the single point at which an object comes into existence, and it is created only when all the bytes are safely stored, so that an upload interrupted in the middle leaves behind some unreferenced pieces, to be cleaned up later, and never an object that claims to exist and cannot be read.
Chunks
Objects range in size over 9 orders of magnitude, and it is impractical to handle a 5 TB object as 1 unit: it would not fit on a single disk together with others, could not be read in parallel, and would have to be copied in its entirety to repair the loss of any part. Objects are therefore cut into chunks of a fixed maximum size, a few megabytes, and each chunk is stored and protected independently. The metadata of an object lists its chunks in order.
A chunk needs an identifier under which storage nodes can keep it, and a particularly useful choice is to name each chunk by a hash of its contents. Naming data by its own hash is called content addressing, and it has 2 valuable consequences. The first is that integrity can be checked by anyone at any time: whoever holds a chunk can recompute the hash and compare it with the name, and a mismatch proves that the bytes have been altered. The second is that identical chunks receive identical names and so are stored only once, however many objects contain them, which is called deduplication.
The simulation for this chapter uploads a file of 10 MB, which is cut into 3 chunks of at most 4 MB, each stored on 3 of 6 nodes. It then uploads the same bytes again under another name, and the second upload writes no chunks at all, the 3 it needs being already present. The store holds 2 objects and the bytes of 1.
Content addressing demands a hash function for which it is not feasible to find 2 different inputs with the same output, since 2 different chunks with the same name would be treated as the same chunk and 1 of them would be lost. The simulation uses a short checksum for brevity, and a real store uses a cryptographic hash of 256 bits.
Surviving the loss of disks
Every chunk must survive the failure of the disks that hold it, and there are 2 ways of arranging this.
Copies
The simple way is replication: each chunk is stored in full on 3 different storage nodes. Any 1 of the copies suffices to read the chunk, so that the chunk survives the loss of any 2 nodes. Reading is fast, since any copy will serve, and repairing a lost copy consists of reading 1 of the survivors and writing it elsewhere. The cost is space. Every terabyte of objects occupies 3 terabytes of disk, and at 100 petabytes this overhead is 200 petabytes of disks that hold nothing but duplicates.
Parity
The economical way stores extra information from which lost pieces can be recomputed, in place of whole copies. The principle can be shown with ordinary addition.
In this small example, 4 disks protect 3 disks’ worth of data against the loss of any 1 disk. To protect the same data against 1 loss by replication would require 2 full copies and therefore 6 disks.
Practical systems extend the idea to tolerate several losses. A chunk is divided into k data pieces, and m additional parity pieces are computed from them, each by a different combination, in such a way that any k of the k + m pieces are sufficient to reconstruct the original. The technique is called erasure coding. Its arithmetic is carried out in a number system devised so that the necessary equations can always be solved, which is more elaborate than addition and rests on the same idea. A common choice is 10 data pieces and 4 parity pieces, stored on 14 different nodes.
The comparison is striking. The coded chunk occupies 1.4 times its own size where replication occupies 3 times, and it survives the loss of any 4 pieces where replication survives the loss of 2. At 100 petabytes of objects, the difference is 160 petabytes of disks.
Erasure coding exacts its price elsewhere. Computing parity takes processor time on every write. Reading normally touches only the data pieces, and when 1 of them is unavailable the reader must fetch 10 other pieces and compute the missing one, which makes reads of damaged chunks slow. Repair is costlier in the same way: to rebuild a single lost piece, 10 pieces must be read over the network, where replication reads 1. And the scheme is unsuitable for small objects, for which the fixed work of dealing with 14 nodes outweighs the saving in space.
Large stores accordingly use both. Small objects and recently written, frequently read data are replicated, for speed. Large objects, and data that has grown cold, are erasure coded, for economy, and a background process converts data from the first form to the second as it ages.
Where the copies go
The number of copies is only half of durability. Their placement is the other half. 3 copies on 3 disks of the same machine are all lost when that machine’s power supply fails, and 3 copies on 3 machines in the same rack are all unreachable when the rack’s network switch fails. Failures of this kind, in which many components go down together for a common reason, are called correlated, and they are far more common than a calculation that treats disks as independent would suggest.
A group of components that may fail together is called a failure domain, and the rule for placement is that the copies or pieces of a chunk are spread over as many failure domains as possible: on different machines, in different racks, and for the most valuable data in different buildings. The system must know the physical arrangement of its machines in order to apply this rule, and the rule has to be reapplied whenever data is moved for repair or rebalancing.
Silent damage
The failures discussed so far announce themselves. A dead disk returns errors, and a dead machine does not respond. A more insidious failure is the disk that works and returns the wrong bytes. Magnetic media decay, firmware has defects, and a write is occasionally directed to the wrong place. The rate of such events is low for any 1 disk and, across tens of thousands of disks and many years, they are a certainty. Because the disk reports success, nothing prompts anybody to look, and the damage lies undiscovered until the data is next read, which for an archive may be years later.
The defence has 2 parts. Every chunk is stored together with a checksum of its contents, and every read verifies the bytes against the checksum before returning them, so that damaged data is never served to a client. This alone is insufficient, since it finds damage only in data that happens to be read. The second part is therefore a background process, the scrubber, which reads every chunk on every disk in rotation, verifies it, and arranges for the repair of any that fails.
The simulation damages a single byte in 1 copy of 1 chunk and then reads the object. The read detects the mismatch, takes the chunk from another node, returns the correct 10485760 bytes, and repairs the damaged copy in passing.
The importance of scrubbing lies in the timing. A chunk with 3 copies survives the corruption of 1 or 2 of them provided the damage is found and repaired while a good copy still exists. If nothing looks for years, the damage accumulates unobserved, and the day arrives on which the last good copy of some chunk is found to be bad as well. Redundancy protects data only for as long as lost redundancy is promptly restored.
Repair
When a disk or node fails, every chunk that had a copy there is left with 1 copy fewer than it should have. A repair process detects this, from the absence of the node’s heartbeats or from the scrubber’s reports, and creates new copies on other nodes from those that remain.
The durability of the whole store depends more on the speed of repair than on any other single factor. A chunk with 3 copies is lost only if all 3 fail, and what matters is the length of the interval during which it is exposed. If a failed copy is replaced within an hour, the chunk is lost only if 2 further specific disks fail within that hour, which is extraordinarily improbable. If replacement takes a week, the window is 168 times longer and the probability of loss correspondingly greater. The remarkable durability figures quoted for large object stores are obtained less by keeping many copies than by repairing quickly.
Repair nevertheless has to be restrained in 2 respects. A node that has merely been restarted for maintenance will return in a few minutes with its data intact, and to begin copying terabytes the instant it disappears would be wasted effort, so repair waits for a short period before acting. And when an entire rack fails, the repair of all its chunks at once could saturate the network and disturb the service to clients, so repair is rate limited and proceeds in order of urgency, attending first to the chunks that have the fewest surviving copies.
Very large objects
An object of 50 GB cannot sensibly be uploaded in a single HTTP request, since any interruption in several hours of transfer would oblige the client to begin again. Object stores therefore offer multipart upload. The client announces that it intends to upload a large object and receives an identifier for the upload. It then sends the object in numbered parts of some tens of megabytes each, in any order and several in parallel, and a part that fails is sent again by itself. When every part has arrived, the client sends a final request listing them, and the store assembles the object by writing a metadata entry that refers to the chunks of all the parts in sequence.
Until that final request, the object does not exist as far as readers are concerned, which preserves the rule that an object appears whole or not at all. Uploads that are begun and never completed leave their parts behind, and a store needs a policy for discarding them after a time, since they would otherwise accumulate invisibly and be charged for.
Downloads have a corresponding facility in the range request, by which a client asks for a stated range of bytes within an object. The store determines from the metadata which chunks cover the range and fetches only those. A video player uses this to jump to the middle of a film without downloading what precedes it, and an interrupted download can be resumed from the point it had reached.
Deleting, and collecting unused chunks
Deleting an object removes its metadata entry, upon which the object ceases to exist for clients at once. Its chunks cannot be deleted at the same moment, for 2 reasons. With deduplication, a chunk may also belong to other objects. And the deletion of chunks on many nodes is slow work that should not hold up the client’s request.
Chunks that are no longer referred to by any object are therefore removed afterwards, by a background process of garbage collection. It determines which chunks are in use, by examining the metadata, and deletes those on the storage nodes that are not. This process contains a race that has cost real systems real data.
The collector’s knowledge of what is unused is necessarily a little out of date, and an upload that arrives in the meantime may rely on a chunk that the collector has already condemned. The standard protection is a period of grace. A chunk is deleted only when it has been unreferenced continuously for a substantial time, a day for example, and an upload that reuses an existing chunk records the fact in a way that restarts the period. The store thus retains some garbage for a day longer than strictly necessary, which is a trivial price for the assurance that nothing in use is ever removed.
Many stores also offer versioning, under which replacing or deleting an object retains the previous version, the latest version being what a plain request returns. This protects against deletion by mistake and against software that overwrites good data with bad, and it is among the few defences against an operator’s or a program’s own errors, which replication does nothing to prevent, since a mistaken deletion is replicated as faithfully as anything else.
Use cases
Applications upload, download and list objects. Operators replace failed hardware, which in a store of this size is a daily task.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | An application can upload an object of up to 5 TB under a name in a bucket. |
| 1 | An application can download an object, or a range of its bytes. |
| 2 | An application can list the objects of a bucket by prefix. |
| 3 | An object that was stored is returned unchanged, or the store reports an error. It does not return damaged bytes. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Durability | An object survives the loss of any 2 of the 3 nodes that hold a chunk, and damage on a disk. |
| 1 | Availability | 99.9% for uploads and downloads. |
| 2 | Consistency | Strong for names: after an upload completes, a download returns the new object. Chunks do not change at all. |
| 3 | Scale | 1 billion objects and 100 PB of data. |
The store combines 2 consistency models. Metadata is strongly consistent: once an upload has been confirmed, every subsequent request for that name finds the new object, and a listing of the bucket includes it. This property, known as read-after-write consistency, is what applications expect, and its absence from some early object stores was a persistent source of defects in the software built on them. The chunks need no consistency model in the usual sense, because they are immutable. A chunk either exists under its name with the correct contents, or it does not exist, and no reader can ever observe an out-of-date version of one.
Core entities
Capacity estimate
Arithmetic ahead. 100 PB in 3 copies is 300 PB, about 21000 disks of 16 TB, with 25 billion chunks to keep track of.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Raw storage | 100 PB × 3 copies | 300 PB |
| Disks of 16 TB, 90% full | 300 PB ÷ 14.4 TB | 20834 |
| Chunks of 4 MB | 100 PB ÷ 4 MB | 25 billion |
| Metadata | 1 billion objects × 1 KB | 1 TB |
The API
The API is that of HTTP applied directly to objects. PUT stores an object under a
name, GET retrieves it, and DELETE removes it.
Upload an object
PUT /v1/buckets/holiday/video.mp4 HTTP/1.1
Host: objects.example
Content-Type: video/mp4
Content-Length: 10485760
(10485760 bytes)
HTTP/1.1 201 Created
ETag: "crc32:9a3f11c2-3"
Clients can compare the ETag, which names the content, with their own checksum.
Download part of an object
GET /v1/buckets/holiday/video.mp4 HTTP/1.1
Host: objects.example
Range: bytes=0-1048575
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/10485760
Content-Length: 1048576
(1048576 bytes)
With a range, a video player fetches only the part it is about to show.
Applications frequently wish to let a user’s browser upload or download an object directly, without the bytes passing through the application’s own servers. For this the application generates a presigned URL: an ordinary address for the object that carries an expiry time and a signature computed with the application’s secret key. Anyone holding the address may perform the 1 operation it describes until it expires, and the store verifies the signature without needing to consult the application.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| API server | cuts uploads into chunks and puts downloads together | none | another API server is used |
| Metadata store | maps an object’s name to its chunks | the names, replicated | a follower takes over, as in chapter 4 |
| Storage node | keeps chunks on its disks | chunks | every chunk it held has 2 other copies |
| Repair worker | finds chunks with fewer than 3 good copies | none | repairs wait for the next run |
One request, step by step
The steps follow the arrows of figure 16.7.
- An application sends
PUT /v1/buckets/holiday/video.mp4with the file as the body. - The API server reads the body 4 MB at a time. It computes each chunk’s checksum and stores the chunk on 3 storage nodes.
- When every chunk is stored, the API server writes 1 record to the metadata store: the object’s name and its list of chunk names.
- In the background, the repair worker checks that every chunk still has 3 good copies, and makes a new copy where 1 is missing.
The design, deployed
The front end is a large stateless tier of API servers behind load balancers. Below it the 2 kinds of storage are plainly separate: 60 machines of the metadata store, which is 20 shards of 3 replicas each, and 600 storage nodes with some 36 disks apiece, which hold the bytes. The bottom tier is peculiar to storage systems. It serves no request, and it runs all the time: a scrubber on every node reading its disks in rotation, a pool of repair workers, and the garbage collector. A store of this size loses a disk on most days, and this tier is what turns that from an event into routine.
How real systems do it
The published designs of large stores show a clear progression, and this chapter compresses several generations into 1.
The Google File System of 2003 established the pattern of a metadata service and many chunk servers, with chunks of 64 MB kept in 3 copies. Facebook’s Haystack, in 2010, addressed a problem that this chapter passes over: a store of billions of small photographs spends most of its disk operations on locating files and very few on reading them, and Haystack packed many photographs into each large file with an index held in memory, so that a read needs a single disk operation. Any store of small objects has to do something of the kind, and a design that kept every object as a separate file would not survive.
For data that is seldom read, Facebook then built f4, which replaces copies by erasure coding with exactly the scheme of this chapter, 10 data blocks and 4 parity blocks, at 1.4 times the size of the data within a data centre. The paper gives the effective overhead of Haystack as 3.6, since it kept 3 copies on disks that were themselves in redundant arrays, and that of f4 as 2.1 once a further coding across data centres is included. Microsoft’s storage system uses a variant, local reconstruction codes, which adds parity blocks that each cover half of the data blocks, so that the common case of a single lost block is repaired by reading 6 blocks where the plain scheme reads 10 or more.
On placement, a paper from Stanford made precise the danger of correlated failures. When replicas are placed on nodes chosen at random, almost any simultaneous loss of 3 nodes in a large cluster destroys some chunk, because nearly every combination of 3 nodes holds the replicas of something. Restricting placement to a limited number of fixed groups of nodes makes a simultaneous loss far less likely to hit all copies of anything, at the cost of losing more when it does.
The deduplication of identical chunks described in this chapter is characteristic of backup and file synchronisation systems, and public object stores generally do not practise it across customers, in part because it lets 1 customer discover whether another holds a given file. And the consistency that this chapter requires of metadata was for many years not provided by the largest public store, Amazon’s S3, which offered it only from the end of 2020.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Recording the object before its bytes are stored | A crash leaves an object that exists by name and cannot be read. | Write the chunks first and the metadata last. |
| Counting copies without regard to placement | A rack or a machine fails and takes every copy of some chunks with it. | Spread the copies over different failure domains. |
| Verifying data only when it is read | Damage in rarely read data goes unnoticed until every copy is bad. | Scrub all chunks continuously in the background. |
| Slow or unprioritised repair | Chunks remain exposed for days, and a second failure destroys them. | Repair promptly, the chunks with fewest copies first. |
| Repairing the instant a node disappears | Terabytes are copied for a node that was only restarting. | Wait a short period before starting repair. |
| Erasure coding everything | Small objects and reads of damaged chunks become slow. | Replicate small and hot data, and code large and cold data. |
| Deleting chunks as soon as an object is deleted | A chunk shared through deduplication, or just reused by an upload, is destroyed. | Collect garbage later, after a period of grace. |
| A weak hash for content addressing | 2 different chunks receive the same name, and 1 is lost. | Use a cryptographic hash. |
| Uploading huge objects in 1 request | Any interruption restarts the entire transfer. | Upload in parts that can be retried separately. |
| Never discarding unfinished uploads | Their parts occupy space indefinitely. | Expire incomplete multipart uploads. |
| Relying on replication against mistakes | A mistaken deletion or overwrite is replicated faithfully. | Keep earlier versions of objects. |
| Names with a common prefix written in order | All new objects fall on the same shard of the metadata store. | Spread names by a hash, or shard by a hash of the name. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A disk returns damaged bytes | nothing | the checksum fails, another copy is used, and the bad copy is replaced |
| A storage node is lost | nothing | the repair worker makes a third copy of each of its chunks on other nodes |
| An upload stops half way | the object does not appear | its chunks have no name pointing at them and are removed later |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| 3 copies of every chunk | simple, and fast to repair | 3 times the disk space; erasure coding needs about half of that |
| Chunks named by checksum | damage is detected, and equal data is stored once | a chunk cannot be changed in place |
| Names in a separate store | listing and renaming are cheap | 2 systems to keep in step |
The simulation
Simulation in the repository. The program code/objectsim simulates this design. To run it, clone the repository and type cargo run --release -p objectsim in the code directory.
References
- S. Ghemawat, H. Gobioff and S. Leung, “The Google File System”, SOSP 2003.
- D. Beaver and others, “Finding a Needle in Haystack: Facebook’s Photo Storage”, OSDI 2010.
- S. Muralidhar and others, “f4: Facebook’s Warm BLOB Storage System”, OSDI 2014.
- C. Huang and others, “Erasure Coding in Windows Azure Storage”, USENIX ATC 2012.
- A. Cidon and others, “Copysets: Reducing the Frequency of Data Loss in Cloud Storage”, USENIX ATC 2013.
- B. Calder and others, “Windows Azure Storage: A Highly Available Cloud Storage Service with Strong Consistency”, SOSP 2011.
- Amazon Web Services, “Amazon S3 Update: Strong Read-After-Write Consistency”, 2020.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Object | a sequence of bytes stored whole under a name |
| Bucket | a container for objects with its own space of names |
| Metadata | what the store records about an object: its name, its size and where its chunks are |
| Storage node | a machine with many disks that stores chunks |
| Chunk | a piece of an object, of a few megabytes at most |
| Content addressing | naming a piece of data by a hash of its contents |
| Deduplication | storing identical data once |
| Replication | keeping several full copies |
| Parity | extra data computed from other pieces, from which a lost piece can be rebuilt |
| Erasure coding | dividing data into k pieces and adding m parity pieces, such that any k rebuild the whole |
| Failure domain | a group of components that can fail together |
| Scrubber | a background process that reads all data and verifies its checksums |
| Repair | making new copies or pieces to replace those that were lost |
| Multipart upload | uploading a large object in separately sent parts |
| Garbage collection | removing chunks that no object refers to |
| Versioning | keeping the earlier versions of an object when it is replaced or deleted |
| Read-after-write consistency | the property that an object can be read as soon as its upload is confirmed |
| Presigned URL | an address that grants 1 operation on 1 object for a limited time |
Summary
- An object store keeps whole, immutable objects under flat names, and gains its scale from offering so little.
- Names and locations are kept in a strongly consistent metadata store, and bytes on simple storage nodes, written first and recorded last.
- Objects are cut into chunks named by a hash of their contents, so that damage is detectable and identical chunks are stored once.
- 3 copies survive 2 losses at 3 times the space, and 10 data pieces with 4 parity pieces survive 4 losses at 1.4 times.
- Copies are spread over failure domains, since components fail together more often than independence would predict.
- A scrubber verifies every chunk in rotation, because disks return wrong data without reporting it.
- Durability depends above all on repairing quickly, so that a chunk is exposed only briefly after a loss.
- Chunks are deleted by garbage collection after a period of grace, so that a chunk being reused is not removed.
Video Streaming: System Design
A film of 2 hours in high definition occupies about 4.5 GB. A viewer who presses play expects the picture to appear within a second and to continue without interruption, on a phone connected through a mobile network whose speed changes from moment to moment as readily as on a television connected by cable, and a popular film is watched by millions of such viewers at once, in every part of the world. Video accounts for the greater part of all the bytes carried by the internet, and a service that delivers it is, before it is anything else, a system for moving very large quantities of data to very many places under conditions it cannot control.
The observation on which every such system rests is that a video does not have to be delivered as a video. It can be cut into short pieces, each an ordinary file of a few megabytes, and delivered as files. Once this is done, the difficult parts of the problem turn out to have been solved already. Files of this size can be kept in the object store of chapter 16, served by ordinary web servers, and held in caches close to the viewers, and the player, which is the only component that knows how fast the viewer’s connection is at this instant, can decide for itself, piece by piece, which quality of picture to ask for. This chapter follows a video from the moment it is uploaded to the moment it is watched, and explains each of these steps.
This chapter covers
- why a video is delivered as a sequence of small files
- encoding 1 upload at several qualities, and dividing that work among many machines
- the manifest, which tells a player what pieces exist
- how a player chooses a quality for each piece, and what it does when the connection worsens
- the buffer, and the balance between quality and interruptions
- edge servers that keep copies of pieces near the viewers
- what determines the cost of the service
- how live video differs from video on demand
Why a video is delivered in pieces
The earliest method of delivering video over the internet treated it as a single file. The browser requested the file and played it as it arrived, and for a short clip on a fast connection this is adequate. For anything longer it has several defects, all of which follow from the indivisibility of the file. The quality is fixed when the download begins, so that a viewer whose connection deteriorates must either wait for the picture to catch up or abandon it. A viewer who wishes to skip to the middle must wait for everything before it, unless the server supports requests for ranges of bytes. And a viewer who leaves after 2 minutes may by then have received 20 minutes’ worth of data, which was paid for and never watched.
A second method, common for some years, used protocols designed especially for video, in which a server kept a connection open to each viewer and sent a continuous stream at whatever rate the connection would bear. This adapted well to changing conditions and scaled badly, since the server had to maintain state for every viewer and nothing between the server and the viewer was able to cache a stream.
The method that prevailed is simpler than either. The video is cut into pieces of a few seconds each, and every piece is stored as a separate file. The player fetches the pieces in order, by ordinary HTTP requests, and plays each as it arrives. The server keeps no state about any viewer, since each request is complete in itself and any server can answer any request. Skipping to the middle is a matter of requesting a later piece. And because pieces are ordinary files with ordinary addresses, the whole existing apparatus for delivering files over HTTP, caches above all, applies to video without modification.
Several qualities of 1 video
Viewers differ too widely for any single version of a video to suit them all. A phone on a weak mobile signal can receive perhaps 500 kilobits per second, while a television on a fibre connection can receive 50 times as much and has the screen to show the difference. A version small enough for the phone would look poor on the television, and a version suited to the television could not be played on the phone at all.
The service therefore prepares each video at several qualities. The process of producing a compressed video at a given picture size and data rate is called encoding, and each complete version so produced is a rendition. The data rate of a rendition, the number of bits it needs for each second of video, is its bitrate.
A detail of this figure is essential to everything that follows. All renditions are cut into pieces at exactly the same points in time, so that piece 17 covers the same 4 seconds of the film in each of them. A player can therefore take piece 17 from one rendition and piece 18 from another, and the picture continues without a break, changing only in sharpness. For this to work, every piece must begin with a complete picture. Most frames of a compressed video are stored only as a description of how they differ from the frame before, and the encoder is instructed to place a complete picture at the start of every piece.
The length of a piece is a compromise. Short pieces allow the player to change quality quickly and to begin playing sooner, and they compress less efficiently and multiply the number of requests. Long pieces are the reverse. Values between 2 and 6 seconds are usual, and this chapter uses 4.
From an upload to pieces
A creator uploads a single file, in whatever format their camera or editing software produced. Before anyone can watch it, the service must encode it at every quality and cut each result into pieces, and encoding is among the most computationally expensive operations in common use. To encode a film of 2 hours at 1 quality may take a single machine an hour or more, and there are 4 qualities.
The work is made tolerable by dividing it. The source is first cut into sections of about a minute, at points where a complete picture begins, so that each section can be encoded without reference to any other. Each section, at each quality, then becomes an independent job, and the jobs are distributed among a pool of encoding machines by a scheduler of the kind designed in chapter 12.
A film of 2 hours yields 120 sections and so 480 jobs, which 100 machines complete in roughly the time that 1 machine would need for 5 of them. The arrangement also confines the effect of failures. If an encoder crashes, the scheduler reissues that 1 job, as chapter 12 described, and a minute of video at a single quality is encoded again, the remainder being unaffected. Encoding jobs are idempotent without any special effort, since encoding the same section twice produces the same pieces under the same names.
The original upload is retained permanently, in the object store, although nobody will ever watch it directly. Encoding technology improves steadily, and a service will in time wish to encode its catalogue again in a more efficient format. This can be done well only from the source, since every encoding discards information, and to encode again from an already encoded version would compound the loss.
Not all videos need be treated alike. The distribution of views is extremely uneven, a small number of videos accounting for most of the watching and most uploads being watched a handful of times. A service may therefore encode every upload quickly at 2 or 3 qualities, so that it is soon available, and spend the considerable additional effort of a full set of carefully optimised renditions only on videos that prove popular.
The manifest
A player that is to fetch pieces must know what pieces exist. This information is supplied by a small text file called a manifest, which the player requests first. It lists the renditions available, with the bitrate and picture size of each, and, for each rendition, the addresses of its pieces in order together with their durations.
With the manifest in hand, the player needs nothing further from the service except the pieces themselves. It knows how long the video is, what qualities are offered, and the address of the piece that covers any given moment at any given quality. All subsequent decisions, including which quality to request and when, are taken by the player alone.
That the intelligence should reside in the player, with the server left ignorant, is a deliberate feature of the design and one of its chief strengths. The server answers requests for files and need not know that the files are video. The player is the 1 participant that can observe the speed of this particular viewer’s connection at this moment, the size of the screen, and the amount of video already downloaded, and it is therefore the participant best placed to choose.
Choosing a quality for each piece
Before requesting each piece, the player must decide which rendition to take it from. If it chooses too high a bitrate, the piece takes longer to download than it takes to play, the player eventually has nothing left to show, and the picture stops while the viewer waits. If it chooses too low a bitrate, the picture is needlessly poor. Choosing well, piece after piece, as conditions change, is called adaptive bitrate selection.
The player has 2 measurements to guide it. It knows how fast recent pieces were downloaded, which is an estimate of the present speed of the connection. And it knows how much video it has downloaded but has yet to play, an amount called the buffer.
The simplest rule uses the first measurement alone: request the highest bitrate that is comfortably below the measured speed. The simulation for this chapter applies this rule with a margin of 20%, taking the best rendition whose bitrate is at most 80% of the speed of the last download, to a connection whose speed drops twice and then recovers.
The steps show both the virtue of the rule and its inherent limitation. The player always acts on the speed of the previous download, and is consequently 1 piece behind events. When the speed fell from 6000 to 1500 at piece 4, the player had already committed itself to a piece at 2500, which took 6.7 seconds to arrive and consumed part of the buffer. It is the buffer that absorbed the error. The picture did not stop, because nearly 9 seconds of video were in hand when the slow download began. Over the 10 pieces, the only time without video was the fraction of a second before the first piece arrived.
The buffer is thus what allows the player to make mistakes without the viewer noticing, and the size at which to maintain it is another compromise. A large buffer tolerates long periods of poor connection. It also represents video that has been downloaded and may never be watched if the viewer leaves, and it delays the effect of a change of quality, since everything already in the buffer will be played at the quality at which it was fetched. Players commonly aim to hold between 10 and 30 seconds.
More refined rules take the buffer into account as well as the speed. When the buffer is nearly empty, such a rule chooses conservatively whatever the measured speed, because the cost of a mistake is an interruption. When the buffer is full, it can afford to try a higher quality, since an error will cost only some seconds of buffer. Some players ignore the measured speed altogether and select the quality as a function of the buffer alone, on the grounds that the level of the buffer already reflects everything the connection has recently done.
Whatever the rule, it must respect an asymmetry in what viewers will tolerate. An interruption, during which the picture freezes, is far more objectionable than a period of reduced sharpness, and viewers who experience interruptions stop watching. A change of quality is also somewhat disturbing in itself, so that a rule which switches up and down with every fluctuation of the measured speed is worse than one which holds a steady, slightly lower quality. A good rule accordingly steps down promptly, steps up cautiously, and prefers stability.
The first piece is a special case. The player has no measurement of the connection when it starts, and viewers are impatient for the picture to appear. Players therefore begin with a low bitrate, which arrives quickly whatever the connection, and improve within a few seconds as measurements accumulate.
Copies near the viewers
Pieces are stored in the object store, in 1 or a few locations. A viewer on another continent is separated from that location by a network path with a delay of perhaps 150 ms in each direction and a capacity shared with a great many others, and if every viewer fetched every piece from the origin, the origin’s network connection would have to carry the entire traffic of the service.
A content delivery network, or CDN, removes both problems. It consists of a large number of servers, called edge servers, placed in or near the networks of the internet providers that viewers use, in hundreds of cities. Each edge server is a cache, of the kind described in chapter 9, for the files of the origin.
A viewer’s request is directed to a nearby edge server, most often by the name resolution system returning the address of a server close to wherever the question came from. If that server holds the piece, it answers immediately. If not, it obtains the piece from the origin, answers, and retains a copy for subsequent viewers.
Video suits this arrangement exceptionally well, for 2 reasons. A piece, once encoded, never changes, so that the problem of stale copies, which dominated chapter 9, does not arise, and pieces can be cached for as long as there is room for them. And the demand for video is highly concentrated, so that an edge server which holds the pieces of the currently popular videos answers the great majority of the requests it receives. For a popular video the origin serves each piece once to each edge server, and the edge servers serve it to millions.
The manifest of an ordinary video is equally immutable and is cached in the same way. The requests that must reach the service itself are thus reduced to those that depend on who is asking, such as checking that the viewer is entitled to watch.
2 refinements are common. When a film is expected to be in great demand on its release, its pieces are copied to the edge servers beforehand, so that the first viewers do not all miss at once and produce at the origin the stampede described in chapter 9. And for videos that are rarely watched, an edge server may hold only the first few pieces, which is sufficient for playback to start promptly, and fetch the remainder from the origin when a viewer in fact continues.
What the service costs
It is instructive to ask where the money goes in such a service, since the answer shapes many decisions that would otherwise seem arbitrary.
Storage is not the principal cost. The renditions of a film amount to a few gigabytes, and a catalogue of 100 million videos to some hundreds of petabytes, which is substantial and, with the erasure coding of chapter 16, affordable. Encoding is a cost incurred once for each video.
The cost that recurs and dominates is the delivery of bytes. Every hour watched at 2500 kilobits per second is rather more than 1 GB transferred, and a service whose members watch a billion hours a month transfers more than an exabyte a year. Almost any measure that reduces the bytes delivered per hour watched therefore repays its cost: more efficient encoding formats, which convey the same picture in fewer bits and justify the expense of encoding the catalogue again; the avoidance of renditions beyond what a device’s screen can display; a limit on how far ahead the player buffers, so that less is wasted when viewers leave; and arrangements with internet providers to place edge servers inside their networks.
Live video
Everything described so far concerns video that exists in full before anyone watches it. A live broadcast uses the same machinery, with 1 important difference: the pieces are produced while the event is taking place.
The camera’s signal is encoded continuously at several qualities, and every 4 seconds a new piece of each rendition is completed and stored. The manifest is no longer fixed. It lists the most recent pieces, is revised as each new piece appears, and is fetched by players again and again, every few seconds, so that they learn of new pieces. It may accordingly be cached only very briefly.
The price of this simplicity is latency, the delay between an event and its appearance on the viewer’s screen. A piece cannot be delivered until it has been completed, which takes 4 seconds, and players hold several pieces in the buffer to guard against interruptions, so that viewers of a live stream built in this way are typically 15 to 30 seconds behind reality. For a concert this is immaterial. For a football match it means hearing the neighbours celebrate a goal that has not yet appeared, and for a conversation between 2 people it is unusable. Shorter pieces and the delivery of pieces while they are still being produced bring the delay down to a few seconds, and truly interactive video, such as a call, requires a different kind of protocol altogether, one which sends each frame as soon as it is captured and accepts occasional loss in exchange for immediacy.
Live video also changes the pattern of load on the caches. Every viewer wants the same new piece in the same instant, at the moment it comes into existence and before any edge server holds it. Edge servers must therefore ensure that the first request for a new piece fetches it from the origin once while the others wait for that fetch to complete, which is the remedy for the stampede given in chapter 9.
Use cases
Viewers play videos, in very large numbers. Creators upload them.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A creator can upload a video file of up to 10 GB. |
| 1 | After processing, the video can be played on any device. |
| 2 | Playback starts within 2 seconds. |
| 3 | The quality adapts to the viewer’s connection while the video plays. |
| 4 | A viewer can jump to any point of the video. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Availability | 99.95% for playback. |
| 1 | Latency | The first piece arrives within 1 second of pressing play. |
| 2 | Consistency | Eventual. A new video may take minutes to appear, and that is acceptable. |
| 3 | Scale | 10 million viewers at the same time, and 500 hours of video uploaded per minute. |
Consistency is scarcely an issue in the playback path, for the reason that has appeared repeatedly: pieces and the manifests of finished videos do not change after they are written, and unchanging data cannot be inconsistent. The catalogue of videos and their states is ordinary metadata and is kept in a strongly consistent store. The 1 window of eventual consistency a creator can observe is between completing an upload and the video’s becoming playable, which lasts as long as the encoding does.
Core entities
Capacity estimate
Arithmetic ahead. 10 million viewers at an average of 2500 kbit/s need 25000 Gbit/s, far more than 1 site can send, so pieces are served from a CDN.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Outgoing traffic | 10000000 viewers × 2500 kbit/s | 25000 Gbit/s |
| Storage per hour of video, 4 qualities | (400 + 1200 + 2500 + 5000) kbit/s × 3600 s | 4.1 GB |
| New storage per day | 500 hours × 60 × 24 × 4.1 GB | 2.95 PB |
| Pieces per hour of video | 3600 ÷ 4 × 4 qualities | 3600 |
The API
A viewer’s player makes 2 kinds of request: 1 for the manifest, and then a long series for pieces.
Get the manifest of a video
GET /v1/videos/8842/manifests/default HTTP/1.1
Host: video.example
HTTP/1.1 200 OK
Content-Type: application/json
{
"piece_seconds": 4,
"pieces": 450,
"qualities": [
{ "kbit": 400, "url": "https://cdn.example/8842/400/piece-{n}.ts" },
{ "kbit": 1200, "url": "https://cdn.example/8842/1200/piece-{n}.ts" },
{ "kbit": 2500, "url": "https://cdn.example/8842/2500/piece-{n}.ts" },
{ "kbit": 5000, "url": "https://cdn.example/8842/5000/piece-{n}.ts" }
]
}
The manifest tells the player which qualities exist and how to build the URL of any piece.
Get 1 piece
GET /8842/1200/piece-17.ts HTTP/1.1
Host: cdn.example
HTTP/1.1 200 OK
Content-Type: video/mp2t
Cache-Control: max-age=31536000, immutable
Content-Length: 600000
(600000 bytes)
immutable tells every cache that this piece will not change, so it can be kept for a year.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Upload service | receives the source file and stores it | none | the creator’s upload resumes on another instance |
| Encoders | turn the source into pieces at 4 qualities | none | the task is delivered again, as in chapter 11 |
| Object store | keeps the source and every piece | everything, see chapter 16 | chunks have 3 copies |
| Video API | returns the manifest | video records | another instance answers |
| CDN | keeps copies of pieces close to viewers | copies only | viewers are served from the next nearest site |
One request, step by step
The steps follow the arrows of figure 17.7.
- A creator uploads the source file. Large uploads are sent in parts, so that a broken upload continues where it stopped.
- The upload service stores the source in the object store and puts 1 task on the queue.
- An encoder takes the task and produces the video at 4 qualities, each cut into pieces of 4 seconds.
- Each piece is stored as its own object. When all are stored, the video is marked ready.
- A viewer presses play. The player asks the video API for the manifest.
- The player fetches pieces from the CDN, 1 after another. A CDN site that does not have a piece fetches it from the object store once and keeps it.
The design, deployed
The tiers of this system are separated by geography as much as by function. The edge servers number in the thousands and stand in hundreds of cities, inside or beside the networks of the internet providers. The origin is in a few places only, and consists of the video API, the object store that holds every piece, and the catalogue. The encoding tier is furthest from the viewers and is never reached by them. It is the largest consumer of computation in the system, and since its work is a queue of independent jobs with no deadline measured in milliseconds, it can use whatever machines are cheapest.
How real systems do it
Delivery in pieces over ordinary HTTP is standardised in 2 forms that differ in detail and agree in principle. Apple’s HTTP Live Streaming is specified in RFC 8216, and Dynamic Adaptive Streaming over HTTP, usually called DASH, is an international standard. Both describe a manifest that lists renditions and their segments, and both leave the choice of rendition entirely to the player, as this chapter does.
The choice itself has been the subject of a good deal of research, and the simple rule of the simulation, which follows the measured speed with a margin, is the starting point, and the state of the art has moved well beyond it. A study carried out on Netflix’s service showed that estimates of capacity are unreliable enough that a player does better, once playback is under way, to choose the rendition from the level of the buffer alone, and reported between 10% and 20% fewer interruptions than the algorithm then in use, at a similar average quality. Later work formulated the choice as an optimisation over a short horizon of future pieces, and still later work trained a model to make it. A production player today usually combines an estimate of speed with the state of the buffer.
On the encoding side, Netflix has described abandoning a fixed list of bitrates for all titles. An animated film and a film of rapid action need very different numbers of bits for the same apparent quality, and each title, and later each scene, is analysed and given its own list. Netflix also places its own caching servers inside the networks of internet providers, under the name Open Connect, which is the arrangement with providers mentioned under the costs of the service.
For live video, the delay of 15 to 30 seconds described above is what the plain protocols give. Both standards have since gained low-latency modes, which publish a piece in smaller parts while it is still being produced and bring the delay down to a few seconds, and conversation in real time uses a different family of protocols altogether, of which WebRTC is the one built into browsers.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Delivering a video as 1 file | The quality cannot change, and caches hold all of it or none. | Cut it into pieces of a few seconds. |
| Cutting each quality at different moments | A change of quality causes a jump or a gap in the picture. | Cut all renditions at the same times, each piece beginning with a complete picture. |
| Encoding a long video as 1 job | It takes hours, and a failure starts it again from the beginning. | Split the source into sections and encode them as separate jobs. |
| Discarding the uploaded source | The catalogue cannot later be encoded in a better format. | Keep the source. |
| Choosing the quality on the server | The server cannot see the viewer’s connection or buffer. | Let the player choose, from its own measurements. |
| Requesting the highest quality the last measurement allows | Any drop in speed empties the buffer and stops the picture. | Keep a margin, and take the buffer into account. |
| Changing quality with every fluctuation | The picture flickers between sharp and soft. | Step down promptly and step up cautiously. |
| Serving every piece from the origin | Distant viewers start slowly, and the origin carries all the traffic. | Serve pieces from edge servers near the viewers. |
| A popular release with cold caches | The first viewers all miss and overload the origin. | Copy the pieces to the edge beforehand. |
| Caching the manifest of a live stream for long | Players do not learn of new pieces. | Cache it for a second or 2 only. |
| Buffering as far ahead as possible | Viewers who leave have downloaded video they never watched. | Limit the buffer to some tens of seconds. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| An encoder crashes during a video | the video is ready later | the task returns to the queue and another encoder takes it |
| A CDN site fails | a short pause, then playback continues | the player’s requests go to another site |
| The viewer’s connection slows down | the picture gets less sharp | the player picks a lower quality for the next piece |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Pieces of 4 seconds | quick reaction to speed changes, cheap seeking | many small files per video |
| 4 qualities | smooth playback on most connections | 4 times the encoding work and storage |
| A CDN | short distance to viewers, low load on the origin | cost, and a new video takes time to spread |
The simulation
Simulation in the repository. The program code/streamsim simulates this design. To run it, clone the repository and type cargo run --release -p streamsim in the code directory.
References
- RFC 8216, HTTP Live Streaming, 2017.
- ISO/IEC 23009-1, Dynamic adaptive streaming over HTTP (DASH).
- T. Huang and others, “A Buffer-Based Approach to Rate Adaptation: Evidence from a Large Video Streaming Service”, SIGCOMM 2014.
- X. Yin and others, “A Control-Theoretic Approach for Dynamic Adaptive Video Streaming over HTTP”, SIGCOMM 2015.
- K. Spiteri, R. Urgaonkar and R. Sitaraman, “BOLA: Near-Optimal Bitrate Adaptation for Online Videos”, INFOCOM 2016.
- H. Mao, R. Netravali and M. Alizadeh, “Neural Adaptive Video Streaming with Pensieve”, SIGCOMM 2017.
- Netflix technology blog, “Per-Title Encode Optimization”, 2015.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Encoding | producing a compressed video at a chosen picture size and bitrate |
| Bitrate | the number of bits a video uses for each second of playing time |
| Rendition | 1 complete version of a video at 1 quality |
| Piece | a few seconds of 1 rendition, stored as a file |
| Manifest | a file that lists the renditions of a video and the addresses of their pieces |
| Adaptive bitrate | choosing the quality of each piece according to current conditions |
| Buffer | video that the player has downloaded and not yet shown |
| Interruption | a pause in playback because the buffer is empty |
| Origin | the place where the service itself stores its files |
| Content delivery network, CDN | a large set of caches placed near viewers |
| Edge server | 1 of those caches |
| Latency of a live stream | the delay between an event and its appearance on the screen |
Summary
- A video is delivered as a sequence of small files, which makes it cacheable, stateless to serve, and adjustable in quality.
- Each upload is encoded at several bitrates, all cut into pieces at the same moments, by many machines working on separate sections.
- A manifest tells the player which pieces exist, and the player alone decides what to fetch.
- The player chooses a quality for each piece from the measured speed and the state of its buffer, and gives up sharpness to avoid interruptions.
- The buffer absorbs the player’s errors, since the player learns of a change in speed only after the next download.
- Edge servers near the viewers hold copies of pieces, which never change and are in highly concentrated demand.
- The dominant cost is the delivery of bytes, which is why efficient encoding and well-placed caches matter so much.
- Live video uses the same pieces, produced as the event happens, at the price of some seconds of delay.
Chat: System Design
A chat service carries short messages between people, and its users hold it to a standard that few other systems are asked to meet. A message is expected to appear on the recipient’s screen within a moment of being sent, as though the 2 phones were directly connected. It is expected to arrive whether or not the recipient’s phone was reachable at the time, to arrive once, and to appear among the other messages of the conversation in the order in which they were sent. People notice at once when any of these expectations is disappointed, since a conversation in which replies precede their questions, or in which a remark is repeated, or in which something said an hour ago turns up now, is immediately felt to be broken.
The system has to meet this standard on a foundation that is distinctly unreliable. A mobile phone loses its connection many times a day, in lifts and tunnels and when passing from one network to another. Its operating system suspends applications that are not on the screen. A message, or the acknowledgement of a message, may be lost at any point, and neither party can tell which of the 2 was lost. The design of a chat service consists largely in arranging matters so that none of this is visible: so that every interruption is followed by an exact recovery, and no message is lost, repeated or misplaced in consequence.
This chapter covers
- how a server can send to a phone that has not asked: 3 methods compared
- gateways, which hold the open connections, and the record of who is connected where
- numbering the messages of a conversation
- how 1 number per message deals with gaps, repetitions and disorder
- catching up after an absence, with a cursor
- sending a message twice without storing it twice
- sent, delivered and read
- groups, several devices, presence, and recipients who are offline
- what end-to-end encryption changes
Reaching a phone that has not asked
In the ordinary use of HTTP, every exchange is begun by the client. The client sends a request and the server replies, and a server has no means of addressing a client that has not just asked it something. For most applications this is no restriction. For chat it is the first obstacle, since the essential act of the service is to deliver a message to a phone at a moment the phone could not have predicted.
There are 3 ways round the obstacle, and they are best compared by what each costs in delay and in wasted effort.
The first method is for the phone to ask at regular intervals whether anything has arrived, which is called polling. It is trivially simple and unsatisfactory in both respects. A message waits, on average, for half the interval before the next request collects it, and nearly all the requests return nothing, so that a phone polling once a second makes 86400 requests a day in order to receive perhaps 50 messages. Shortening the interval reduces the delay and multiplies the waste, and across millions of phones the empty requests alone amount to a formidable load.
The second method, long polling, removes most of both defects by a simple change on the server. The phone sends a request as before, and the server, if it has nothing to report, refrains from answering and holds the request open. When a message arrives, the server answers the waiting request with it, and the phone, having received the answer, immediately sends another request to wait on. Messages are delivered as soon as they arrive, and no request is answered empty except when one has been held for so long, usually about half a minute, that it is released to prevent intermediate network equipment from concluding that the connection is dead.
The third method abandons the pattern of request and reply. The phone opens a single connection to the server and keeps it open, and either side may send on it at any time. The protocol that provides this over the same ports and through the same infrastructure as HTTP is called WebSocket. It has the lowest delay and the least overhead, since a message in either direction is a small frame on an existing connection and involves no new request.
| Polling | Long polling | Open connection | |
|---|---|---|---|
| Delay before a message is received | up to 1 interval | none | none |
| Requests when nothing happens | 1 per interval | 1 per half minute or so | none, apart from a periodic sign of life |
| What the server holds for each phone | nothing | 1 waiting request | 1 connection |
| Sending from phone to server | a separate request | a separate request | on the same connection |
Chat services use the open connection, falling back on long polling in the rare environments where a connection of the third kind cannot be established. The price is in the third row of the table. The server must now maintain a connection for every phone that is online, and with 50 million phones online that is 50 million connections held open at once, most of them idle at any instant. This single fact shapes the architecture of the service.
Gateways and sessions
An open connection is a resource of an unusual kind. It consumes little processing and a modest amount of memory, and it is tied irrevocably to the particular machine on which it terminates: a message for a given phone can be written to its connection only by the 1 process that holds that connection. The service therefore sets aside a class of servers, called gateways, whose sole function is to hold connections. A gateway accepts connections from phones, authenticates them, relays what arrives on them to the rest of the service, and writes to them whatever the rest of the service asks it to deliver. It contains as little other logic as possible, so that the software which changes often, concerning messages and conversations, can be deployed without disturbing the millions of connections.
A properly configured machine can hold some hundreds of thousands of idle connections, and at 100000 each, 50 million phones require 500 gateways. A message addressed to a member must be handed to the particular gateway on which that member’s phone is connected, and so the service needs to know, for every member who is online, which gateway that is. This knowledge is kept in a session store, an in-memory store mapping each connected device to its gateway. A gateway writes an entry when a phone connects and removes it when the phone disconnects.
The session store can be wrong, and the design has to allow for it. A gateway that crashes has no opportunity to remove its entries, which continue to assert that 100000 phones are connected to a machine that no longer exists. Every entry is therefore given a time limit and must be renewed periodically by the gateway that owns it, so that the entries of a dead gateway lapse of their own accord, which is the lease of chapter 10 in another setting. More fundamentally, the session store is treated as a hint and nothing more. If the service forwards a message to a gateway and the phone proves not to be there, nothing has been lost, since the message is safely stored and the phone will collect it when it reconnects, by the mechanism described below. The correctness of delivery does not depend on the session store’s being accurate, only its speed.
When a gateway does fail, its 100000 phones all detect the loss within a few seconds and all attempt to reconnect. If they did so immediately, the surviving gateways would receive 100000 connection attempts in the same instant, each requiring a cryptographic handshake, and might themselves be overwhelmed, causing further disconnections and further attempts. Phones therefore wait for a random interval before reconnecting and lengthen the interval after each failure, which is the backoff with jitter of chapter 13 applied by the client.
Numbering the messages of a conversation
We come now to the central problem, which is to ensure that every member of a conversation sees the same messages in the same order, without omission or repetition, in spite of everything that can happen to connections.
It is necessary first to say what “the order” is, since the obvious definition does not survive examination. One might order messages by the time at which they were sent, as recorded by the sender’s phone. But the clocks of phones differ by seconds or minutes, and a phone whose clock is slow would have its messages placed before messages to which they are replies. Nor can the times at which messages arrive at the various recipients be used, since these differ from one recipient to another, and the members would see different orders.
The order is therefore defined by decree, at a single place. All messages of a conversation pass through 1 point in the service, which assigns to each the next number in a sequence belonging to that conversation: 1, 2, 3 and so on. The order of the conversation is the order of these numbers, for every member and on every device. A number of this kind is a sequence number, and the reader will recognise it as the sequence number that the leader of a shard assigned to each record in chapter 3. The message store that assigns it is, in fact, a replicated log for each conversation, and the requirement that 2 messages never receive the same number is met in the same way, by having a single leader accept all appends to a given conversation.
The effect of this decision is to reduce the whole problem of reliable, ordered delivery to a matter of arithmetic on the recipient’s device. Each device remembers 1 number for each conversation, the number of the last message it has displayed, and compares the number of every incoming message with it.
3 distinct faults occur in the course of these steps, and the same comparison disposes of each. A message whose number is exactly 1 greater than the last displayed is the next message, and is shown. A message whose number is greater than that has arrived ahead of others that are missing, and the device does not show it, since to do so would present the conversation out of order. It asks the server for everything after the last number it has. A message whose number is equal to or less than the last displayed has been seen before, and is discarded. The simulation for this chapter runs exactly this scenario, and ends with 4 messages on the server and the same 4, in the same order and each once, on the phone.
The number that a device remembers is called its cursor, and it is what makes recovery from an absence exact. A device that has been offline does not ask the server what it has missed, a question the server could answer only by keeping track of what each device has received. The device states its position, “I have everything up to number 1 in this conversation”, and the server replies with everything after that position. The server keeps no record of deliveries and has no need to know why the device was away or for how long. A phone that was switched off for a week recovers by the same request as a phone that passed through a tunnel.
Sending without duplicating
The preceding section dealt with messages travelling to a device. A similar hazard attends messages travelling from it. A phone sends a message and waits for the server’s confirmation. If none arrives, the phone cannot distinguish between a message that never reached the server and a message that was stored and whose confirmation was lost, and since an unsent message is the worse outcome, it must send again. In the second case the server now receives the same message twice, and a server that stored whatever it received would give the 2 copies 2 successive numbers, to be displayed twice on every member’s screen.
The remedy is the idempotency key that chapters 11 and 13 used for the same purpose. The phone generates a random identifier for each message before the first attempt and includes it in every attempt. The server records the identifier with the stored message, and on receiving an identifier it has already recorded for that conversation, stores nothing and returns the number it assigned originally. A message may then be sent any number of times and will be stored once.
The same identifier serves the phone in another way. A message that has been typed and sent should appear in the sender’s own view of the conversation at once, before the server has confirmed it, since any perceptible delay makes the application feel sluggish. The phone therefore displays the message immediately, marked as pending, and when the confirmation arrives with the server’s number, it uses the identifier to find the pending message and move it to its proper place in the sequence.
Sent, delivered and read
Chat applications commonly show the sender how far a message has progressed, and the 3 customary indications correspond to 3 different events in the system. A message is sent when the server has stored it and assigned its number, at which point it is safe and will not be lost. It is delivered when a device of the recipient has received it, which the device reports by informing the server of its new cursor. It is read when the recipient has opened the conversation, which the device reports similarly, as the number of the last message actually displayed on screen.
It will be seen that delivery and reading are both conveyed by a single number for each device, with no separate acknowledgement for every message. A cursor of 40 acknowledges messages 1 to 40 together. This economy matters in large and busy conversations, and it has the further merit of being idempotent, since reporting the same cursor twice has no additional effect.
These reports are themselves events that must be conveyed to the sender’s devices, and they pass through the same machinery as messages. They are, however, of less consequence. A message that is lost is a serious failure, whereas a read indication that arrives late or is occasionally lost is a blemish, and services accordingly transmit such indications with less ceremony, without storing each durably.
Recipients who are offline
When a message is stored and the recipient has no device connected, the service has done all that it can do directly. The message waits in the store, and will be delivered by the catching-up procedure when a device next connects.
To the recipient, however, a message that waits until the application is next opened has not really arrived. The service therefore asks the notification service of chapter 13 to send a push notification through the provider that serves the phone’s operating system, which is able to wake the device and display an alert even when the application is not running. The notification is merely a signal. It may be delayed or dropped by the provider, as chapter 13 explained, and it need not carry the message itself. When the user responds to it, the application opens its connection, reports its cursor, and obtains the messages in the ordinary way. Reliability thus continues to rest on the store and the cursor, and the notification serves only to bring the user back sooner.
Mobile operating systems reinforce this division. They suspend applications that are in the background and close their connections in order to save power, so that a phone is, from the service’s point of view, offline for most of the day although it is switched on and in its owner’s pocket. The combination of a push notification to attract attention and a cursor to recover the state is the pattern to which every mobile messaging application conforms.
Groups and several devices
A conversation with many members requires no new mechanism. The message is stored once, in the conversation’s sequence, and each member’s devices keep their own cursors. What differs is the work of delivery: the chat service must look up the sessions of every member and forward the message to the gateway of each one who is online, which is the fan-out of chapter 15.
For groups of ordinary size, a few dozen or a few hundred members, this is done directly. For very large groups, of tens of thousands, direct fan-out for every message becomes as expensive as it was for the accounts with millions of followers in chapter 15, and the same remedy applies. Devices are told only that the conversation has new messages, or discover it when the conversation is opened, and then fetch from their cursors.
A member who uses a phone, a tablet and a computer has 3 devices, each of which may be online or offline independently, and each must show the complete conversation. This too is already provided for, since every device has a cursor of its own and catches up separately. A message sent from the phone reaches the member’s own computer in the same way that it reaches the other members, by being stored in the sequence and delivered to every device that has not yet reached that number. The 1 thing that is shared among a member’s devices is the position up to which the member has read, so that a conversation read on the phone is not shown as unread on the computer.
Presence
Many applications show whether a contact is at present online, which is called presence. It appears a small feature and is, relative to its importance, among the most expensive.
The difficulty is one of fan-out. Whenever a member comes online or goes offline, every contact who might be displaying that member’s status must be informed, and with phones connecting and disconnecting continually, a member with 500 contacts produces 500 notifications at each change of state. Across all members, the traffic in presence updates can exceed the traffic in messages.
Services contain this in several ways. Presence is declared to be eventually consistent, and a status that is some seconds out of date is accepted. Updates are sent only to contacts who are themselves online and have the member in view, a device subscribing to the presence of the contacts it is currently displaying and to no others. And a brief disconnection is not announced at all: the service waits some seconds before declaring a member offline, so that a phone which drops its connection and restores it at once does not cause its owner to flicker in a thousand contact lists.
The indication that someone is typing is handled in the same spirit. It is forwarded to the other participants who are online and is stored nowhere, since if it is lost nothing of value is lost with it.
Storing messages
At 20 billion messages a day, the message store receives some 230000 writes a second and grows by terabytes daily. Its pattern of access is, fortunately, very regular. Messages are always appended to the end of a conversation and almost always read as a range from the end of a conversation, the most recent 50, or everything after a cursor.
The store is accordingly keyed by conversation and, within a conversation, by sequence number, so that the messages of a conversation lie together in order and a request for a range reads 1 contiguous run. It is divided among shards by a hash of the conversation identifier, in the manner of chapter 1, so that each conversation resides entirely on 1 shard and has 1 leader assigning its numbers. A very active conversation becomes a hot key in the sense of chapter 9, and the shards must be small enough that 1 busy group cannot overwhelm its neighbours.
Recent messages are requested constantly and old ones hardly ever, since people seldom scroll back more than a few days. The store can therefore keep recent messages on fast storage and move older ones to cheaper media, as the object store of chapter 16 does with cold data.
End-to-end encryption
In the design described, the service can read every message, since messages reach it in a form it is able to store, index and search. Many chat services now offer end-to-end encryption, in which a message is encrypted on the sender’s device with keys known only to the participants’ devices and is decrypted only on the recipients’ devices. The service transports and stores ciphertext that it has no means of reading.
Nothing in the architecture of this chapter is thereby altered. The service still numbers, stores, forwards and replays messages, which are now opaque to it. What changes is everything that depended on the server’s knowledge of the contents. The server can no longer search messages, so search must be performed on each device over its own copy. It cannot compose the text of a push notification, so the notification carries ciphertext or a bare signal, and the device decrypts or fetches. A newly added device cannot be given the history by the server in readable form, and must obtain the means of reading it from another of the member’s devices. The exchange and verification of keys, which is the difficult part of such a scheme, is a subject for a book on cryptography.
Use cases
A member sends messages, receives them, and returns after absences of every length.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A member can send a message to a conversation of 2 or more members. |
| 1 | A member who is online receives a message within 500 ms. |
| 2 | A member who was offline receives every missed message after reconnecting. |
| 3 | Messages in a conversation are shown in the same order to every member. |
| 4 | A message is shown once, even when it is delivered more than once. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Durability | A message that the sender saw as sent is not lost. |
| 1 | Consistency | Strong within a conversation: 1 order of messages. Presence is eventual. |
| 2 | Availability | 99.95% for sending and receiving. |
| 3 | Scale | 50 million members online, and 20 billion messages per day. |
Order is the property that members perceive most directly, and so the sequence of each conversation is strongly consistent. A message is confirmed to its sender only when a majority of the replicas of the conversation’s log has stored it under its number, and no 2 replicas ever disagree about which message bears a given number. Everything of secondary importance, presence, typing indications and read positions, is eventually consistent, on the principle that it is better for these to be briefly wrong than for the sending of messages to wait upon them.
Core entities
Capacity estimate
Arithmetic ahead. 50 million open connections need about 500 gateway servers, and 20 billion messages per day are 230000 per second.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Connections per gateway | given | 100000 |
| Gateways | 50000000 ÷ 100000 | 500 |
| Messages per second | 20000000000 ÷ 86400 | 231000 |
| Storage per day | 20 billion × 200 bytes | 4 TB |
The API
Sending a message and catching up are ordinary HTTP requests, shown below. Live delivery takes place on the open connection, on which the server pushes each new message as a small frame carrying the conversation, the number and the content.
Send a message
POST /v1/conversations/301/messages HTTP/1.1
Host: chat.example
Idempotency-Key: phone-a-000512
Content-Type: application/json
{ "text": "are you coming?" }
HTTP/1.1 201 Created
Content-Type: application/json
{ "number": 1, "time": "2026-10-14T09:00:00Z" }
The server chooses the number. The key lets the phone send again after a lost answer without creating a second message.
Catch up after being offline
GET /v1/conversations/301/messages?after=1 HTTP/1.1
Host: chat.example
HTTP/1.1 200 OK
Content-Type: application/json
{
"messages": [
{ "number": 2, "sender": "bob", "text": "hello?" },
{ "number": 3, "sender": "bob", "text": "we are starting" },
{ "number": 4, "sender": "bob", "text": "table 4" }
]
}
after=1 is the phone’s cursor: the last number it has shown.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Gateway | holds the open connections of many phones | connections, in memory | its phones reconnect to another gateway and catch up |
| Chat service | numbers and stores messages, and forwards them | none | another instance takes the requests |
| Message store | keeps every conversation’s messages in order | messages, replicated | a follower takes over, as in chapter 4 |
| Session store | remembers which gateway each online member is connected to | sessions, in memory | phones re-register when they reconnect |
One request, step by step
The steps follow the arrows of figure 18.6.
- The sender’s phone sends the message over the connection it keeps open to gateway A.
- Gateway A passes the message to the chat service.
- The chat service asks the message store to append it. The store gives it the next number of the conversation, and the sender is told that the message is sent.
- The chat service looks up the receiver in the session store, finds gateway B, and forwards the message there. A receiver who is offline is skipped: the message waits in the store.
- Gateway B pushes the message down the receiver’s open connection.
The design, deployed
The gateway tier is the one that this system has and most others lack. Its 500 machines hold 100000 connections each, and the load balancers in front of them distribute connections, which last for hours, where other systems distribute requests. Because a connection is tied to its gateway, this tier cannot be drained quickly: replacing the software on a gateway means disconnecting 100000 phones, and deployments are therefore made in small groups of gateways. Behind the gateways, the chat service is stateless and the message store is sharded by conversation, with 3 replicas of each shard in 3 zones.
How real systems do it
WhatsApp is the best known example of how few machines the connection tier needs when it is built with care. In 2012 its engineers reported more than 2 million connections held open on a single server, running a server written in Erlang on FreeBSD, and later accounts put the figure between 2 and 3 million. The figure of 100000 for each gateway used in this chapter is therefore conservative, and the difference is mostly a matter of memory for each connection and of how much work the gateway is asked to do beyond holding it.
For storage, Discord has published 2 accounts that follow the same service as it grew, first on Cassandra when it held billions of messages and later on ScyllaDB when it held trillions. In both, messages are keyed by channel and by a bucket of time, so that the messages of a channel for a given period lie together and a request for recent history reads 1 partition, which is the layout described under storing messages. The accounts are candid about the hot key: a single very large and active channel overloads the partition that holds it, and much of the later work concerns protecting the store from that.
The numbering of messages by the server within a conversation, and the client’s cursor, are the common practice of such services. Protocols differ in the transport. Facebook described building its mobile messenger on MQTT, a lightweight protocol of publishing and subscribing designed for unreliable networks, in order to keep the connection cheap in power.
End-to-end encryption, where it is offered, is nearly always the Signal protocol, whose central mechanism derives a fresh key for every message, so that the capture of 1 key exposes neither earlier messages nor later ones. For groups, each sender distributes a key of their own to the members, which is why adding and removing members of a large encrypted group is expensive.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Ordering messages by the sender’s clock | Phones’ clocks differ, and replies appear before the messages they answer. | Let the server number the messages of each conversation. |
| Displaying whatever arrives | A message that overtakes others appears out of order, and a repeat appears twice. | Display a message only if its number follows the last one shown. |
| Sending only new messages after a reconnection | Whatever was sent during the absence is never delivered. | Have the device report its cursor, and send everything after it. |
| Tracking on the server what each device has received | The server holds a great deal of state and still cannot know what was lost in transit. | Let each device keep its cursor and report it. |
| Storing every message the server receives | A retry after a lost confirmation is stored as a second message. | Have the phone attach an identifier, and store each identifier once. |
| Trusting the session store | A message is sent to a gateway where the phone no longer is, and is considered delivered. | Treat sessions as a hint, and rely on the stored sequence and the cursor. |
| Session entries without a time limit | A crashed gateway’s phones appear connected indefinitely. | Give entries a lease that the gateway renews. |
| Reconnecting immediately after a gateway fails | Its phones overwhelm the remaining gateways. | Reconnect after a random, lengthening delay. |
| Relying on a push notification to deliver the message | Notifications are delayed and dropped, and messages with them. | Use the notification as a signal, and fetch from the cursor. |
| Announcing every change of presence to every contact | Presence traffic exceeds message traffic. | Inform only contacts who are watching, and wait before declaring someone offline. |
| Putting message logic in the gateways | Every deployment drops millions of connections. | Keep gateways minimal and deploy the logic separately. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| The receiver is offline | the message arrives later | it waits in the message store until the phone reports its cursor |
| A gateway crashes | its phones show ‘connecting’ for a moment | they reconnect elsewhere and catch up from their cursors |
| The answer to a send is lost | the sender’s phone sends again | the idempotency key returns the same message number |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Open connections | messages arrive at once | servers hold 1 connection per online phone |
| Numbers per conversation | order, gaps and repeats are handled by 1 mechanism | every message goes through the store that assigns numbers |
| Storing every message | offline members catch up | storage grows with every message |
The simulation
Simulation in the repository. The program code/chatsim simulates this design. To run it, clone the repository and type cargo run --release -p chatsim in the code directory.
References
- WhatsApp blog, “1 million is so 2011”, 2012.
- S. Vishnevskiy, “How Discord Stores Billions of Messages”, Discord blog, 2017.
- B. Ingram, “How Discord Stores Trillions of Messages”, Discord blog, 2023.
- RFC 6455, The WebSocket Protocol, 2011.
- M. Marlinspike and T. Perrin, “The Double Ratchet Algorithm”, Signal specifications, 2016.
- L. Zhang, “Building Facebook Messenger”, Facebook engineering blog, 2011.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Polling | asking at regular intervals whether anything has arrived |
| Long polling | asking, and having the server hold the request until it has an answer |
| WebSocket | a protocol for a connection that stays open, on which either side may send |
| Gateway | a server whose function is to hold many open connections |
| Session | the record that a device is connected to a particular gateway |
| Conversation | a set of members and the ordered messages exchanged among them |
| Sequence number | the position of a message in its conversation, assigned by the server |
| Cursor | the number of the last message a device has received or displayed |
| Catching up | fetching every message after a device’s cursor |
| Sent, delivered, read | stored by the server, received by a device, and displayed to the recipient |
| Presence | whether a member is currently online |
| End-to-end encryption | encryption between the participants’ devices, which the service cannot undo |
Summary
- A phone keeps 1 connection open, so that the server can deliver a message the moment it arrives.
- Gateways hold the connections, and a session store records which gateway each device is on, as a hint that may be wrong.
- The server assigns every message the next number of its conversation, and that numbering is the order for everyone.
- A device displays a message only if its number follows the last one displayed, which handles gaps, disorder and repetition alike.
- After any absence, a device reports its cursor and receives everything after it, and the server keeps no record of deliveries.
- A message carries an identifier chosen by the sending phone, so that a retry is stored once.
- Push notifications call the user back, and the stored sequence and the cursor do the delivering.
- The sequence is strongly consistent, and presence and read positions are eventually consistent.
Payment System: System Design
A payment system moves money from a customer to a shop. In its outward form it resembles many of the systems in this book, being a service that accepts requests, writes records and calls other services, and yet it differs from all of them in the consequences of its errors. When a news feed shows a post some seconds late, or a cache returns a slightly stale value, or a rate limiter admits a few requests too many, nobody is harmed, and the preceding chapters made deliberate use of that latitude in order to gain speed and availability. A payment system has no such latitude. If it charges a customer twice, real money has left a real account, and if it records a payment that did not take place, goods are shipped that were never paid for. Its errors are measured in currency, are noticed by the people affected, and in most jurisdictions are regulated by law.
The priorities of the design are in consequence the reverse of those that governed earlier chapters. Correctness is placed before availability, so that a payment system which is uncertain declines to act where another system would guess. Nothing is ever deleted or overwritten, so that every state the system has been in can be reconstructed and explained. And every operation is built on the assumption that it will be attempted more than once, since the networks and services on which payments depend fail in ways that leave the outcome of a request unknown. This chapter develops the 3 ideas on which a sound payment system rests: the idempotent request, the double-entry ledger, and the treatment of an unknown outcome as a state in its own right.
This chapter covers
- the parties to a card payment, and the stages it passes through over several days
- why a payment request must be safe to repeat, and how a key makes it so
- representing money without losing fractions of a cent
- the double-entry ledger, in which every movement is 2 entries that cancel
- why entries are never changed, and how mistakes are corrected
- a charge whose outcome is unknown, and why it is neither a success nor a failure
- reconciliation, which compares our records with the bank’s
- payments that span several services, and undoing what cannot be made atomic
- keeping card numbers out of the system
The parties and the stages
A payment by card involves more parties than the 2 that are visible at the checkout. The customer’s card was issued by a bank, which holds the customer’s account. The shop has an account at another bank. Between the 2 banks stands a card network, which carries messages between them and lays down the rules. A payment system of the kind designed here acts on behalf of the shop: it receives the shop’s request to charge a card, communicates with the network, and keeps the records. For the purposes of this chapter, the banks and the network may be regarded as a single external service, slow and occasionally unavailable, whose behaviour we can observe and cannot change.
What appears to the customer as a single event is in fact spread over several days.
In authorisation, the customer’s bank is asked whether the card is valid and the funds are available, and, if so, sets the amount aside. No money moves. The customer sees “payment accepted” at this point, within a second or 2. In capture, the shop confirms that it wishes to collect the authorised amount, which it typically does when the goods are dispatched, and the amount captured may be less than the amount authorised if part of an order could not be fulfilled. In settlement, which takes place in batches some days later, the money is actually transferred between the banks. And for months afterwards the payment may be reversed, by a refund that the shop initiates or by a dispute in which the customer’s bank reclaims the money on the customer’s complaint.
The practical consequence is that a payment is not a single action with a result. It is an object with a long life and a series of states, and at each stage our system’s record of that state must be brought into agreement with what the banks have actually done. Much of the design follows from taking this seriously.
A request that is safe to repeat
Suppose a shop’s server sends a request to charge a customer 40 euros, and the connection fails before any reply is received. The shop does not know whether the charge was made. If it does nothing, it may have failed to collect payment for an order. If it sends the request again and the first had in fact succeeded, the customer is charged 80 euros. This is the dilemma of the lost reply, which has appeared in chapters 11, 13 and 18, and here it concerns money.
It is resolved in the same way. The shop attaches to each request an idempotency key, a string of its own choosing that uniquely identifies the business event concerned, such as the number of the order. The payment system records every key together with the result of its request, and when a request arrives bearing a key that has been seen before, it performs no action and returns the recorded result. The shop may then repeat a request as often as it needs to, in complete safety, until it obtains an answer.
Four details determine whether this protection is real.
The key and the effects of the request must be recorded atomically. If the system were to move the money in 1 step and record the key in another, a crash between the 2 would leave a completed charge with no record of its key, and the shop’s retry would be treated as new. Both are therefore written in a single database transaction.
The recorded result must include failures. In the simulation for this chapter, a charge of 9000 cents is refused because the customer’s account holds only 6000. That refusal is stored under the key, and a retry of the same request receives the same refusal, even if the customer has in the meantime received funds that would now cover it. The principle is that a retry must behave as a second delivery of the same request, which it is, since the shop, having perhaps already told the customer that the payment was declined, would otherwise find that it had succeeded after all.
Concurrent requests with the same key must be dealt with. A shop’s client may retry while the first attempt is still in progress, so that 2 requests bearing the same key are being processed at once. The system reserves the key, by an atomic “create if absent”, before doing anything else, and the second request, finding the key already reserved and no result yet recorded, is told to try again shortly.
And a key should be bound to the contents of its request. If a key that was first used for a charge of 40 euros arrives with a request for 400, something is wrong in the client, and the system should reject the request. To return the result of the earlier and different one would hide the fault.
The simulation sends 5 requests, of which 2 are repetitions. The 2 requests under the
key order-7 leave the customer with the same balance of 6000, the second having
moved nothing, and likewise for order-9. The total held across all accounts is 10000
cents at the start and 10000 at the end.
Representing money
A matter that seems too elementary to need discussion has caused a remarkable number of defects. Amounts of money must not be held in the floating-point numbers that programming languages offer for fractional values.
Floating-point numbers are stored in binary, and most decimal fractions have no exact binary representation, in the same way that a third has no exact decimal one. The value 0.10 is held as a number very slightly different from a tenth, and arithmetic on such values accumulates the differences: in most languages, the sum of 0.10 and 0.20 is not equal to 0.30. A single such discrepancy is far smaller than a cent. Across millions of transactions, in sums, percentages and currency conversions, the discrepancies round in different directions in different parts of the system, and totals that ought to agree fail to agree by a few cents, which in accounting is an error to be found and explained.
Amounts are therefore held as whole numbers of the smallest unit of the currency, so that 40 euros is the integer 4000, in cents. Arithmetic on integers is exact. The currency is always stored beside the amount, since 4000 means nothing without it, and since the smallest unit is not a hundredth in every currency: some have no subdivision and others divide into a thousand. Where a calculation yields a fraction of the smallest unit, as when a fee of 2.9% is computed or an amount is divided among 3 recipients, the rule for rounding is laid down explicitly, and the remainder is assigned to a specified party, so that the parts always add up to the whole.
The ledger
The core of a payment system is its record of where money is, and the form of that record has been settled for some 500 years. It is the double-entry ledger, and its governing rule is that every movement of money is recorded as 2 entries of equal amount and opposite sign, 1 against the account the money leaves and 1 for the account it enters.
The method has 3 properties, each of which answers a need that a simpler design would leave unmet.
It cannot create or destroy money. Since every movement consists of 2 entries that cancel, the sum of all entries in the ledger is 0 at all times, whatever has happened. Should the sum ever differ from 0, the ledger contains an error, and this is discovered by a single addition, with no need to understand any individual transaction. A design that kept only a balance for each account and adjusted the 2 balances in turn would have no such check. A defect that deducted from 1 account and failed to credit the other would cause money to vanish, and nothing would reveal it until a customer complained.
It explains every balance. The balance of an account is defined as the sum of that account’s entries, and so for any balance one can produce the complete list of movements that gave rise to it. When a shop asks why its balance is what it is, the answer is a list of entries, each referring to the payment that caused it.
And its history is permanent. Entries are added and never altered or removed, so that the ledger is an append-only log of the same kind as the log of chapter 2. An error is corrected by adding entries that reverse it, as the refund in the steps above reverses the first payment, and the record then shows both the error and its correction. This is what makes the system auditable. If a row could be edited, then a mistaken edit, or a dishonest one, would leave no evidence, whereas in an append-only ledger whatever has once been recorded remains for inspection.
The 2 entries of a movement must be written atomically, both or neither. This is the principal reason why the ledger is kept in a database with transactions and strong consistency, and why the weaker models that sufficed elsewhere in this book are out of place here.
Balances in practice
To define a balance as the sum of an account’s entries is correct and, taken literally, slow, since an account with a million entries would require a million additions for each reading. The system therefore keeps a stored balance for each account as well and updates it in the same transaction that adds the entries. The entries remain the authority. The stored balance is a cache of their sum, and it can be verified against them, or recomputed from them, at any time.
Checking that a customer has sufficient funds and then deducting the amount is a sequence of reading and writing, and it is exposed to the race that has appeared several times in this book: 2 simultaneous payments from the same account each read a balance of 6000, each conclude that 4000 can be afforded, and each deduct, leaving the account overdrawn. The check and the deduction have to be a single atomic step, which is obtained either by having the transaction lock the account’s row before reading it, or by making the update conditional, in the form “deduct 4000 if the balance is at least 4000”.
An account that takes part in a very large number of payments, such as that of a major shop, becomes a point of contention under this arrangement, since every payment to that shop must lock the same row. A usual remedy is to add the entries at once and to bring the shop’s stored balance up to date a little later, in batches. For an account that only receives money, a balance that lags by a few seconds is harmless. For the account from which a payment is made, the balance must be exact at the moment of the check.
A charge whose outcome is unknown
The ledger is under our control. The card network is not, and a request to it can come to 3 ends, of which the third is the source of most of the difficulty in the subject. The network may reply that the charge succeeded. It may reply that the charge was declined. Or no reply may arrive within the time allowed.
In the third case our system does not know whether the customer has been charged. Both of the natural reactions are mistaken. To treat the timeout as a failure and inform the shop that the payment did not go through is wrong if the charge in fact succeeded, since the customer has then paid for an order that the shop believes unpaid. To send the charge again is wrong for the same reason, since the customer may then be charged twice.
The correct treatment recognises “unknown” as a legitimate state of a payment, with a name and a procedure of its own.
The steps display 3 practices that are worth stating in general terms.
The payment is recorded as pending in our own system before the network is called. If our process crashes during the call, the record survives to say that a charge was in progress and under what reference, so that it can be followed up on restarting. Had we called the network first and recorded afterwards, a crash between the 2 would leave a customer charged and our system unaware that any payment had been attempted.
The request to the network carries a reference that we generate, and the network supports either the repetition of a request under the same reference without a second charge, or a query on the status of a reference. This is our own idempotency key, presented to the next party along. Without some such facility at the network, the dilemma of the lost reply cannot be resolved at all, and a payment system must select its external partners with this requirement in mind.
And the outcome is determined by asking. A background process takes each pending payment in turn and queries the network until it obtains a definite answer, upon which the payment moves to done or failed and the ledger entries are written. The shop, which was told that the payment was pending, is informed of the final outcome by a call to an address it has registered, the webhook of chapter 13.
The states of a payment thus form a small machine: created, pending, and then either done or failed, with later movements to refunded or disputed. Every transition is recorded with its time and its cause, and certain transitions are prohibited outright. A payment that is done never returns to pending, and a refund is a new movement with entries of its own.
Reconciliation
However carefully all of this is constructed, our records and the bank’s will sometimes differ. A reply was lost and the query that should have resolved it was itself mishandled. A defect in our software caused a step to be skipped. A dispute was raised at the bank and never communicated to us. The bank made an error of its own.
A payment system therefore does not assume that its records are right. Every day it obtains from the bank a statement of what the bank did in fact process, and compares that statement, payment by payment, with its own ledger. This comparison is called reconciliation.
Each discrepancy falls into 1 of a few classes: a payment that the bank has and we lack, a payment that we have and the bank lacks, or a payment present in both with different amounts or states. Each is investigated, automatically where a rule can decide and by a person where it cannot, and is corrected by adding entries to the ledger, in accordance with the rule that nothing already recorded is altered.
Reconciliation is the final safeguard of the whole system. Idempotency keys, atomic transactions and the handling of pending payments are all intended to prevent discrepancies, and reconciliation is what detects those that occur in spite of them, including any that arise from mistakes in the preventive mechanisms themselves. A payment system without it is correct only so long as all of its code is free of defects, which is to say that it is not correct.
Payments that span several services
Placing an order usually requires more than a payment. Stock has to be reserved, the payment taken, and a shipment arranged, and in a system divided into services each of these belongs to a different service with a database of its own. One would wish the 3 to be atomic, so that either all take place or none does, and no single database transaction can span 3 databases.
There is a protocol, called 2-phase commit, that makes several databases commit together. A coordinator first asks every participant to prepare and to promise that it will be able to commit, and when all have promised, instructs them all to do so. The protocol achieves atomicity and is seldom used between services, for 2 reasons. Each participant must hold its locks from the moment of its promise until the instruction arrives, and if the coordinator fails in that interval the participants are left holding locks indefinitely, unable to proceed in either direction. And every participant, including external ones such as a card network, must implement the protocol, which external services do not.
The usual alternative gives up atomicity and substitutes for it the assurance that the process will eventually be either completed or undone. The operation is divided into steps, each a local transaction in a single service, and for every step there is defined a compensating action that reverses its effect: releasing the reserved stock, refunding the payment, cancelling the shipment. The steps are executed in order, and if one fails, the compensating actions of those already completed are executed in reverse. A sequence of steps organised in this way is called a saga.
A saga is not a transaction, and the difference is observable. While it is in progress, other parts of the system can see its intermediate states: the stock is reserved before the payment has been taken, and for a short time a customer may have been charged for an order that is about to be cancelled. Compensation, moreover, is not erasure. A refunded payment appears on the customer’s statement as a charge and a refund, and a confirmation email that has been sent cannot be recalled. The design of a saga consists largely in ordering its steps so that those most easily reversed come first and those that cannot be reversed come last.
The mechanisms needed to run a saga dependably are those of earlier chapters. The steps are driven by messages on a queue, as in chapter 11, and each service publishes its message by means of an outbox, in the same transaction as its own change. Every step and every compensating action is idempotent, since the queue delivers at least once.
Keeping card numbers out
A card number, in combination with its expiry date and security code, is all that is required in order to spend someone else’s money, and a system that stores card numbers is a target for theft in exact proportion to the number it holds. The card industry imposes a demanding security standard on any system that handles them, with audits whose burden grows with every component the numbers pass through.
The most effective measure is therefore structural: to arrange that card numbers pass through as little of the system as possible. The form in which the customer enters the card is served by, and submits directly to, a small and isolated service called a vault, whose sole purpose is to hold card numbers. The vault returns a token, a random identifier that stands for the card and has no value outside this system. The remainder of the system, including the shop’s servers, the payment API and the ledger, stores and handles only the token. When a charge is to be made, the token is presented to the vault, which alone communicates the real number to the card network. A breach of any component other than the vault then yields tokens, which are of no use to a thief.
Use cases
Shops charge customers and refund them. Accountants read the ledger, in order to confirm that it balances.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A shop can charge a customer an amount. |
| 1 | A request that is sent again with the same key has no second effect and returns the first result. |
| 2 | A shop can refund a payment, in full or in part. |
| 3 | Every payment is recorded as entries in a ledger, and the entries of a payment add up to 0. |
| 4 | An accountant can list the entries of any account. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Consistency | Strong. Both entries of a payment are written, or neither. |
| 1 | Durability | A recorded payment is not lost under any single failure. |
| 2 | Availability | 99.99%. When in doubt, the system refuses a payment and does not guess. |
| 3 | Scale | 1000 payments per second. |
This is the chapter in which strong consistency prevails in every dispute. The 2 entries of a movement, the stored balance and the result recorded under the idempotency key are all written in 1 transaction on a store that confirms a write only when a majority of its replicas hold it. When the store cannot reach a majority, payments are refused. A payment system that is unavailable for a minute loses some sales, and a payment system that is inconsistent loses money and, with it, the confidence of the people whose money it was.
Core entities
Capacity estimate
Arithmetic ahead. 1000 payments per second are 86 million per day and about 35 GB of ledger entries per day.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Payments per day | 1000 × 86400 | 86.4 million |
| Entries per day | 86.4 million × 2 | 172.8 million |
| Storage per day | 172.8 million × 200 bytes | 34.6 GB |
The API
A charge is a new resource and is created with POST, a method that HTTP does not
define as idempotent, and this is exactly why the API demands a key with every such
request.
Charge a customer
POST /v1/payments HTTP/1.1
Host: pay.example
Idempotency-Key: order-7
Content-Type: application/json
{ "from": "ada", "to": "shop", "amount": 4000, "currency": "EUR" }
HTTP/1.1 201 Created
Location: /v1/payments/9001
Content-Type: application/json
{ "id": 9001, "state": "done", "amount": 4000, "currency": "EUR" }
Amounts are whole numbers of cents. Fractions of a currency are not stored as floating-point numbers, which cannot represent 0.10 exactly.
The same request again
POST /v1/payments HTTP/1.1
Host: pay.example
Idempotency-Key: order-7
Content-Type: application/json
{ "from": "ada", "to": "shop", "amount": 4000, "currency": "EUR" }
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 9001, "state": "done", "amount": 4000, "currency": "EUR" }
The same payment comes back with 200, and no money moves.
A charge without enough money
POST /v1/payments HTTP/1.1
Host: pay.example
Idempotency-Key: order-8
Content-Type: application/json
{ "from": "ada", "to": "shop", "amount": 9000, "currency": "EUR" }
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "/problems/insufficient-funds",
"title": "The account does not hold this amount",
"status": 422
}
422 is the status for a request that is well formed and cannot be carried out.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Payment API | checks the key and the request | none | another instance answers |
| Key store | remembers the result of every key | keys, replicated | a follower takes over; until then payments are refused |
| Ledger | writes the 2 entries of a payment in 1 transaction | every entry, replicated | a follower takes over; until then payments are refused |
| Card network | moves money between banks | outside our control | the payment stays pending and is checked again |
One request, step by step
The steps follow the arrows of figure 19.7.
- A shop sends
POST /v1/paymentswith the keyorder-7. - The API looks up
order-7in the key store. If a result is stored, it is returned and nothing else happens. - For a new key, the ledger checks the balance and writes 2 entries, minus 4000 for the customer and plus 4000 for the shop, together with the key’s result, in 1 transaction.
- The card network is asked to move the money between the banks. Its answer can take seconds, so the payment is first pending and becomes done when the answer arrives.
The design, deployed
By the standards of this book the payment system is small, since 1000 payments per second is a modest load, and its deployment is shaped by correctness and isolation more than by scale. The payment API is stateless. The ledger is a single database with a leader and 2 followers in 3 zones, which confirms a write only when a follower in another zone holds it. The vault that holds card numbers is drawn in red because it is a separate system with its own network, its own access rules and its own audit, which the rest of the system can reach only to exchange a token. The background tier carries the work that follows a payment: publishing events from the outbox, asking the card network about pending payments, and the nightly reconciliation.
How real systems do it
The idempotency key is the centrepiece of Stripe’s public API, and an engineer of that company has published the most detailed account of implementing it. The account divides a request into atomic phases, each a database transaction that ends at a call to an outside service, and records after each phase how far the request has progressed, so that a retry with the same key resumes from the last completed phase and does not start again. This is a more careful version of the pending state described above, generalised to requests with several outside calls. Airbnb has described a similar design for its own payments, in which a request is divided into a phase before the outside call, the call itself and a phase after it, with the rule that no outside call may be made inside a database transaction.
The saga comes from a paper of 1987, which proposed it for transactions that run too long to hold locks. The 2-phase commit protocol and its weakness, the participant left in doubt when the coordinator fails, are described in the classic literature on transaction processing.
For the ledger, several companies have described building append-only stores specifically for double-entry records, with the immutability enforced by the store itself, whatever the application attempts. Whatever the store, the rule of keeping card numbers in an isolated component is not a matter of taste. The security standard of the card industry applies to every system that stores, processes or transmits card numbers, and the tokenising vault exists to keep as much of the system as possible outside its scope.
What this chapter leaves out is considerable. A real card payment involves a standard message format between the parties, further authentication of the cardholder in many countries, the screening of every payment for fraud before it is authorised, the conversion of currencies, and the rules of each card network on disputes. Each is a subject in itself, and none of them changes the 3 ideas on which the chapter rests.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| A charge without an idempotency key | A retry after a lost reply charges the customer twice. | Require a key, and record the result of every key. |
| Recording the key separately from the charge | A crash between the 2 leaves a charge whose retry is treated as new. | Write the key, the entries and the result in 1 transaction. |
| Not recording failures under the key | A retry of a declined charge may succeed after the shop has reported failure. | Store every outcome, and return it for every retry. |
| Amounts in floating-point numbers | Totals differ by cents across millions of transactions. | Store whole numbers of the smallest unit, with the currency. |
| Adjusting 1 balance and then the other | A crash in between destroys or creates money. | Write both entries of a movement in 1 transaction. |
| Editing or deleting ledger rows | History is lost, and mistakes and fraud leave no trace. | Add reversing entries, and never change existing ones. |
| Reading a balance and then deducting | 2 simultaneous payments both pass the check and overdraw the account. | Lock the account, or make the deduction conditional on the balance. |
| Treating a timeout as a failure | The customer was charged, and the shop is told otherwise. | Keep the payment pending, and ask the network for its outcome. |
| Sending a timed-out charge again without a reference | The customer is charged twice. | Give every charge a reference by which the network recognises a repeat. |
| Calling the network before recording the payment | A crash leaves a charge of which the system has no knowledge. | Record the payment as pending first. |
| No reconciliation | Differences from the bank’s records accumulate unnoticed. | Compare the ledger with the bank’s statement every day. |
| Treating a saga as a transaction | Intermediate states are seen by others, and some steps cannot be undone. | Put reversible steps first and irreversible ones last. |
| Card numbers in ordinary databases and logs | A breach anywhere exposes cards. | Confine card numbers to a vault, and use tokens elsewhere. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| The answer to a charge is lost | the shop retries | the key returns the first result |
| The card network times out | the payment shows as pending | the system asks the network about that payment until it gets a definite answer |
| The ledger’s leader fails | payments are refused for about 1 second | a follower is elected, as in chapter 4 |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Strong consistency everywhere | money is not lost or duplicated | payments are refused during a failover |
| A key on every request | retries are safe | the result of every key has to be stored |
| Entries, with balances computed from them | every balance can be explained and checked | more data than 1 number per account |
The simulation
Simulation in the repository. The program code/paysim simulates this design. To run it, clone the repository and type cargo run --release -p paysim in the code directory.
References
- B. Leach, “Implementing Stripe-like Idempotency Keys in Postgres”, 2017.
- Stripe API reference, “Idempotent requests”.
- J. Chew, “Avoiding Double Payments in a Distributed Payments System”, Airbnb engineering blog, 2019.
- H. Garcia-Molina and K. Salem, “Sagas”, SIGMOD 1987.
- J. Gray and A. Reuter, Transaction Processing: Concepts and Techniques, Morgan Kaufmann, 1992.
- PCI Security Standards Council, Payment Card Industry Data Security Standard.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Card network | the organisation that carries payment messages between banks |
| Authorisation | asking the customer’s bank to approve an amount and set it aside |
| Capture | the shop’s confirmation that an authorised amount is to be collected |
| Settlement | the actual transfer of money between banks |
| Refund | a return of money initiated by the shop |
| Dispute | a reversal initiated by the customer through their bank |
| Idempotency key | a name the caller gives a request, so that a repeat has no further effect |
| Ledger | the append-only list of all entries, which is the record of where money has gone |
| Double entry | recording every movement as 2 entries that add up to 0 |
| Entry | 1 line of the ledger: an account, an amount with a sign, and the payment that caused it |
| Pending | the state of a payment whose outcome is not yet known |
| Reconciliation | comparing 2 records of the same payments in order to find differences |
| 2-phase commit | a protocol by which several databases commit together or not at all |
| Saga | a sequence of local transactions, each with a compensating action |
| Compensating action | an operation that reverses the effect of an earlier step |
| Token | a random identifier that stands for a card number |
| Vault | the isolated service that alone holds card numbers |
Summary
- A payment system places correctness before availability, and declines to act when it is uncertain.
- A card payment is an object with a life of days: authorisation, capture, settlement, and possibly refund or dispute.
- Every request carries an idempotency key, recorded atomically with its result, so that a repeat changes nothing.
- Money is held as whole numbers of the smallest unit of its currency.
- Every movement is 2 ledger entries that add up to 0, written together and never altered afterwards.
- A charge whose reply is lost stays pending until its outcome has been established by asking the network.
- Reconciliation compares the ledger with the bank’s statement every day and catches what everything else missed.
- Operations that span several services are run as sagas, with compensating actions in place of atomicity.
- Card numbers are confined to a vault, and the rest of the system handles tokens.
Ticket Booking: System Design
A booking system sells things of which there is a fixed and usually small supply: the seats of a cinema for a particular showing, the rooms of a hotel for a particular night, the places on a particular flight. Each such thing can be sold once, and the entire purpose of the system is to see that it is not sold twice. 2 travellers who arrive at the airport holding confirmations for seat 14C on the same flight have been failed by the airline’s booking system in the most direct way possible, and no apology alters the fact that 1 of them will not be sitting there.
The requirement sounds elementary, and on a single machine serving 1 customer after another it is. It becomes difficult under the conditions in which booking systems in fact operate. Many customers act at the same time, on many servers, and 2 of them will sooner or later choose the same seat in the same instant. A customer who has chosen a seat needs some minutes in which to pay, and may abandon the purchase without saying so. And demand is sometimes of a different order from supply, as when the tickets for a concert go on sale and 2 million people arrive within a minute to compete for 50000 seats. This chapter is about keeping the 1 essential promise, that a seat belongs to at most 1 buyer, under each of these conditions in turn.
This chapter covers
- the promise that a seat is sold at most once, and where it has to be enforced
- 2 buyers choosing the same seat at the same moment
- 3 ways of making the sale of a seat atomic
- holding a seat while its buyer pays, and what happens when the buyer does not
- a payment that arrives after its hold has run out
- booking several seats together
- a seat map that is allowed to be slightly wrong
- selling a number of places without numbered seats
- a sale for which far more buyers arrive than there are seats
The promise
It is useful to state exactly what the system promises before considering how. The promise is an invariant, a condition that must hold at every moment and that no sequence of events may be allowed to violate: every seat of every showing is in exactly 1 of the states free, held or sold, and when it is held or sold, it is held by or sold to exactly 1 buyer.
Everything else in the system is permitted to be approximate. The picture of the auditorium that a customer sees may be a few seconds out of date. The count of remaining seats shown on the event’s page may be slightly wrong. The confirmation email may arrive a minute late. None of these does lasting harm, and each can therefore be served from caches and queues by the methods of earlier chapters. The state of each seat cannot be approximate, and so the design begins by identifying the single place in which that state is kept and making sure that every change to it passes through an operation that cannot break the invariant.
That place is the seat store, and it is strongly consistent in the sense of chapter
- It is replicated by the leader and majority method of chapters 3 and 4, so that a change to a seat is confirmed only when a majority of replicas hold it and no 2 replicas can disagree about who owns a seat.
2 buyers and 1 seat
The simplest implementation of a purchase reads the seat, checks that it is free, and writes the buyer’s name against it. Between the reading and the writing there is an interval, and the entire difficulty of concurrent booking lies in that interval.
Each request, taken alone, behaved correctly. It read the seat, found it free, and wrote. The fault lies in the combination, in which the second request’s decision was based on a reading that the first request’s write had already made obsolete. This is the same race that produced 2 leases for 1 job in chapter 12 and 2 forwarded requests for 1 token in chapter 7, and it will arise in any design that separates a decision from the reading on which the decision rests.
It is also far from rare. The seats in a hall are not chosen uniformly: everybody wants the middle of the front rows, and on the first morning of a popular sale, hundreds of buyers are looking at the same few dozen seats.
Making the sale atomic
The reading and the writing must be joined into a single step that the store carries out in its entirety before attending to anything else. There are 3 established ways of doing so.
The first is a lock taken in advance. The transaction that handles a purchase asks the database to lock the row of the seat before reading it. A second transaction that wants the same row has to wait until the first has finished, by which time the seat is taken, and the second, reading it now, finds it so. This approach, in which a lock is taken on the assumption that a conflict is likely, is called pessimistic. It is straightforward and correct, and its cost is that buyers queue behind one another for popular seats, each holding a database connection while it waits. If the holder of a lock is slow, everyone behind it is slow as well.
The second is a conditional write. The request does not lock anything. It issues a single statement of the form “set this seat to held by Ada, provided that it is currently free”, and the store reports whether the condition was met. Of 2 simultaneous statements for the same seat, the store executes one first, which succeeds, and then the other, whose condition is now false and which therefore changes nothing. This is the compare-and-set of chapter 1, and it is called optimistic, since it proceeds on the assumption that conflicts are unusual and merely detects them when they occur. Nobody waits. A buyer who loses the race is told immediately and chooses again.
The third relies on a uniqueness constraint. Bookings are kept in a table of their own, and the database is instructed that no 2 rows of that table may have the same showing and seat. A purchase is then an attempt to insert a row, and of 2 simultaneous attempts for the same seat the database accepts 1 and rejects the other as a violation of the constraint. The invariant is thereby enforced by the database itself, independently of the application’s logic, and it continues to hold even if some future code path forgets to check.
| Lock in advance | Conditional write | Uniqueness constraint | |
|---|---|---|---|
| What a losing buyer experiences | waits, then is refused | is refused at once | is refused at once |
| Database connections held while waiting | yes | no | no |
| Behaviour when many want 1 seat | a queue forms | 1 wins, the rest retry elsewhere | 1 wins, the rest retry elsewhere |
| Protection if application code is wrong | none | none | the database still refuses |
Our design uses the conditional write for changes of a seat’s state and, in addition, a uniqueness constraint on confirmed bookings, as a second line of defence that does not depend on every part of the application being correct.
Holding a seat while the buyer pays
A purchase is not instantaneous. The buyer selects a seat, then enters the details of a card, and the payment itself takes some seconds to be authorised, as chapter 19 described. During this time the seat must not be sold to anyone else, since it would be intolerable to take a buyer’s payment and then report that the seat has gone. Yet the seat cannot be marked as sold either, since the buyer may never complete the payment.
The seat is therefore given an intermediate state, held. A hold reserves the seat for 1 buyer for a limited period, commonly between 5 and 15 minutes. If the buyer pays within that period, the seat becomes sold. If not, the hold lapses and the seat is available again. A hold is a lease in the sense of chapter 10, granted on a seat.
The simulation for this chapter plays out these events on a hall of 3 seats and ends with seat 1 sold to Bob, Ada’s late attempt to pay having been refused.
The time limit is what prevents a seat from being lost to sale. Without it, every buyer who closed the browser in the middle of a purchase would leave a seat permanently reserved, and within a few hours of a busy sale the hall would appear full while a large part of it had been paid for by nobody. The hold must carry its expiry time within itself, as a field of the seat’s record, so that its validity can be judged by anyone who reads the record, at any time, by a comparison with the clock.
This last point determines how expiry is implemented. One possibility is a background process, sometimes called a reaper, that periodically searches for holds whose time has passed and sets those seats back to free. The simpler and more dependable method requires no such process for correctness. A held seat whose expiry time has passed is treated as free by every operation that examines it, so that the conditional write for a new hold reads “set this seat to held by Bob, provided that it is free or that its hold has run out”. The seat becomes available at the exact moment of expiry without anything having to happen at that moment. A background process may still tidy the records, and nothing depends on its punctuality.
The length of the hold is a compromise between 2 kinds of loss. A long hold gives buyers ample time and keeps abandoned seats out of circulation for longer, which in a sale that will sell out matters a great deal. A short hold returns seats quickly and causes more buyers to lose their seat while they are still typing. Systems commonly display the remaining time to the buyer, which both informs and hurries.
A further abuse must be anticipated. Someone who wishes to disrupt a sale, or to reserve seats for resale, can request holds on a great many seats with no intention of paying, and so remove them from sale for the duration of each hold. Holds are therefore limited in number for each account and each session, and repeated abandonment by the same party is noticed and restricted.
Payment and the hold
The hold and the payment are separate operations in separate systems, and the interval between them contains a case that has to be decided in advance.
Suppose a buyer submits payment 10 seconds before the hold is due to expire, and the authorisation takes 15 seconds. When the payment system reports success, the hold has lapsed, and the seat may already have been taken by somebody else. The booking system now holds a successful payment for a seat it can no longer deliver.
2 measures deal with this, and a careful system uses both. When the buyer submits payment, the hold is first extended by an amount comfortably longer than a payment can take, in a conditional write that succeeds only if the hold is still valid and still belongs to this buyer. If the extension fails, the buyer is told that the seat is gone and no payment is attempted. And when the payment succeeds, the conversion of the hold into a sale is itself conditional on the seat’s still being held by this buyer. In the rare case that this last write fails nonetheless, the system refunds the payment at once, by the procedure of chapter 19, and informs the buyer. The invariant is never sacrificed to complete a payment: it is always the money that is returned and never the seat that is double-sold.
The whole exchange must also be idempotent, for the reasons given at length in chapter 19. The request that confirms a booking carries a key, and a repetition of it, after a lost reply, returns the original booking and neither charges again nor fails for want of a hold that the first attempt already consumed.
Several seats together
A family books 4 seats and wishes to have all 4 or none. A booking that secured 2 of them and then failed on the third would leave the family with 2 seats it does not want and 2 other buyers refused for no purpose.
The seats of a single showing normally reside in the same shard of the seat store, and the 4 conditional writes can then be made within 1 transaction, which succeeds only if every one of them does. Where a database with locks is used, a hazard arises that is worth describing because it recurs wherever several locks are taken. If 1 request locks seat 5 and then asks for seat 6, while another locks seat 6 and then asks for seat 5, each waits for the other indefinitely. This is a deadlock, and the customary prevention is for every request to take its locks in the same fixed order, by seat number for example, so that no such cycle can form.
The seat map
Before choosing, a buyer looks at a picture of the hall showing which seats are free. This picture is requested far more often than seats are bought, since every buyer looks, most look several times, and many look without buying.
To assemble the picture from the seat store on every request would place the heaviest load on the 1 component that must remain fast and correct for the sake of the sales themselves. The seat map is therefore served from a cache, of the kind described in chapter 9, which is refreshed every second or 2 or updated by messages announcing each change of a seat’s state.
The map is consequently eventually consistent. A buyer may be shown a seat as free that was taken half a second earlier. This is entirely acceptable, and the reason should be clearly understood, since it illustrates a principle of wide application. The map is advisory. It influences which seat a buyer attempts to take, and it decides nothing. The decision is made by the conditional write to the seat store, which is always correct, and a buyer who acts on stale information is refused and shown a fresh map. An approximate display backed by an exact transaction is a combination that appears throughout well-designed systems. It would be a serious error to reverse the roles, allowing a sale to proceed because a cached map showed the seat as free.
Places without numbers
Many events sell admission without assigning seats: 5000 places on the floor of a concert hall, any of which is as good as any other. The inventory is then a count, with no distinct seats, and the invariant becomes that the number sold shall never exceed the capacity.
The same reasoning applies in a simpler form. To read the count, check that it is below the capacity, and write it back increased by 1 is the race already described, and would oversell. The sale must be a conditional update, “increase the number sold by 2, provided that the result does not exceed 5000”, which the store applies atomically.
A single counter for a popular event is, however, a single row that every purchase must modify, and under very heavy demand it becomes a point of contention, the hot key of earlier chapters. A common remedy divides the inventory in advance into several portions, 50 portions of 100 places for example, each a separate counter, and directs each buyer to a portion chosen at random. Purchases then contend only within a portion. The portions empty at different rates, so that a buyer may occasionally be refused by an exhausted portion while places remain in others and must be redirected, and this complication is the price of removing the bottleneck.
It should be mentioned that some businesses sell more places than they possess, by policy. Airlines know from experience that a certain proportion of passengers do not appear, and sell a correspondingly greater number of tickets than there are seats. The invariant has not failed in such a case. It is a different invariant, with a capacity set above the physical number, and the system enforces the limit it is given exactly as before.
A sale with far more buyers than seats
When the tickets for a greatly anticipated event are released, the load on the system bears no relation to anything it experiences at other times. 2 million people may arrive in the first minute for 50000 seats. Of all those requests, at most 50000 can end in a sale, and the remaining 1950000 are certain to be disappointed. The task of the system is to conduct the 50000 sales correctly and to disappoint the others cheaply, without the weight of the crowd bringing down the machinery on which the sales depend.
No amount of capacity in the booking system itself is a satisfactory answer, since the seat store’s rate of work is limited by the need for strong consistency, and provisioning for a peak that lasts for minutes and occurs a few times a year is wasteful. The crowd is therefore kept away from the booking system altogether.
A waiting room is a very simple service placed in front of the sale. Each person who arrives is assigned a position in a queue and shown a page that reports the position and refreshes occasionally. Serving this page requires almost no work and can be done from the edge servers of chapter 17. People are admitted from the queue to the booking system proper at a controlled rate, matched to what that system can serve comfortably, and each person admitted receives a signed token, valid for a limited time, without which the booking system accepts no request. The token is what makes the arrangement enforceable, since a person cannot skip the queue by addressing the booking system directly.
The effect is to convert a peak that the booking system could not survive into a steady load that it handles with ease, the excess being absorbed by a component designed for nothing else. It is the load levelling that chapter 11 attributed to queues, applied to people. It also restores a measure of fairness, since admission follows the order of the queue, where it would otherwise fall to whoever’s request happened to get through an overloaded server.
Whether the order of the queue should be the order of arrival is itself a question of policy. When a sale opens at a known moment, the people who arrive in the first second are separated by network delays and nothing else, and strict order of arrival rewards those with the fastest connections and, above all, automated programs. Many systems therefore gather everyone who arrives before the opening into a pool and assign their positions at random when the sale begins.
Automated purchasing is the abiding adversary of such sales. Programs operated by resellers can submit requests faster than any person and from thousands of addresses. The defences are layered and none is complete: rate limits for each account and each address, as in chapter 7; a limit on the number of tickets for each buyer; challenges that are easy for people and costly for programs; and the requirement of an account with some history. The waiting room is where most of these checks are applied, before a request has cost the booking system anything.
Use cases
A buyer looks at the seats of a showing, holds one, and pays for it.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A buyer can see which seats of a showing are free. |
| 1 | A buyer can hold a free seat for 5 minutes. |
| 2 | A buyer can pay for a seat that they hold, upon which the seat is sold to them. |
| 3 | A hold that is not paid for within its time ends, and the seat is free again. |
| 4 | A seat is held by, or sold to, at most 1 buyer at any moment. |
| 5 | A buyer can book several seats together, receiving all of them or none. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Consistency | Strong for the state of every seat. Eventual for the seat map. |
| 1 | Availability | 99.95% for booking. When the seat store cannot reach a majority, sales stop. |
| 2 | Latency | A hold is granted or refused within 200 ms. |
| 3 | Scale | 100000 showings on sale, and a peak of 2 million arrivals in a minute for a single showing. |
Non-functional requirements 0 and 1 belong together. The state of a seat is the 1 thing in this system that may never be wrong, and the design accordingly accepts that when the seat store has lost its majority, no seats can be sold until it is restored. An hour without sales is a loss of revenue. A hall with 2 buyers for 1 seat is a loss of the customers’ trust, and cannot be repaired by restarting anything.
Core entities
Capacity estimate
Arithmetic ahead. The seats of 100000 showings fit in a few gigabytes. The difficulty of this system lies in the peak of arrivals. The volume of data is small.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Seats on sale | 100000 showings × 300 seats | 30 million |
| Storage for seats | 30 million × 100 bytes | 3 GB |
| Arrivals at the peak | 2000000 ÷ 60 seconds | 33000 per second |
| Admitted to booking | chosen to suit the seat store | 500 per second |
| Time to sell 50000 seats | 50000 ÷ 500 per second | 100 seconds |
The API
A hold is a resource created within a showing, and a booking is a resource created
from a hold. Both are created with POST, and both carry an idempotency key.
See which seats are free
GET /v1/shows/881/seats HTTP/1.1
Host: tickets.example
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=2
{
"seats": [
{ "label": "A1", "state": "free" },
{ "label": "A2", "state": "held" },
{ "label": "A3", "state": "sold" }
]
}
The header Cache-Control permits this answer to be reused for 2 seconds, which
states openly that the map may be that much out of date.
Hold a seat
POST /v1/shows/881/holds HTTP/1.1
Host: tickets.example
Idempotency-Key: 5d1c-hold-1
Content-Type: application/json
{ "seats": ["A1"] }
HTTP/1.1 201 Created
Location: /v1/shows/881/holds/7730
Content-Type: application/json
{ "id": 7730, "seats": ["A1"], "expires": "2026-10-14T10:05:00Z" }
Hold a seat that somebody else has taken
POST /v1/shows/881/holds HTTP/1.1
Host: tickets.example
Idempotency-Key: 9a2e-hold-1
Content-Type: application/json
{ "seats": ["A1"] }
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "/problems/seat-taken",
"title": "The seat is no longer available",
"status": 409,
"seats": ["A1"]
}
409 Conflict is the status for a request that is valid in itself and cannot be
carried out because of the present state of the resource.
Pay for a held seat
POST /v1/bookings HTTP/1.1
Host: tickets.example
Idempotency-Key: 5d1c-book-1
Content-Type: application/json
{ "hold": 7730, "payment_token": "tok_83hf" }
HTTP/1.1 201 Created
Location: /v1/bookings/55102
Content-Type: application/json
{ "id": 55102, "show": 881, "seats": ["A1"], "state": "confirmed" }
The payment token stands for the buyer’s card, as chapter 19 explained, so that the booking system never handles a card number.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Waiting room | admits buyers at a rate the booking system can serve | queue positions, in memory | positions are assigned again, and those already admitted keep their tokens |
| Booking API | turns requests into conditional writes | none | another instance serves the request |
| Seat store | holds the state of every seat | seats, holds and bookings, replicated | a follower is elected, and sales pause for about a second |
| Seat map cache | serves the picture of the hall | a copy, up to 2 seconds old | the map is rebuilt from the seat store |
| Payment system | charges the buyer | see chapter 19 | holds are extended, and bookings wait |
One request, step by step
The steps follow the arrows of figure 20.6.
- A buyer asks to hold seat A1 of showing 881.
- The booking API sends the seat store 1 conditional write: set A1 to held by this buyer until 10:05, provided that A1 is free or its hold has run out. The store answers that the write was applied, and the API returns the hold. Had the condition been false, the API would have answered
409. - The buyer submits payment. The API first extends the hold, conditionally, and then asks the payment system to charge the buyer. When the charge succeeds, the API sends a second conditional write: set A1 to sold to this buyer, provided that it is still held by them.
- Independently of any request, holds whose time has passed are cleared from the records. No sale depends on this, since an expired hold is already treated as free.
The design, deployed
The figure shows where the load goes. At the opening of a great sale, 33000 people arrive each second, and they are met by the edge servers and the waiting room, which do almost no work for each of them. Only 500 a second pass through to the booking tier, which is an ordinary arrangement of load balancers, stateless API servers and a cache for the seat map. The seat store at the bottom, 3 replicas of each shard with shards assigned by showing, therefore sees a load that it can serve with strong consistency. The numbers of machines in the lower tiers do not change between an ordinary day and the largest sale.
How real systems do it
The mechanisms of this chapter are those of database practice more than of any 1 published system, and the references are accordingly to the foundations.
Optimistic concurrency control, the method of proceeding without locks and checking at the moment of writing whether anything has changed, was set out in a paper of 1981 that also states the condition for preferring it, which is that conflicts be rare. A sale in which hundreds of buyers want the same seat does not meet that condition for the popular seats, and the consequence is the one this chapter accepts: most of those buyers are refused and choose again. Systems that cannot accept this lock in advance, or avoid the contention altogether by assigning seats to buyers, on the principle of “best available”, so that buyers do not choose.
For sales in which stock is a count, a pattern often described for the largest shopping events takes the deduction out of the database altogether during the peak. The remaining stock of each item is held in an in-memory store, a purchase is a small script that checks and decrements it atomically, and the orders are written to the database afterwards from a queue. This is the conditional update of this chapter, moved to a faster store, at the cost of a period in which the 2 stores have to be reconciled.
The waiting room is offered as a product by several content delivery networks, and their descriptions match the design here: a queue held at the edge, a signed cookie or token for those admitted, a controlled rate of admission, and a choice between admitting in order of arrival and admitting at random from those present when the sale opens. The random choice exists for the reason given above, that order of arrival in the first second measures the speed of connections and of programs.
Deliberate overbooking is studied under the name of revenue management, and the airlines’ practice rests on decades of statistics about how many passengers fail to appear on each route.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Reading a seat and then writing it | 2 buyers both find it free and both are sold it. | Make the write conditional on the seat’s state. |
| Trusting the seat map to decide a sale | A stale map sells a seat that is already taken. | Let the map advise, and the conditional write decide. |
| Selling a seat as soon as it is chosen | Buyers who never pay keep seats for ever. | Hold the seat for a limited time, and sell it on payment. |
| Holds without an expiry time in the record | Expiry depends on a background process being punctual. | Store the expiry time, and treat an expired hold as free. |
| Taking payment without checking the hold | A buyer pays for a seat that has since gone to someone else. | Extend the hold conditionally before charging. |
| Keeping the money when the final write fails | The buyer has paid and has no seat. | Refund at once, and never double-sell to save a payment. |
| A booking request without an idempotency key | A retry charges twice, or fails because the hold is already used. | Require a key, and return the first result for a repeat. |
| Booking several seats in separate steps | A buyer is left with some of the seats they wanted. | Write all of them in 1 transaction. |
| Locking seats in whatever order they were requested | 2 requests wait for each other indefinitely. | Take locks in a fixed order. |
| 1 counter for a very popular event | Every purchase contends on the same row. | Divide the places into portions with separate counters. |
| Letting the whole crowd reach the booking system | The seat store is overwhelmed, and the sales themselves fail. | Admit buyers from a waiting room at a controlled rate. |
| Unlimited holds for each account | 1 party removes many seats from sale without paying. | Limit the holds of each account and session. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A buyer abandons a purchase | the seat appears as held for up to 5 minutes | the hold runs out, and the seat is free |
| The payment is slow | the buyer waits | the hold was extended before charging, so the seat is kept |
| The payment succeeds and the seat is gone | the buyer is told, and refunded | the refund is issued automatically |
| The reply to a booking is lost | the buyer’s app tries again | the key returns the booking already made |
| The seat store’s leader fails | holds and bookings fail for about 1 second | a follower is elected, as in chapter 4 |
| The seat map cache is lost | the map loads more slowly for a moment | it is rebuilt from the seat store |
| A sale opens to an enormous crowd | most people wait in the queue | the waiting room admits them at the rate the system can serve |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Strong consistency for seats | a seat is never sold twice | no sales while the seat store lacks a majority |
| Conditional writes | no buyer waits behind another | a buyer who loses a race has to choose again |
| Holds with a time limit | abandoned seats return to sale | a slow buyer can lose a seat |
| A cached seat map | the seat store is spared most of the reads | the map can show a seat that has just been taken |
| A waiting room | the booking system works at a steady load | buyers wait, and may wait for nothing |
The simulation
Simulation in the repository. The program code/bookingsim simulates this design. To run it, clone the repository and type cargo run --release -p bookingsim in the code directory.
References
- H. Kung and J. Robinson, “On Optimistic Methods for Concurrency Control”, ACM Transactions on Database Systems, 1981.
- PostgreSQL documentation, “Explicit Locking”, on
SELECT ... FOR UPDATE. - Cloudflare documentation, “Waiting Room”, on queueing methods.
- K. Talluri and G. van Ryzin, The Theory and Practice of Revenue Management, Springer, 2004.
- RFC 9110, HTTP Semantics, 2022, for status 409.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Invariant | a condition that must hold at every moment |
| Seat store | the strongly consistent store of the state of every seat |
| Pessimistic locking | locking a record before reading it, on the assumption that a conflict is likely |
| Optimistic concurrency | writing on condition that the record is unchanged, and retrying when it is not |
| Uniqueness constraint | a rule, enforced by the database, that no 2 rows may share certain values |
| Hold | the reservation of a seat for 1 buyer for a limited time |
| Deadlock | 2 requests each waiting for a lock that the other holds |
| Seat map | the picture of which seats are free, held and sold |
| Overbooking | deliberately selling more places than physically exist |
| Waiting room | a queue in front of a sale, from which buyers are admitted at a controlled rate |
409 Conflict | the HTTP status for a request that the present state of the resource does not allow |
Summary
- The 1 promise of a booking system is that a seat is held by or sold to at most 1 buyer, and everything else may be approximate.
- Reading a seat and then writing it lets 2 buyers take the same seat, so the write is made conditional on the seat’s state.
- A seat is held for a limited time while its buyer pays, and an expired hold is treated as free without anything having to act.
- Before charging, the hold is extended, and if the seat is lost in spite of that, the payment is refunded.
- The seat map is served from a cache and may be stale, because it advises and the conditional write decides.
- Places without numbers are sold by a conditional update of a count, divided into portions when demand is extreme.
- When far more buyers arrive than there are seats, a waiting room admits them at the rate the system can serve.
Ride Hailing: System Design
A ride hailing service brings together 2 people who are moving about a city: a rider who wants to travel from 1 place to another, and a driver who is somewhere nearby and free to take them. The rider opens an application, states a destination, and within a few seconds is told that a particular car will arrive in 4 minutes. Behind that brief exchange the service has had to determine which of the tens of thousands of drivers at present on the road are close to this rider.
The question is unusual because it is about position, and position does not submit to the methods on which the rest of this book has relied. A key-value store finds a record by its exact key, and an ordered index finds the records whose keys lie in a range. Neither is of any direct use for finding the points that lie near a given point, since nearness involves 2 coordinates at once and no ordering of points along a single line keeps neighbours together. The data, moreover, will not keep still. Every driver’s position changes continually and is reported every few seconds, so that the service receives far more writes than reads, and each of those writes makes its predecessor worthless. This chapter develops an index for moving points, and then turns to the second problem that a ride presents, which is to give each ride to exactly 1 driver.
This chapter covers
- why finding nearby points is unlike any lookup met so far
- dividing a map into squares, and searching a square together with its neighbours
- choosing the size of the squares, and what goes wrong at either extreme
- squares within squares, for cities whose density varies
- storing positions that are replaced every few seconds
- drivers who disappear without saying so
- offering a ride to drivers in turn, and assigning it to exactly 1
- the states of a ride, and where strong consistency is needed
- estimating a time of arrival, and dividing the world among servers
Why nearness is hard
Suppose the service keeps the latitude and longitude of 20000 drivers and wishes to find the 3 nearest to a rider. The plain method is to compute the distance from the rider to each of the 20000 and to choose the 3 smallest. This is correct, and it costs 20000 calculations for a single request, a cost that grows in proportion to the number of drivers and has to be paid again for every rider who opens the application. At 1000 requests per second it amounts to 20 million distance calculations a second, nearly all of them for drivers who are on the other side of the city and could have been excluded at a glance.
The ordinary remedy for a slow search is an index, and it is instructive to see why the ordinary index fails. An index on latitude can find, very quickly, all the drivers within a narrow band of latitude around the rider. That band, however, extends right round the world, and within a city it runs the full width of the city from east to west and contains thousands of drivers, nearly all of them distant. An index on longitude has the same defect in the other direction. A database can use the 2 together only by retrieving both bands and intersecting them, which examines far more entries than it finally keeps. The root of the difficulty is that an index orders its entries along 1 dimension, whereas nearness on a map is a property of 2.
What is required is some means of naming regions of the map, such that points which are near each other usually bear the same name or closely related ones. A lookup by name, which every store performs well, would then serve as a search by position.
Dividing the map into squares
The simplest such scheme lays a grid of equal squares over the map. Every position falls in exactly 1 square, and the square can be computed from the coordinates by a division: if squares are 1 km on a side, a point 42.5 km east and 17.5 km north of the map’s origin lies in the square numbered 42 in 1 direction and 17 in the other. The service keeps, for each square, the list of drivers currently within it, in an ordinary table keyed by the square’s number.
To find drivers near a rider, the service computes the rider’s square and retrieves the drivers listed there. Only these few need have their distances measured. The remainder of the city is never examined.
This is very nearly sufficient, and it has 1 flaw that must be corrected.
A rider who stands close to the edge of a square may have a driver a few metres away on the other side of the edge, in the adjoining square, and a search confined to the rider’s own square would overlook that driver and return another who is a kilometre off. The search must accordingly include the 8 squares that surround the rider’s, 9 squares in all. Any driver within the length of 1 side of the rider is then certain to be among those examined, wherever in the square the rider happens to be.
The simulation for this chapter places 20000 drivers at random on a map of 100 by 100 units, with squares of 1 unit, and looks for the 3 nearest to a rider by both methods. Measuring every driver takes 20000 distance calculations. The grid takes 15, that being the number of drivers in the rider’s 9 squares, and both methods return the same 3 drivers, at distances of 0.19, 0.84 and 0.85 units. The work has been reduced by a factor of more than 1000, and it no longer depends on the total number of drivers, only on how many are in the vicinity.
Choosing the size of the squares
The size of the squares is the 1 parameter of this index, and the index performs badly when it is wrong in either direction.
If the squares are too large, each holds many drivers, and the 9 squares of a search return hundreds or thousands of candidates whose distances must all be computed. The index has excluded little, and in the limit of 1 square covering the whole city it excludes nothing.
If the squares are too small, most are empty. A search of 9 tiny squares commonly finds no driver, and the service must widen it to the next ring of 16 squares and then to the ring beyond, issuing many lookups to collect a handful of drivers.
The suitable size is of the order of the distance over which a match is useful. If a driver more than 2 km away is too far to be worth offering, squares of about 1 km give a search area of 3 km by 3 km, which covers the range of interest with a single round of lookups.
No single size, however, suits a whole city. In the centre at midday a square of 1 km may contain 300 drivers, while on the outskirts at night an area of 25 square kilometres may contain 2. A grid fine enough for the centre is nearly empty at the edge, and a grid coarse enough for the edge is crowded in the centre.
Squares within squares
The difficulty of uneven density is met by making the grid hierarchical. The whole map is taken as 1 square and divided into 4. Each of these is divided into 4 again, and the division is repeated to whatever depth is required, so that every square of 1 level contains exactly 4 squares of the next level down, each with half its side.
The squares of such a hierarchy can be given names with a most convenient property. The 4 quarters of the map are named 0, 1, 2 and 3. The quarters of square 2 are named 20, 21, 22 and 23, and so on, each level adding 1 digit. The name of a square then begins with the name of every larger square that contains it. A position can be described by a single name of, say, 15 digits, denoting a very small square, and its first 10 digits name the larger square containing it, and its first 6 a larger one still.
Several consequences follow. Drivers are stored under their full, fine names in a store that keeps its keys in order, such as the key-value store of chapter 1 with its sorted index. All the drivers within any larger square then have names beginning with that square’s name, and since names with a common beginning are adjacent in sorted order, they can be retrieved by a single scan of a range of keys. A search for nearby points has thus been converted into a search by prefix, which is precisely the operation an ordered index is built for.
The level at which to search can be chosen at the moment of searching. In a dense district the service searches small squares, and in a sparse one it searches the larger squares that contain them, by using a shorter prefix, with no change to the stored data. A search may begin fine and move to a coarser level until it has found enough candidates.
One caution applies to every scheme of this family. 2 points whose names share a long prefix are certainly close together, and the converse does not hold. 2 points that are very close may lie on either side of a boundary between 2 large squares and have names that differ from the first digit. The neighbouring squares must therefore still be searched, exactly as with the plain grid, and the neighbours of a square are computed by arithmetic on its position and never by supposing that their names resemble its own.
The squares of such schemes are in practice not always square. Some systems divide the globe into cells bounded by lines of latitude and longitude, which are simple to compute and become narrower towards the poles. Others cover the globe with hexagons, which have the merit that all 6 neighbours of a cell are at the same distance from its centre, whereas a square has 4 near neighbours and 4 that are further off at the corners. The principle is the same in each case: to name regions so that position becomes a key.
Positions that are replaced every few seconds
Each driver’s application reports its position every 4 seconds or so. With 500000 drivers on the road, that is 125000 updates per second, considerably more than the rate of requests from riders, and each update supersedes the last. This pattern of load is very different from that of the stores in earlier chapters, and it justifies a different treatment of durability.
In chapter 2 every write was forced to disk before it was acknowledged, since an acknowledged write that was subsequently lost would have been a broken promise. A driver’s position carries no such obligation. It will be replaced by a fresh report within 4 seconds, and nobody will ever wish to know where a driver was 10 seconds ago for the purpose of matching. Were the entire index of positions to be lost, it would rebuild itself in 4 seconds as the drivers reported again. The index is therefore kept in memory only, without any log on disk and without waiting for copies to be confirmed, and this is what allows it to absorb 125000 writes a second on modest hardware. To have given it the durability of chapter 2 would have multiplied its cost many times and purchased nothing.
An update consists of 2 small steps. The driver’s new position is recorded, and if the driver has crossed into a different square, the driver is removed from the list of the old square and added to that of the new. Most updates find the driver in the same square as before and alter only the stored coordinates.
Positions that must be kept are a separate matter and take a separate path. The route actually driven during a ride is needed in order to calculate the fare and to settle any later dispute, and it is appended to a durable log, through the message queue of chapter 11, apart from the live index.
Drivers who disappear
A driver may cease to be available without informing the service. The phone’s battery is exhausted, the car enters a tunnel, the application is closed. If nothing were done, the driver’s last known position would remain in the index indefinitely, and the service would go on offering rides to a car that is no longer there.
The remedy is the lease, which has now appeared in nearly every chapter. Each position is stored with a time limit of some 15 seconds, several times the interval between reports. Every report renews it, and a driver who stops reporting vanishes from the index when the limit expires. Nothing has to detect the departure, since an entry that is not renewed lapses of its own accord.
Giving a ride to exactly 1 driver
To find nearby drivers is half of the task. The other half is to assign the ride to 1 of them, and here the considerations are of a different kind, since an error is no longer a slightly worse match and becomes 2 cars arriving for 1 rider, or a rider left waiting for a car that is not coming.
The nearest driver in a straight line is not necessarily the best choice. A driver 300 metres away on the far side of a river, or heading in the opposite direction along a road with a central barrier, may need 10 minutes to arrive, while another 800 metres off reaches the rider in 3. Having obtained a short list of candidates from the index, the matching service therefore ranks them by estimated time of arrival along the road network, a calculation discussed further on.
The ride is then offered to the drivers in turn.
2 decisions embodied in these steps merit comment.
The ride is offered to 1 driver and, only if that driver declines or fails to answer in time, to the next. The alternative, to offer it to several at once and take the first acceptance, would assign rides sooner and would leave every driver but 1 having responded to an offer that was then withdrawn, which drivers reasonably resent. An offer has a time limit and is in effect a lease on the ride, so that a driver who does not respond cannot hold it up for more than a few seconds.
The assignment itself is a conditional write to a strongly consistent store, of the form “set the driver of this ride to 7056, provided that it has no driver”. This is the operation that guarantees a ride at most 1 driver. In the last step, a late acceptance from the first driver arrives after the second has been assigned, and the identical conditional write, finding the ride already assigned, is refused. The same operation marks the driver as busy, so that a driver cannot be assigned to 2 rides. It will be recognised as the conditional write that sold a seat to 1 buyer in chapter 20 and gave a job to 1 worker in chapter 12.
In a busy area, to match each ride at the instant it is requested does not give the best result overall. If 2 riders make requests within a second of each other and 2 drivers are nearby, giving the first rider the nearest driver may leave the second rider with a long wait, where the other pairing would have served both tolerably. Services therefore sometimes collect the requests of a small area for a second or 2 and assign the whole batch together, so as to minimise the total waiting time of all the riders concerned.
The states of a ride
A ride, like the payment of chapter 19 and the job of chapter 12, is an object that passes through a series of states: requested, offered, accepted, driver arriving, in progress, completed, and paid, with cancellation possible from several of them. Each transition is caused by an event from the rider’s application, the driver’s application, or the service itself, and each is recorded durably.
This record requires the strong consistency that the index of positions was able to do without. Money depends on it, since the fare is calculated from the recorded beginning and end of the ride, and both parties must be shown the same state, since a rider who is shown “driver arriving” while the driver is shown “cancelled” has been badly served. The ride store is accordingly a replicated database of the kind described in chapters 3 and 4, in which each transition is a conditional write that verifies the current state before altering it, so that a ride cannot, for example, be both cancelled and started.
The design thus contains 2 stores at opposite ends of the range of consistency, each suited to what it holds.
| Index of positions | Ride store | |
|---|---|---|
| Contents | where each driver is at present | the state and history of every ride |
| Writes | 125000 per second, each superseding the last | a few per ride |
| Durability | none: in memory, and rebuilt in 4 seconds | full: stored on a majority |
| Consistency | eventual, and stale by up to 4 seconds | strong |
| If it is lost | matching pauses for a few seconds | not permitted |
Once a ride has been accepted, the rider wishes to watch the car approach on a map. The driver’s position must then be conveyed to 1 particular rider every few seconds, which is the delivery problem of chapter 18. The rider’s application holds a connection open, and the service forwards on it the positions of the assigned driver.
Estimating the time of arrival
The figure that riders care most about is the number of minutes until the car arrives, and the distance in a straight line is a poor guide to it. The estimate requires a model of the road network, represented as a graph in which the junctions are nodes and the stretches of road between them are edges, each edge carrying the time presently needed to drive along it. The estimated time of arrival is the length of the shortest path through this graph from the driver to the rider.
To compute shortest paths on the road graph of an entire country for every candidate driver of every request would be far too slow by the textbook method, which examines a large part of the graph for each query. Practical systems carry out a great deal of the work beforehand. They identify in advance the major roads that long journeys almost always use and compute the times between the points where such roads meet, so that a query has only to find its way from each end to the nearest major road and then consult a table. The travel times on the edges are revised continually from the movements of the very drivers that the service is tracking, which makes the stream of position reports serve a second purpose as a measurement of traffic.
Dividing the world
Rides are local. A rider in 1 city is never matched with a driver in another, and this affords a natural way of dividing the system among machines, by geography. The map is partitioned into regions, each consisting of a set of the squares described earlier, and each region’s index of positions and matching are served by their own group of servers.
Partitioning by position has the same hazard as partitioning by any other key, namely that the load is unevenly distributed. The centre of a great city on a Friday evening has many times the activity of a rural region, so that regions are made small where the density is high and large where it is low, which the hierarchical naming of squares makes straightforward. Riders and drivers close to the boundary between 2 regions need the search to extend across it, which is once again the matter of neighbouring squares, between servers this time.
Demand also moves in ways that can to some degree be foreseen. When a concert ends or rain begins, requests in a district may increase fivefold within minutes, more quickly than drivers are able to get there. The service has accordingly to guard its own components against such surges, using the rate limiting of chapter 7, and many services raise the price in the affected district for the time being, so as to attract drivers to it and to moderate the demand.
Use cases
A rider requests a ride and watches the car approach. A driver reports a position every few seconds and accepts rides.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A driver’s application reports the driver’s position every 4 seconds. |
| 1 | A rider can request a ride from a position to a destination. |
| 2 | The service finds free drivers near the rider and offers the ride to them in turn. |
| 3 | A ride is assigned to at most 1 driver, and a driver to at most 1 ride. |
| 4 | The rider can follow the position of the assigned driver. |
| 5 | Every change in the state of a ride is recorded. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Latency | Nearby drivers are found within 100 ms. |
| 1 | Consistency | Strong for rides and their assignment. Eventual for positions, which may be 4 seconds old. |
| 2 | Availability | 99.95% for requesting a ride. |
| 3 | Durability | The record of a ride is not lost. A driver’s current position need not survive a failure. |
| 4 | Scale | 500000 drivers on the road, 125000 position reports per second, and 1000 ride requests per second. |
Non-functional requirements 1 and 3 state the division that runs through this whole design. To hold positions to the standard appropriate to rides would make the index many times more expensive and no more useful, and to hold rides to the standard appropriate to positions would lose customers’ money. The skill lies in recognising which of the system’s data is of which kind.
Core entities
Capacity estimate
Arithmetic ahead. The positions of 500000 drivers occupy about 50 MB of memory. The quantity that matters is the rate of updates, 125000 per second.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Position reports | 500000 drivers ÷ 4 seconds | 125000 per second |
| Memory per driver | id, position, square, state, expiry | about 100 bytes |
| Memory for all drivers | 500000 × 100 bytes | 50 MB |
| Drivers in a search | 9 squares × about 20 drivers | about 180 |
| Rides per day | 1000 per second at the peak, a third of that on average | about 30 million |
The API
Every report of a position creates a new position in the collection of that driver’s
positions, with POST. A ride is created with POST as well. A driver accepts by
writing the assignment of that driver to the ride, with PUT, so that repeating the
request has no further effect.
A driver reports a position
POST /v1/drivers/7056/positions HTTP/1.1
Host: rides.example
Content-Type: application/json
{ "latitude": 38.7223, "longitude": -9.1393, "heading": 270 }
HTTP/1.1 201 Created
Reports are sent so frequently that many services convey them over an open connection in a compact binary form, without a separate HTTP request for each. The meaning is unchanged.
A rider requests a ride
POST /v1/rides HTTP/1.1
Host: rides.example
Idempotency-Key: 3f7a-ride-1
Content-Type: application/json
{
"from": { "latitude": 38.7169, "longitude": -9.1399 },
"to": { "latitude": 38.7742, "longitude": -9.1342 }
}
HTTP/1.1 201 Created
Location: /v1/rides/501
Content-Type: application/json
{ "id": 501, "state": "requested" }
The key ensures that a rider whose application retries after a lost reply does not summon 2 cars.
A driver accepts
PUT /v1/rides/501/assignments/7056 HTTP/1.1
Host: rides.example
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 501, "state": "accepted", "driver": 7056 }
A driver whose offer has ended, or who accepts after another driver has been assigned,
receives 409 Conflict.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Location service | receives position reports and files each driver under a square | none | another instance receives the reports |
| Grid index | holds the drivers of each square | positions, in memory, with a lease | it is rebuilt from reports within 4 seconds |
| Matching service | finds candidates, ranks them, and offers the ride in turn | none | another instance continues from the ride’s recorded state |
| Ride store | holds every ride and its state | rides, replicated | a follower is elected, as in chapter 4 |
One request, step by step
The steps follow the arrows of figure 21.6.
- Every driver’s application reports its position every 4 seconds.
- The location service computes the square of the position and updates the grid index, moving the driver from 1 square’s list to another’s when a border has been crossed and renewing the driver’s lease.
- A rider requests a ride. The matching service computes the rider’s square, reads the drivers of that square and its 8 neighbours from the grid index, and ranks the free ones by estimated time of arrival.
- The matching service offers the ride to the first of them. When a driver accepts, it writes the assignment to the ride store as a conditional write, which succeeds for 1 driver only.
The design, deployed
The system is deployed separately in each region of the world, since no ride crosses between them, and the figure shows 1 region. Drivers and riders hold connections to gateways, as the phones of chapter 18 do. The location service is stateless and turns each of the 125000 reports per second into an update of the grid index, which is divided among 30 machines by area and kept in memory only. The matching service reads the index and writes assignments to the ride store, which is the 1 component here with replicas in 3 zones and durable storage. The picture makes visible the division that the chapter insisted upon, between data that is rebuilt in seconds and data that must not be lost.
How real systems do it
The grid of this chapter corresponds to 3 schemes that are in wide use, and they differ in the shape of the cell and in how the cells are named.
The geohash divides the world by latitude and longitude, halving alternately in each direction, and writes the result as a short string in which every further character selects a smaller cell within the last. It is the hierarchy of names with shared prefixes described above, and several in-memory stores offer queries by radius that are built on it. Its cells are rectangles whose width varies with latitude.
Google’s S2 library projects the sphere onto the faces of a cube and numbers the cells along a space-filling curve, so that a cell is a 64-bit integer and cells that are close on the curve are close on the ground. A region is covered by a small set of cells of mixed sizes, and a search becomes a few scans of ranges of integers.
Uber developed H3, which it published in 2018, and which uses hexagons. The stated reason is the one given above: the centres of all 6 neighbours of a hexagon are at the same distance, which makes it natural to reason about rings of cells around a point and about movement between adjacent cells, where a square has neighbours at 2 different distances. H3 has 16 levels of resolution, each cell containing 7 of the next, and since hexagons do not divide exactly into smaller hexagons, a child cell is only approximately contained in its parent. The surface of a sphere cannot be covered by hexagons alone, and every level contains exactly 12 pentagons, placed where they are least likely to matter.
For the estimate of arrival time, the technique of computing in advance along the important roads is known as contraction hierarchies, and it answers a query on the road network of a continent in about a millisecond. And Uber has described assigning the work for each area to particular servers with a ring of consistent hashing whose membership spreads by gossip, which is the alternative to a coordinator that chapter 1 described.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Measuring the distance to every driver | The cost of each request grows with the size of the whole fleet. | Index drivers by the square they are in. |
| Searching only the rider’s square | A driver just across the border is missed. | Search the 8 neighbouring squares as well. |
| A single size of square for everything | The centre is crowded, and the outskirts are empty. | Use a hierarchy of squares, and choose the level when searching. |
| Supposing that neighbouring squares have similar names | 2 nearby points on either side of a major boundary are treated as distant. | Compute the neighbours of a square, and search them. |
| Storing live positions durably | Every 1 of 125000 writes a second waits for a disk, to no purpose. | Keep positions in memory, where they rebuild themselves. |
| Positions without a time limit | Drivers who have gone offline are still offered rides. | Store each position with a lease that its reports renew. |
| Ranking by distance in a straight line | The nearest driver is across a river and 10 minutes away. | Rank by estimated time along the roads. |
| Offering a ride to many drivers at once | All but 1 respond to an offer that is then withdrawn. | Offer to 1 driver, and move to the next after a time limit. |
| Assigning a ride by reading and then writing | 2 drivers are assigned to 1 ride. | Assign with a conditional write that requires the ride to have no driver. |
| Keeping rides in the store used for positions | The record of a ride is lost in a restart. | Keep rides in a durable, strongly consistent store. |
| Requesting a ride without an idempotency key | A retry summons a second car. | Require a key on the request. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A driver’s phone loses its connection | that driver receives no offers | the lease on the position runs out, and the driver leaves the index |
| The grid index is lost | matching pauses for a few seconds | drivers’ next reports rebuild it |
| A driver does not answer an offer | the rider waits 15 seconds longer | the offer ends, and the next driver is asked |
| 2 drivers accept the same ride | the later driver is told that the ride is taken | the conditional write accepts only the first |
| The reply to a ride request is lost | the rider’s application tries again | the key returns the ride already created |
| The ride store’s leader fails | requests fail for about 1 second | a follower is elected |
| No driver is nearby | the rider is told so, or the search is widened | the search moves to a coarser level of squares |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Positions in memory only | 125000 updates per second on modest hardware | a failure empties the index for a few seconds |
| A grid in place of exact search | the work depends only on the drivers nearby | borders must be handled by searching neighbours |
| Offers in turn | no driver has an accepted offer withdrawn | assignment is slower when drivers do not answer |
| Strong consistency for rides | a ride has 1 driver and 1 state | a brief pause in new rides when a leader fails |
| Regions by geography | each city is served independently | busy districts need smaller regions |
The simulation
Simulation in the repository. The program code/nearbysim simulates this design. To run it, clone the repository and type cargo run --release -p nearbysim in the code directory.
References
- I. Brodsky, “H3: Uber’s Hexagonal Hierarchical Spatial Index”, Uber engineering blog, 2018.
- Google, S2 Geometry Library documentation, “S2 Cells”.
- G. Niemeyer, “geohash.org”, 2008.
- R. Geisberger and others, “Contraction Hierarchies: Faster and Simpler Hierarchical Routing in Road Networks”, WEA 2008.
- A. Guttman, “R-Trees: A Dynamic Index Structure for Spatial Searching”, SIGMOD 1984.
- Uber engineering blog, “How Ringpop from Uber Engineering Helps Distribute Your Application”, 2016.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Spatial index | a structure that finds the points near a given point without examining them all |
| Grid | a division of the map into equal squares |
| Square, cell | 1 region of such a division, which serves as a key |
| Neighbouring squares | the 8 squares that touch a given square |
| Hierarchical grid | squares divided into 4 smaller squares, repeatedly |
| Prefix | the first digits of a square’s name, which name a larger square that contains it |
| Position report | a message from a driver’s application that gives the driver’s position |
| Matching | choosing a driver for a ride |
| Offer | a proposal of a ride to 1 driver, valid for a limited time |
| Estimated time of arrival | the predicted time until the driver reaches the rider, along the roads |
| Road graph | the road network as junctions joined by stretches of road, each with a travel time |
Summary
- Finding nearby points is hard for ordinary indexes, which order their entries along 1 dimension.
- A grid names each region of the map, so that a lookup by square replaces a comparison with every driver.
- A search covers the rider’s square and its 8 neighbours, since the nearest driver may be just across a border.
- A hierarchy of squares whose names share prefixes allows the size to be chosen for each search and turns nearness into a scan of a range of keys.
- Positions are replaced every few seconds and are kept in memory with a lease, since losing them costs 4 seconds.
- A ride is offered to drivers in turn and assigned by a conditional write, so that it has at most 1 driver.
- Rides are kept in a durable, strongly consistent store, at the opposite extreme from the index of positions.
Search Autocomplete: System Design
When a person begins to type into a search box, a short list of suggestions appears
beneath it, and the list changes with every letter. Typing ka produces “kafka”,
“karaoke near me” and “kayak”, and the addition of an f reduces the list to “kafka”
and “kafka vs rabbitmq”. The feature is called autocomplete or typeahead, and it
spares the user some typing, corrects spelling before it has gone wrong, and shows
what other people have looked for.
2 characteristics set this feature apart from the search that it accompanies. It is invoked far more often, once for every letter typed where a search is made once, so that a service handling 100000 searches a second may receive a million requests for suggestions in the same second. And it must be very much faster. A page of search results that takes half a second to appear is acceptable, whereas suggestions that arrive half a second after a keystroke are useless, since the user has by then typed 2 further letters and the suggestions refer to text that is no longer in the box. The practical limit is about 100 ms from the keystroke to the display, of which the network consumes the greater part.
There is no possibility of computing anything substantial within such a limit, and the design is governed by that fact. Whatever can be computed in advance is computed in advance, so that answering a request amounts to a single lookup, and whatever can be answered without reaching the service at all is answered nearer to the user. The interest of the design lies in the question of what exactly should be prepared beforehand, in how the prepared data is kept reasonably up to date, and in a small number of ways in which a feature that looks harmless can cause real embarrassment.
This chapter covers
- what a suggestion is, and where the candidates come from
- finding the searches that begin with a prefix: a sorted list, and a tree of letters
- why the answers are computed before anyone asks
- how much data that requires, and how it is rebuilt
- counting what people search for
- searches that became popular within the last hour
- what the browser must do: waiting, discarding late answers, and caching
- suggestions that must not be shown
- dividing the data among servers
What a suggestion is
A suggestion is a complete search that begins with what the user has typed so far. The text already typed is called the prefix, and the suggestions are completions of it.
The candidates are, for the most part, searches that other people have made. A search
engine keeps a record of the searches submitted to it, and the completions of ka are
drawn from the searches in that record which begin with ka. There are generally far
more of these than can be displayed, since thousands of distinct searches begin with
any short prefix, and so they have to be ranked and only the first few shown.
The basic ranking is by frequency. A search that has been made 900 times is preferred to one that has been made 300 times, on the reasoning that what many people have looked for is what the next person is most likely to want. Frequency alone is a serviceable beginning, and the later sections refine it.
The problem can therefore be stated precisely. Given a prefix, return the k most frequent searches that begin with it, where k is a small number such as 5 and the answer is required within a few milliseconds.
Finding the searches that begin with a prefix
A sorted list
The most direct structure is a list of all known searches in alphabetical order, each accompanied by its count. In such a list, all the searches that begin with a given prefix stand together, since alphabetical order groups words by their initial letters. The first of them can be found by binary search, or in an ordered index of the kind used throughout this book, and the remainder follow immediately after it.
The simulation for this chapter is built in this way, with 10 searches held in an
ordered map. For the prefix ka it locates the first entry not less than ka, reads
forward for as long as the entries begin with ka, which yields kafka, kafka vs
rabbitmq, karaoke near me and kayak, sorts these 4 by count, and returns the first 3.
The method is correct and, for long prefixes, perfectly adequate. Its weakness lies in
the short prefixes, which are the ones most often requested, since every search begins
with a single letter. The number of entries beginning with k may run to millions,
and all of them must be read and ranked in order to discover the 5 best. The cost of a
request is proportional to the number of matches, and the number of matches is
greatest for exactly those requests that are most numerous.
A tree of letters
The traditional structure for prefixes is a tree in which each edge is labelled with a letter and the path from the root to any node spells out a prefix. It is called a trie, or a prefix tree.
To find the completions of ka, one starts at the root, follows the edge labelled k
and then the edge labelled a, and arrives at a node beneath which lie all the
searches that begin with ka and no others. Reaching that node takes as many steps as
the prefix has letters, 2 in this case, however many millions of searches the tree
contains.
The trie makes the node easy to find and leaves the main difficulty where it was. The completions lie somewhere in the subtree below the node, and to determine the 5 most frequent it is still necessary to visit the whole subtree, which for a short prefix is a large part of the entire tree. The structure has shortened the search for the beginning of the answer and has done nothing to shorten the collection of the answer itself.
Computing the answers before anyone asks
The observation that resolves the difficulty is that the question “which are the 5
most frequent searches beginning with ka” has the same answer whoever asks it and
whenever, until the underlying counts change. There is consequently no reason to work
it out at the time of the request. It can be worked out once, in advance, for every
prefix, and the results stored.
In terms of the trie, this means that every node carries, in addition to its children, a short list of the best completions that lie beneath it. In terms of a plain table, it means a row for every prefix, containing that prefix’s best completions.
With such a table, a request is a single lookup by key, of the kind that a key-value
store or a cache performs in well under a millisecond. Its cost does not depend on the
length of the prefix, on the number of searches that match it, or on the size of the
collection. The simulation prints the rows of this table for the prefixes of its
example: for k, kubernetes with 950, kafka with 900 and karaoke near me with 800;
for ka, kafka, karaoke near me and kayak; for kaf, the 2 searches about kafka; and
for x, nothing.
Once the answers have been computed in advance, the trie is no longer indispensable. A flat table that maps each prefix, as a string, to its list does everything required and can be held in any key-value store. The trie continues to be of use in building the lists, as will be seen, and in memory it is the more compact representation, since prefixes that share their opening letters share the corresponding nodes. As far as serving is concerned, however, the design has reduced a search problem to a lookup problem, and has done so by the same exchange that chapter 15 made for the news feed, in which work was moved from the moment of reading to the moment of writing.
What this costs
The cost is storage and the labour of construction. Every search of n letters has n
prefixes, so that a collection of 100 million distinct searches of 20 letters on
average gives rise to as many as 2 billion prefixes. In practice the number is much
smaller, since a great many searches share their prefixes and there is only 1 row for
k, shared by every search that begins with that letter. It can be reduced further by
2 simple rules. Prefixes beyond a certain length, 20 letters for example, are not
stored, since by that point so few searches match that they can be found directly. And
searches that have been made only a few times are excluded from the collection
altogether, which, as a later section explains, is also required for reasons that have
nothing to do with storage.
A row holds 5 completions, which can be stored as references to the searches and need not be full copies. A few hundred million rows of some tens of bytes each come to tens of gigabytes, an amount that can be held in the memory of a small number of machines.
Building the lists
The lists are built from the bottom of the trie upwards. The list for a node is the best 5 drawn from the lists of its children, together with the search that ends at the node itself, if there is one. Since each child’s list contains at most 5 entries, a node with 26 children has to consider at most 131 candidates, and the entire trie is processed in a single pass whose cost is proportional to the number of nodes.
The same property makes it possible to bring the lists up to date without rebuilding everything. When the count of a single search changes, only the lists on the path from that search up to the root can be affected, which for a search of 20 letters is 20 nodes. In practice, most services nevertheless rebuild the whole table at intervals, hourly or daily, from the latest counts, and replace the old table with the new one in a single step. A complete rebuild is simpler to make correct, is done by a batch job that places no load on the servers answering requests, and has the considerable advantage that the previous table remains at hand and can be reinstated at once if the new one proves to be faulty.
Counting what people search for
The counts from which the lists are built come from the record of searches. Every search that is actually submitted, as distinct from every prefix that is typed, is appended to a log, by way of the message queue of chapter 11, so that the recording adds nothing to the time taken by the search itself.
A batch job reads this log at intervals and produces, for every distinct search, the number of times it was made within some recent period. 3 aspects of this counting call for care.
The text of a search has to be normalised before it is counted, since “Kafka”, “kafka” and “ kafka “ are the same search to the person who typed them and would otherwise be counted as 3 separate ones, each with a third of the proper weight. Normalisation ordinarily converts the text to small letters, removes superfluous spaces, and brings accented characters to a standard form. The same normalisation must be applied to the prefix when a request for suggestions arrives, or the lookup will fail to find a row that exists.
Not every search need be counted. At a high volume, a random sample of 1 search in 10 or 1 in 100 yields counts that are proportionally accurate for everything popular enough to appear in a list of suggestions, at a tenth or a hundredth of the work. Sampling loses precision only for rare searches, which are not going to be suggested in any case.
And the counts should favour what is recent. A search that was made a million times 3 years ago and has scarcely been made since ought not to rank above one that has been made 100000 times this week. A common method reduces older counts by a constant factor at each period, halving the weight of every search’s history each week for example, so that popularity has to be sustained in order to be retained. This is the decay that chapter 9 applied to the counts of the least frequently used rule, and it is applied here for the same reason.
What became popular within the hour
A table rebuilt once a day cannot know of anything that happened today. When news breaks and a million people begin to search for a name that nobody had searched for the day before, suggestions built from yesterday’s counts will not include it, and the feature appears conspicuously out of touch at exactly the moment when attention is greatest.
The usual remedy is to maintain a second, much smaller collection alongside the main table. A stream processor reads the log of searches as they arrive and keeps counts over a short sliding window, the last hour for instance, using the windowed counting of chapter 7. Searches whose recent rate far exceeds their historical rate are recognised as trending and placed in a small table of their own, which is brought up to date every minute or so. When a request arrives, the service consults both the main table and the small one and merges the 2 lists.
The division gives each part the method that suits it. The great mass of suggestions changes slowly and is rebuilt at leisure, in bulk, with every opportunity for checking. The few that change quickly are handled by a mechanism that is fast because it is small.
What the browser must do
Much of the performance of autocomplete depends on the behaviour of the client, and a well-constructed service can be made to seem slow and erratic by a carelessly written one.
A user who types quickly produces a keystroke every 100 ms or less. A request sent on every keystroke is mostly wasted, since its answer will be out of date before it has arrived. The client therefore waits for a brief pause in the typing, of perhaps 50 to 100 ms, before sending a request, a technique known as debouncing. A user who types 5 letters in rapid succession and then stops causes 1 request where there would have been 5. Requests are likewise withheld until at least 1 or 2 letters have been typed, according to the language, since the suggestions for a single letter are seldom of any use and such prefixes account for a large share of the load.
A second matter concerns the order in which answers arrive, and it is the source of a defect that is frequently seen.
Requests travel independently over the network and may be answered in an order
different from that in which they were sent. If the client displays every answer as it
arrives, a slow answer for ka can overwrite the correct answer for kaf, and the
user is shown suggestions that do not correspond to the text in the box. The client
must therefore remember which prefix each request was for, and display an answer only
if that prefix is still the text in the box. Requests that have been overtaken should
also be cancelled, which saves the work of answering them.
The third matter is caching, for which autocomplete is exceptionally well suited. The
answer for a given prefix is the same for every user, in the absence of
personalisation, and remains valid for minutes or hours. It can therefore be cached at
every level, with the methods of chapter 9. The browser keeps the answers it has
already received, so that a user who deletes a letter sees the previous suggestions
again instantly, with no request. The edge servers of a content delivery network, as
described in chapter 17, keep the answers for common prefixes close to users, and
since requests are heavily concentrated on a small number of short prefixes, a large
proportion of them never reach the service. For this to work, a request for
suggestions must be an ordinary GET with the prefix in its address, and the reply
must state for how long it may be reused.
Suggestions that must not be shown
Autocomplete presents text that the service did not write, taken from what other people have typed, directly beneath the service’s own name. It has in consequence 2 ways of causing harm that are not apparent from the engineering alone.
The first concerns offensive or damaging suggestions. If a sufficient number of people search for a person’s name together with an accusation, the plain frequency ranking will propose that accusation to everybody who starts to type the name, and so lends the authority of the service to a rumour. Suggestions may equally promote abuse, dangerous activity or unlawful content, for no better reason than that many people have searched for it. The service therefore passes candidates through a filter before they enter the table, composed of lists of terms and categories that are never to be suggested, and provides a means by which any particular suggestion can be removed promptly once it has been reported. Because the table is built in advance, the filter is applied during the build and costs nothing when requests are answered, and an urgent removal is effected by a small list of exclusions that is checked as answers are served.
The second concerns privacy. A search may contain a person’s name, an address, a telephone number or a medical question. If a search that only 1 person ever made were to be offered as a suggestion, that person’s private search would be shown to strangers who chanced to type the same opening letters. A search is therefore admitted as a candidate only when it has been made by a sufficient number of different people, some hundreds for example, so that no suggestion can be traced to an individual. This threshold, which was mentioned earlier as a means of saving storage, is in fact required on grounds of privacy, and the number to be counted is that of distinct people, whatever the number of searches, since 1 person may make the same search a great many times.
Dividing the data among servers
The table of prefixes is too large for 1 machine in a big service and has to be divided. The natural key for the division is the prefix itself.
It is tempting to assign prefixes to servers by their first letter, and this
distributes the load poorly. Far more searches begin with s than with x, so that
some servers would be overloaded and others nearly idle. The division is better based
on measured load: the ranges of prefixes are chosen so that each receives roughly the
same number of requests, with the consequence that a common letter is spread over
several servers by its second letter and several rare letters share 1 server. As an
alternative, prefixes may be distributed by a hash, with the consistent hashing of
chapter 1, which balances the load without any measurement and gives up the property
that neighbouring prefixes reside on the same server.
Since the table is read-only between rebuilds, replicating it is uncomplicated. Every replica of a shard holds an identical copy of the same immutable file, and any of them may answer any request. There is no leader and no question of consistency among the replicas, apart from the short interval during which a new table is being distributed and some replicas have it while others still serve the old one. A user may notice a small change in the suggestions if 2 successive requests fall on either side of that interval, and this is harmless.
Use cases
A person types into a search box, and the service proposes completions.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | For the text typed so far, the service returns up to 5 searches that begin with it. |
| 1 | Suggestions are ordered by how often they have recently been searched for. |
| 2 | Searches that have become popular within the last hour appear among the suggestions. |
| 3 | A suggestion that has been reported can be removed within minutes. |
| 4 | No suggestion is shown that fewer than a set number of different people have searched for. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Latency | Suggestions are displayed within 100 ms of a keystroke, and the service itself answers within 10 ms. |
| 1 | Availability | 99.9%. If suggestions are unavailable, searching still works. |
| 2 | Consistency | Eventual. Suggestions reflect counts that are up to a day old, or up to a minute old for trending searches. |
| 3 | Scale | 1 million requests per second, and 100 million distinct searches. |
Autocomplete is an aid, and nothing else depends on it, which is reflected in non-functional requirements 1 and 2. A search box with no suggestions is merely a search box, and suggestions computed from yesterday’s counts are nearly as good as suggestions computed from this minute’s. Both facts are used to the full. The table is built offline and is allowed to be stale, and the client is written so that a slow or failed request for suggestions leaves the user free to type and to search without hindrance.
Core entities
Capacity estimate
Arithmetic ahead. The table of prefixes occupies about 25 GB, which is held in memory on a few machines, and most requests are answered by caches before they reach those machines.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Requests per second | 100000 searches × about 10 letters typed | 1 million |
| Requests that reach the service | 1 million × 20% not answered by caches | 200000 per second |
| Rows in the table | distinct prefixes, after the rules on length and popularity | about 500 million |
| Size of a row | prefix 12 bytes + 5 references of 8 bytes | about 50 bytes |
| Size of the table | 500 million × 50 bytes | 25 GB |
The API
Suggestions are a resource addressed by the prefix, which is passed as a parameter of
the query. The request is a plain GET, so that every cache between the user and the
service is able to store the answer.
Suggestions for a prefix
GET /v1/suggestions?q=ka HTTP/1.1
Host: search.example
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300
{
"prefix": "ka",
"suggestions": ["kafka", "karaoke near me", "kayak"]
}
The reply repeats the prefix so that the client can verify that the answer belongs to
the text now in the box. Cache-Control permits browsers and edge servers to reuse
the answer for 5 minutes.
A prefix with no completions
GET /v1/suggestions?q=kafx HTTP/1.1
Host: search.example
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300
{ "prefix": "kafx", "suggestions": [] }
An empty list is a successful answer and is returned with 200. It is cached like any
other, which protects the service against repeated requests for prefixes that lead
nowhere.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Suggestion API | looks up the list for a prefix and applies the list of exclusions | a copy of the table, in memory | another replica answers |
| Prefix lists | the table of the best completions for every prefix | an immutable file, replaced at each rebuild | every replica holds the same file |
| Search log | records every search that is submitted | the log, in a message queue | as in chapter 11 |
| Counting job | counts searches, with decay, at intervals | none between runs | the job is run again |
| Search counts | the count of every distinct search | counts, in a store | the previous counts are still available |
One request, step by step
The steps follow the arrows of figure 22.6.
- After a pause in the typing, the browser asks for suggestions for the text in the box, unless it already holds the answer.
- The suggestion API, if no cache has answered first, normalises the prefix, reads the row for it from the table in its memory, removes any excluded entries, and returns the list.
- Independently of this, every search that a user submits is appended to the search log.
- At intervals, the counting job reads the log and brings the count of every search up to date, reducing older counts.
- A new table of prefix lists is built from the counts, checked, and distributed to the replicas, which begin serving it in place of the old one.
The design, deployed
Most of the million requests per second are answered before they reach this system, by the browser’s own memory of earlier answers and by edge servers that cache each prefix for 5 minutes. The serving tier receives about a fifth of them. It consists of 24 machines, each holding a part of the table in memory, behind load balancers that route by the range of the prefix. Nothing in the serving tier ever writes. The table is produced by the build tier below, from the log of searches, and handed upwards once an hour as a finished file, so that a fault in the building can at worst leave the serving tier with an old table.
How real systems do it
Facebook published an early and clear account of a typeahead in 2010, for the search box that suggests people and pages. Its design starts in the browser, which is loaded with the user’s own friends and pages before a letter is typed and answers from that list first, and which caches every reply so that deleting a letter costs nothing. Only then is the service asked, and the service is a set of indexes, 1 for each kind of result, queried in parallel and merged by an aggregator. The emphasis on the client is the same as in this chapter, and the personal first stage is a form of the personalisation that this chapter mentions only in passing.
The data structure for the general problem, the most frequent completions of a prefix, has been studied as top-k completion. A paper from Microsoft compares several structures and shows that a trie which stores at each node the best score beneath it can find the best completions without visiting the whole subtree, in space close to that of the compressed strings. This is a middle way between the 2 extremes of this chapter, the trie that must be searched and the table that stores every answer, and it is what a system uses when the table would be too large. Search libraries commonly implement completion with a compact automaton that shares both the prefixes and the suffixes of the stored strings.
On what must not be shown, the operators of the large search engines publish policies stating which kinds of prediction they remove, covering violence, sexually explicit and hateful predictions and those that make damaging statements about named people, together with a means of reporting. The existence of these policies is the practical evidence for the section on suggestions that must not be shown. The threshold of distinct users is an application of the idea of k-anonymity, that no released item should be traceable to fewer than k people.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Ranking the matches while the user waits | Short prefixes match millions of searches and cannot be ranked in time. | Compute the best completions for every prefix in advance. |
| Updating the table on every search | The serving path carries a constant load of writes. | Rebuild in batches, and keep a small separate table for what is trending. |
| A request on every keystroke | Most answers are out of date on arrival. | Wait for a brief pause in the typing before asking. |
| Displaying every answer as it arrives | A slow answer for an earlier prefix overwrites the right one. | Display an answer only if it is for the text now in the box. |
| Requests that caches cannot store | Every keystroke of every user reaches the service. | Use GET with the prefix in the address, and state how long the answer may be reused. |
| Counting searches without normalising | The same search is divided among several spellings. | Normalise the text before counting and before looking up. |
| Counts that never decay | Searches that were popular years ago keep their places. | Reduce older counts at each period. |
| Suggesting anything that is frequent | Offensive or defamatory suggestions appear under the service’s name. | Filter candidates during the build, and allow prompt removal. |
| Suggesting rare searches | 1 person’s private search is shown to strangers. | Admit a search only when enough different people have made it. |
| Dividing by first letter | Servers for common letters are overloaded. | Divide by measured load, or by a hash. |
| Making search depend on suggestions | A failure of suggestions blocks the search box. | Let the user type and search whether or not suggestions arrive. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| The suggestion service is unreachable | no suggestions, and search works as usual | caches continue to answer common prefixes, and the service returns |
| A newly built table is faulty | strange or missing suggestions | the previous table is reinstated in 1 step |
| The counting job fails | suggestions grow slowly out of date | the job is run again, and the previous table serves meanwhile |
| An offensive suggestion is reported | it disappears within minutes | it is added to the exclusions, and the filter is corrected for the next build |
| An answer is lost or late | the list fails to update for 1 keystroke | the next keystroke sends a new request |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Answers computed in advance | 1 lookup per request, whatever the prefix | storage for every prefix, and staleness between rebuilds |
| Rebuilding in batches | simple, verifiable, and easy to reverse | new searches appear late, without the trending table |
| A separate trending table | fresh suggestions within a minute | a second mechanism to operate |
| Caching at the edge and in the browser | most requests never reach the service | a removed suggestion lingers until the caches expire |
| A threshold of distinct people | privacy, and a smaller table | rare searches that would be legitimate are never suggested |
The fourth row conceals a tension that deserves to be noticed. The longer the answers may be cached, the lighter the load, and the longer a suggestion that has been removed continues to be shown from caches that the service cannot reach. The period for which answers are cached is therefore chosen with removals in mind, and it is the reason why 5 minutes is a more usual figure than 5 hours.
The simulation
Simulation in the repository. The program code/typeahead simulates this design. To run it, clone the repository and type cargo run --release -p typeahead in the code directory.
References
- Facebook engineering blog, “The Life of a Typeahead Query”, 2010.
- B. Hsu and G. Ottaviano, “Space-Efficient Data Structures for Top-k Completion”, WWW 2013.
- Google Search Help, “How Google autocomplete predictions work”.
- L. Sweeney, “k-Anonymity: A Model for Protecting Privacy”, International Journal of Uncertainty, Fuzziness and Knowledge-Based Systems, 2002.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Autocomplete, typeahead | proposing complete searches for the text typed so far |
| Prefix | the text that has been typed so far |
| Completion | a search that begins with the prefix |
| Trie, prefix tree | a tree in which the path to each node spells a prefix |
| Top k | the k best items by some measure, here the k most frequent |
| Normalisation | bringing text to a standard form before comparing or counting it |
| Decay | reducing the weight of older counts |
| Trending | recently far more frequent than in the past |
| Debouncing | waiting for a pause in the typing before sending a request |
| Sampling | counting a random fraction of events in place of all of them |
Summary
- Autocomplete is requested for every keystroke and must answer within about 100 ms, which leaves no time to compute anything.
- A sorted list or a trie finds the searches that begin with a prefix quickly, and ranking them still costs in proportion to their number.
- The best completions of every prefix are therefore computed in advance, so that a request is a single lookup.
- The table is rebuilt in batches from counts of submitted searches, normalised, sampled and weighted towards the recent.
- A small table of trending searches, brought up to date every minute, supplies what the daily rebuild cannot.
- The client waits for a pause before asking, discards answers that arrive late, and reuses answers it already has.
- Candidates are filtered for offensive content, and a search is suggested only if enough different people have made it.
Web Crawler: System Design
A web crawler is a program that reads the web as a person would who had unlimited patience and no particular interest in any page. It begins with a few known addresses, downloads the pages at those addresses, extracts from each page the addresses to which it links, and proceeds to download those in their turn. Continued long enough, the process reaches every page that can be reached by following links, and the collection that results is the raw material of a search engine, of an archive, or of any system that needs to know what the web contains.
As an algorithm, this is the traversal of a graph, and an introductory course in programming covers it in a week. What separates the exercise from the system is the nature of this particular graph. It has tens of billions of nodes and can be stored on no single machine. It is not given in advance, each node being discoverable only by fetching another. It changes while it is being traversed. Parts of it are infinite, generated on demand by programs that will produce a new page for every address they are asked for. And every node belongs to somebody, who is paying for the server that answers and who will, quite reasonably, block a crawler that asks too much too quickly. A crawler has therefore to be at once fast in aggregate and gentle towards each individual site, and most of its design follows from the need to reconcile the 2.
This chapter covers
- the loop at the centre of every crawler
- the frontier: which address to fetch next, and why it is more than a queue
- politeness, and a queue for every host
- what a site asks of crawlers, and why a crawler complies
- recognising an address that has been seen before
- recognising a page that has been seen before under another address
- sites that never end
- dividing the crawl among many machines
- returning to pages that change
The loop
Every crawler, whatever its size, repeats the same 4 steps. It takes an address from the collection of those waiting to be fetched, which is called the frontier. It downloads the page at that address. It extracts the links from the page. And it adds to the frontier those of the links that it has not met before.
If the frontier were a simple first-in, first-out queue, this would be a breadth-first traversal. Almost everything of interest in a crawler’s design is a departure from that simple queue, prompted by 1 of 3 matters: the order in which addresses should be taken, given that there are always far more waiting than can be fetched, how soon the same server may be asked again, and how it is known that an address, or a page, has been dealt with already.
Politeness
A crawler with 1000 connections at its disposal could, if it chose, direct all of them at a single web site. For a small site this would be indistinguishable from an attack. The server would be overwhelmed, its ordinary visitors would be turned away, and its owner would respond by blocking the crawler’s addresses permanently, as would the owners of other sites who heard of it. A crawler that is to be tolerated must restrict itself, for each site separately, to a rate that the site will not notice. A usual rule allows 1 request to a given host at any moment, followed by a pause of a second or more before the next.
The difficulty is that this restriction is at odds with the order in which addresses are discovered. A page generally links mostly to other pages of its own site, so that the frontier, at any moment, contains long runs of addresses on the same host. A crawler that took addresses strictly in order would spend nearly all its time waiting for the pause on some host to expire, while thousands of other hosts stood ready.
The standard arrangement resolves this by giving the frontier a structure that mirrors the constraint. There is a separate queue for every host, and with each queue is recorded the earliest time at which that host may next be asked. A fetcher that wants work takes an address from any host whose time has come, and that host’s time is then moved forward by the length of the pause.
The simulation for this chapter crawls a small invented web of 3 hosts under this rule. In 8 seconds it fetches 10 pages, never asking any host twice within 2 seconds, and never standing idle while some host is ready.
The consequence for the performance of a crawler is fundamental and somewhat counter-intuitive. The speed of a crawl is determined by the number of different hosts it has in hand, and hardly at all by how fast any 1 of them answers. With a pause of 1 second, a crawler that knows of a million hosts can fetch a million pages a second, and a crawler confined to a single enormous site can fetch 1. A very large site therefore takes a long time to crawl completely however many machines are available, and the only remedy is an agreement with its owner.
The pause need not be the same for every host. A crawler can measure how long a
server takes to answer and wait some multiple of that, which treats a fast server
more briskly and backs away from one that is struggling. A server that begins to
return errors, or the status 429 of chapter 7, is telling the crawler directly that
it is asking too much, and the pause for that host should be lengthened at once.
What a site asks of crawlers
By a convention almost as old as the web, a site may publish a file named
robots.txt at its root, stating which parts of the site crawlers are asked to stay
out of, and sometimes how long they should wait between requests. A site might
exclude its search results, its shopping baskets, or pages that are costly to
generate.
Nothing enforces this file. A crawler obeys it because the alternative is to be blocked, and because the exclusions usually serve the crawler’s own interest by steering it away from pages of no value. The file has to be fetched before any other page of the host, and since it applies to every later request it is kept in a cache, with a time limit of perhaps a day, so that it is neither fetched for every page nor relied upon indefinitely after the site has changed it.
A crawler also identifies itself, by a name in the User-Agent header of each
request together with an address at which its operators can be reached. An
administrator who finds an unfamiliar program in the logs and can discover what it is
and whom to write to is far less inclined to block it than one who cannot.
An address seen before
The web is densely connected, and the same address appears as a link on a great many pages. In the small simulation, 7 of the links encountered led to addresses that were already known. On the real web the proportion is far higher, and a crawler that fetched every address every time it was mentioned would never finish. Every address ever added to the frontier is therefore recorded in a seen set, and a newly extracted link is checked against the set before anything else is done with it.
Before the check, the address has to be brought to a standard form, since many
different strings denote the same page. The name of the host is not sensitive to
case. A port number that is the default may be given or left out. The part of an
address after a # refers to a position within the page and is never sent to the
server. The parameters of a query may appear in any order, and often include
identifiers of a session or of an advertising campaign that have no effect on what
is returned. A crawler that failed to normalise its addresses would fetch the same
page many times under different spellings.
The seen set is very large. 10 billion addresses of 80 bytes each would occupy 800 GB, and the set is consulted for every link on every page. Storing a fixed-size hash of each address in place of the address reduces this by a factor of 10. A further reduction is possible if occasional errors are tolerated. The Bloom filter that chapter 9 used against cache penetration serves here too: it occupies about 10 bits for each address and answers the question of membership with certainty when the answer is no, and with a small probability of error, around 1 in 100, when the answer is yes. For a crawler the error means that a few addresses in every hundred which are in truth new are taken as seen and are not fetched. Whether that loss is acceptable depends on the purpose of the crawl, and for most purposes a web of which 1% is missed is a perfectly good web.
A page seen before
Normalising addresses does not eliminate duplication, because the same content is
very often served at addresses that are truly distinct. A site answers both with and
without www. An article is available at a short address and a long one, or in a
printable version, or on a second site that reproduces it. Estimates of the share of
the web that duplicates some other part of it run to a quarter or more.
Identical copies are detected easily. The crawler computes a checksum of the content of each page it fetches and keeps the checksums, as the object store of chapter 16 does for chunks. A page whose checksum is already present is a duplicate, and is neither stored again nor examined for links.
Copies that are nearly identical are a harder matter, and are much the commoner case. 2 copies of the same article may differ in the date printed at the top, in the advertisements, or in a counter of visitors, and a checksum, which changes completely when a single byte changes, treats them as unrelated. For this purpose a different kind of fingerprint is used, constructed so that similar documents receive similar fingerprints. The page is broken into overlapping sequences of a few words, each sequence is hashed, and the hashes are combined into a short value in such a way that every sequence has a small influence on every bit. 2 pages that share most of their sequences then receive fingerprints that differ in only a few bits, and near duplicates can be found by looking for fingerprints close to one another.
Sites that never end
Some parts of the web are generated by programs and have no boundary. The classic instance is a calendar.
The page for each day contains a link to the following day, and the server will obligingly produce a page for any date whatever. A crawler that follows links faithfully proceeds through the calendar day by day into the indefinite future. A shop with 5 filters that may be combined in any order, a site that embeds a fresh session identifier in every link, and a directory that by mistake contains a link to itself all have the same effect. Such structures are called crawler traps. Most are accidents, and a few are built on purpose to waste the time of crawlers.
No test can tell, for an arbitrary site, whether its pages are endless, and the defences are accordingly limits, since nothing can serve as a detector. A crawler restricts the number of pages it will take from any 1 host, as the simulation does when it stops the calendar at 4. It restricts the depth to which it will follow links from the page at which it entered a site. It restricts the length of the addresses it will accept, since addresses in a trap tend to grow. And it gives a larger allowance to hosts that many other sites link to, on the grounds that a site which the rest of the web considers important is unlikely to be a trap.
Which address next
With far more addresses waiting than can ever be fetched, the order in which they are taken decides what the crawl contains. A crawler that is to feed a search engine wants the important pages first, and has to estimate importance before fetching, from what is known about an address in advance: how many pages link to it, how important those pages are themselves, how near it is to the root of its site, and how valuable the other pages of its host have proved.
Priority and politeness have to be combined, and the usual structure has 2 stages. A set of queues ordered by priority feeds the queues ordered by host. Addresses enter at the front according to their estimated importance, are drawn from the high-priority queues more often than from the low, and are then placed in the queue of their host, from which the rule of politeness releases them. The first stage decides what is worth fetching, and the second decides when it may be fetched.
Many machines
A crawl of billions of pages is carried out by many machines, and the work has to be divided among them in such a way that the constraints still hold. The decisive constraint is politeness, which is a property of a host and must therefore be enforced in 1 place for each host. If 2 machines each held addresses of the same host and each observed the pause on its own account, the host would receive twice the permitted load.
The division is therefore made by host. Each host is assigned to a single crawler
machine, by hashing its name, with the consistent hashing of chapter 1 so that the
addition of a machine moves only a small share of the hosts. All the addresses of a
host are queued on its machine, which alone keeps the host’s waiting time and its
robots.txt. When a machine extracts a link to a host that belongs to another
machine, it forwards the address there, usually in batches by way of a message queue.
Since most links lead to the site on which they are found, most addresses stay where
they were discovered.
The frontier must survive failures. It represents days of accumulated discovery and cannot quickly be rebuilt. It is therefore kept on disk, and since it is far larger than memory, each host’s queue is held mostly on disk, with only the next few addresses of each host in memory.
A crawler also depends heavily on the name resolution system, since every host name has to be turned into a network address before the host can be contacted. At thousands of requests a second, the ordinary resolver becomes a bottleneck, and crawlers keep their own cache of resolved names, refreshed as the entries expire.
Returning to pages
The web changes, and a collection that was accurate last month is partly wrong today. A crawler has to revisit, and since it cannot revisit everything as often as it would wish, it has to decide how its limited capacity is best spent.
Pages change at very different rates. The front page of a newspaper changes every few minutes, and a paper published by a scientific journal 10 years ago will never change again. A crawler therefore records, for each page, how often it has found the page changed on its previous visits, and schedules the next visit accordingly: soon for a page that is usually different, and after a long interval for one that is usually the same.
HTTP allows a revisit to be made cheaply. The crawler sends the entity tag that it
received with the page on the last occasion, in the If-None-Match header that
chapter 1 described, and a server whose page has not changed answers 304 Not Modified with no body. The crawler has confirmed that its copy is current at the
cost of a few hundred bytes.
Use cases
An operator starts a crawl and governs it. A consumer, typically the indexer of a search engine, reads the pages that have been fetched.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | Starting from given addresses, the crawler fetches the pages that can be reached by following links. |
| 1 | No address is fetched more than once in a crawl. |
| 2 | The crawler observes the rules that each site publishes for crawlers. |
| 3 | No host is asked more often than once in a configured interval. |
| 4 | Pages whose content has already been stored are recognised and not stored again. |
| 5 | Pages are fetched again at intervals that depend on how often they change. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Scale | 10 billion pages in a crawl of a month, which is about 4000 pages per second. |
| 1 | Politeness | At most 1 request at any moment to a host, with at least 1 second between requests. |
| 2 | Durability | The frontier and the seen set survive the failure of any machine. |
| 3 | Consistency | Eventual. A small share of addresses may be fetched twice or missed. |
| 4 | Robustness | No page, however malformed or large, stops the crawl. |
A crawler is unusually tolerant of small errors, and non-functional requirement 3 says so. If 1 address in 100 is skipped because a Bloom filter was mistaken, or fetched twice because a machine failed between fetching and recording, the resulting collection is for all practical purposes as good. This tolerance is what allows the seen set to be approximate and the coordination between machines to be loose. The 1 requirement that admits no approximation is politeness, since the party harmed by a violation is somebody else.
Core entities
Capacity estimate
Arithmetic ahead. 10 billion pages of 100 KB are 1 PB of content, fetched at 4000 pages per second, which needs about 3 Gbit/s of bandwidth and a few hundred fetching machines.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Pages per second | 10 billion ÷ (30 × 86400 s) | about 3900 |
| Content stored | 10 billion × 100 KB | 1 PB |
| Bandwidth | 3900 × 100 KB × 8 | about 3 Gbit/s |
| Hosts needed to sustain the rate | 3900 pages per second × 1 s pause | at least 3900 hosts ready at every moment |
| Seen set as hashes | 10 billion × 8 bytes | 80 GB |
| Seen set as a Bloom filter | 10 billion × 10 bits | 12.5 GB |
The API
A crawler has few callers. An operator supplies starting addresses and rules, and a consumer reads what has been fetched, in pages, by a cursor.
Give starting addresses
POST /v1/seeds HTTP/1.1
Host: crawler.internal
Content-Type: application/json
{ "addresses": ["https://news.example/", "https://shop.example/"] }
HTTP/1.1 202 Accepted
Content-Type: application/json
{ "accepted": 2 }
The status is 202, since the addresses have been taken into the frontier and
nothing has yet been fetched.
Set the rules for a host
PUT /v1/hosts/calendar.example/rules HTTP/1.1
Host: crawler.internal
Content-Type: application/json
{ "pause_seconds": 5, "page_limit": 1000, "depth_limit": 6 }
HTTP/1.1 204 No Content
Read the fetched pages
GET /v1/pages?after=880213&limit=2 HTTP/1.1
Host: crawler.internal
HTTP/1.1 200 OK
Content-Type: application/json
{
"pages": [
{ "id": 880214, "address": "https://news.example/a", "fetched": "2026-10-14T09:00:02Z", "status": 200, "content": "/v1/pages/880214/content" },
{ "id": 880215, "address": "https://shop.example/item-1", "fetched": "2026-10-14T09:00:03Z", "status": 200, "content": "/v1/pages/880215/content" }
],
"next_after": 880215
}
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Frontier | holds the waiting addresses, in a queue for each host | the queues and waiting times, on disk | its hosts are taken over, with their queues, by another machine |
| Fetcher | downloads pages, within a time limit and a size limit | none | the address returns to the frontier |
| Host rules | holds each host’s robots.txt and limits | a cache | the rules are fetched again |
| Parser | extracts and normalises the links of a page | none | the page is parsed again |
| Link filter | removes addresses that have been seen | the seen set | the set is restored from its copy |
| Page store | keeps the content of every page | the pages, in an object store | as in chapter 16 |
One page, step by step
The steps follow the arrows of figure 23.5.
- The frontier hands a fetcher the next address of a host whose waiting time has passed, and moves that host’s time forward.
- The fetcher consults the rules of the host. If the address is excluded, it is dropped. Otherwise the fetcher downloads the page, giving up if the server is too slow or the page too large.
- The page passes to the parser.
- The content is given a checksum. If no page with that checksum has been stored, the page is stored.
- The parser extracts the links, normalises them, and passes them to the link filter.
- The filter drops every address that is in the seen set, adds the others to the set, and sends them to the frontier, where each joins the queue of its host.
The design, deployed
A crawler has no users in the ordinary sense, and its deployment is that of a pipeline. The 40 frontier machines are the heart of it. Each owns the hosts whose names hash to it, and holds their queues on disk. The 300 fetchers are stateless and hold thousands of connections each. Parsers, the seen set and the queue that forwards links to the machine owning their host form the processing tier, and the pages themselves go to an object store. Since the frontier is the only state that cannot be rebuilt quickly, it is the part that is replicated.
How real systems do it
The frontier of this chapter, with queues by priority feeding queues by host, is the design of Mercator, a crawler built at Compaq and described in 1999. Mercator’s paper names the 2 stages the front queues and the back queues, keeps exactly 1 back queue for each host being crawled, and holds a heap of the times at which each host may next be contacted. The paper on the original Google crawler, of the year before, describes a simpler arrangement and is of interest chiefly for its account of what went wrong when a crawler first met the real web.
How far a single machine can be taken was shown by IRLbot, which crawled over 6 billion pages in 41 days from 1 server. Its paper is largely about the 2 problems that this chapter treats under the seen set and the endless site. For the first it replaces the in-memory set by a structure on disk that checks addresses in large sorted batches. For the second it gives each site a budget that depends on how many other sites link to it, which is the allowance described above, and it argues that without such a budget any crawler at scale is eventually captured by sites that generate pages without end.
The fingerprint under which similar pages receive similar values is simhash. Google described using it on a collection of 8 billion pages, with fingerprints of 64 bits, treating 2 pages as near duplicates when their fingerprints differ in at most 3 bits, and the paper’s main contribution is a way of finding all fingerprints within that distance of a given one without comparing it with every other.
The convention of robots.txt, in use since 1994, was written down as a standard
only in 2022. And the scheduling of revisits according to observed rates of change
goes back to a study which showed, among other things, that revisiting every page
equally often keeps a collection fresher than revisiting each in proportion to how
often it changes, since pages that change constantly can never be kept fresh and
absorb the effort.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| 1 queue for all addresses | The crawler either overloads a site or waits on 1 host while thousands are ready. | Keep a queue and a waiting time for every host. |
| Enforcing politeness separately on each machine | A host served by 2 machines receives twice the permitted load. | Assign each host to exactly 1 machine. |
Ignoring robots.txt | The crawler is blocked, and fetches pages of no value. | Fetch and cache the rules of each host before anything else. |
| Comparing addresses as raw strings | The same page is fetched under many spellings. | Normalise every address before checking the seen set. |
| Detecting duplicates by address only | The same article is stored from many addresses. | Compare checksums of content, and fingerprints for near duplicates. |
| Following every link without limit | A calendar or a set of filters occupies the crawler for ever. | Limit the pages and the depth for each host. |
| No limit on time or size of a download | 1 slow or endless response holds a fetcher indefinitely. | Give every download a time limit and a size limit. |
| The frontier in memory only | A restart loses days of discovery. | Keep the frontier on disk. |
| Asking the resolver for every request | Name resolution becomes the bottleneck. | Cache resolved names. |
| Revisiting every page equally often | Capacity is spent on pages that never change. | Revisit according to how often each page has changed. |
| Persisting when a host returns errors | The host is pushed further into overload, and blocks the crawler. | Lengthen the pause for a host that answers with errors or 429. |
Failures
| Failure | What is observed | How the system recovers |
|---|---|---|
| A fetcher crashes during a download | the page is not stored | the address was not removed from its queue until the page was stored, and is fetched again |
| A crawler machine is lost | its hosts are not crawled for a time | its hosts and their queues pass to other machines |
| A host does not respond | downloads to it time out | its pause is lengthened, and after repeated failures it is set aside for a day |
| A page is enormous or never ends | the download is cut off | the size limit and the time limit end it |
| The seen set gives a false “seen” | a new address is not fetched | accepted, as a known property of the filter |
| A site blocks the crawler | every request is refused | the host is set aside, and an operator is informed |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| A queue for every host | politeness without idleness | a frontier that is large and complicated |
| Hosts assigned to machines by hash | politeness enforced in 1 place | links to other hosts have to be forwarded |
| A Bloom filter for the seen set | a tenth of the memory | about 1 new address in 100 is missed |
| Limits for each host | traps cannot hold the crawler | a large legitimate site may be cut short |
| Revisits according to past change | fresh copies of what changes | stale copies of pages that change unexpectedly |
The simulation
Simulation in the repository. The program code/crawlsim simulates this design. To run it, clone the repository and type cargo run --release -p crawlsim in the code directory.
References
- A. Heydon and M. Najork, “Mercator: A Scalable, Extensible Web Crawler”, World Wide Web, 1999.
- S. Brin and L. Page, “The Anatomy of a Large-Scale Hypertextual Web Search Engine”, WWW 1998.
- H. Lee, D. Leonard, X. Wang and D. Loguinov, “IRLbot: Scaling to 6 Billion Pages and Beyond”, WWW 2008.
- G. Manku, A. Jain and A. Das Sarma, “Detecting Near-Duplicates for Web Crawling”, WWW 2007.
- RFC 9309, Robots Exclusion Protocol, 2022.
- J. Cho and H. Garcia-Molina, “Effective Page Refresh Policies for Web Crawlers”, ACM Transactions on Database Systems, 2003.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Crawler | a program that fetches pages and follows their links |
| Frontier | the addresses that are known and have yet to be fetched |
| Seed | an address from which a crawl starts |
| Host | the server named in an address, to which politeness applies |
| Politeness | limiting the rate of requests to each host |
robots.txt | a file in which a site states what crawlers are asked to leave alone |
| Normalisation | bringing an address to a standard form |
| Seen set | the set of every address that has been added to the frontier |
| Bloom filter | a compact set that can be wrong only by reporting an absent item as present |
| Near duplicate | a page that differs only slightly from another |
| Crawler trap | a part of a site that produces pages without end |
| Revisit | fetching a page again to learn whether it has changed |
Summary
- A crawler repeats 4 steps: take an address, fetch the page, extract its links, and add the new ones to the frontier.
- Each host may be asked only at intervals, so the frontier keeps a queue and a waiting time for every host.
- The speed of a crawl comes from the number of hosts in hand, and no single site can be crawled quickly.
- Addresses are normalised and checked against a seen set, which may be a Bloom filter where a small loss is acceptable.
- Duplicate content is found by checksum, and near duplicates by fingerprints that are similar for similar pages.
- Endless sites cannot be detected, and are contained by limits on pages and depth for every host.
- Each host is assigned to 1 machine, so that politeness is enforced in a single place.
- Pages are revisited at intervals that depend on how often they have been found changed.
Metrics and Monitoring: System Design
Every system in this book has been described as doing what its design says. A real system has also to be watched, since machines fail, software is released with defects, and traffic arrives in patterns that nobody foresaw. The people responsible need to know, at every moment, how many requests the system is receiving, how many of them are failing, how long they take, and how close each machine is to the limits of its memory and its disks. They need to see these quantities change over hours and weeks, and they need to be woken at night when one of them goes wrong. The system that provides for this is a monitoring system, and the numbers it collects are called metrics.
A monitoring system has a peculiar workload. It receives an enormous number of very small writes, since every service on every machine reports dozens of numbers every few seconds, and those writes never stop. Its data is of a single shape, a number with a time attached, which permits a degree of compression that no general database approaches. Almost nobody reads the data, and yet when it is read, during an incident, the answer is wanted immediately and may span months. And the system has a responsibility of an unusual kind. It is the means by which every other failure is detected, and it must therefore go on working when the things around it do not.
This chapter covers
- what a metric is, and the 3 kinds in common use
- time series, and why their number is the quantity to watch
- whether services send their measurements or are asked for them
- storing a number every few seconds for a year in 2 bytes a point
- reducing old data to 1 point a minute, and what that loses
- why an average of response times misleads, and what to keep in its place
- alerts that wake somebody for the right reasons
- keeping the monitoring system alive when everything else is failing
What a metric is
A metric is a number, measured repeatedly, that describes some aspect of a running system. 3 kinds account for nearly all of them, and they behave differently enough that it matters which is which.
A counter is a number that only ever increases: the requests served since the process started, or the bytes sent. Its value at a single moment is of no interest. What is wanted is its rate of change, obtained by subtracting 2 readings and dividing by the time between them. Counters have the great merit that nothing is lost between readings. If a reading is missed, the next one still includes everything that happened in the interval.
A gauge is a number that rises and falls: the memory in use, the length of a queue, the temperature of a processor. Here the value at a moment is the thing of interest, and whatever it did between 2 readings is unknown. A queue that filled and emptied within 5 seconds leaves no trace in a gauge read every 10.
A histogram records the distribution of a quantity that takes a different value on every occasion, above all the time taken to answer a request. Its treatment needs a section of its own, since the natural way of summarising such a quantity, by its average, turns out to be nearly useless.
Time series
A measurement is identified by the name of the metric and by a set of labels,
which are pairs of a key and a value saying what the measurement refers to. The
requests served by the payments service on machine 14 with status 500 might be
recorded as the metric requests_total with the labels service=payments,
machine=14 and status=500. The sequence of values recorded for 1 metric with 1
particular set of label values is a time series.
Labels are what make metrics useful, since they allow the same measurement to be added up or separated out along any dimension: all requests to payments, all requests with status 500, all requests on machine 14. They are also the principal hazard in the operation of a monitoring system, for a reason that follows from simple multiplication.
Every distinct combination of label values is a separate series, to be stored and indexed separately. A metric with the labels service, machine and status, in a system with 20 services, 100 machines and 5 statuses in use, has up to 10000 series, which is entirely manageable. Suppose now that a well-meaning engineer adds a label for the identifier of the user, so as to see which users experience errors. With a million users, the 1 metric has up to 10 billion series. The number of series that a metric gives rise to is called its cardinality, and a single label whose values are unbounded, such as a user identifier, a request identifier or the full text of an address, is enough to exhaust the memory of the system within hours.
The rule that follows is that labels must take a small and bounded number of values. A question about an individual user or request is a legitimate one, and it belongs to a different kind of system: to logs, which record individual events, or to traces, which follow a single request through the services it touches. Metrics answer questions about aggregates, and pay for their cheapness by being unable to answer any other kind.
Sending or being asked
Measurements have to travel from the services to the monitoring system, and there are 2 ways of arranging it.
Under the push model, each service sends its measurements to the monitoring system at regular intervals. Under the pull model, each service exposes its current values at an address of its own, and the monitoring system visits every service at regular intervals to collect them.
| Push | Pull | |
|---|---|---|
| Who must know whom | every service knows the address of the monitoring system | the monitoring system knows the address of every service |
| A service that has died | stops sending, which looks the same as having nothing to report | fails to answer, which is itself a clear signal |
| A service that lives for seconds | can report before it exits | may be gone before it is visited |
| Who controls the load | the services, all at once | the monitoring system, at its own pace |
| Services behind a firewall | can send outwards | cannot be reached |
The second row is the strongest argument for pulling. When the monitoring system does the asking, the absence of an answer is information, and the health of every service is checked as a by-product of collecting its numbers. The third row is the strongest argument for pushing, since a batch job that runs for 5 seconds cannot wait to be visited. Many installations use both, pulling from long-lived services and accepting pushes from short-lived ones. The design of this chapter pulls, with collectors that visit each service every 10 seconds.
Storing a number every 10 seconds
The store receives points, each consisting of a time and a value, and must keep them for months. Stored plainly, as 2 numbers of 8 bytes, a point occupies 16 bytes. A million series sampled every 10 seconds produce 8.6 billion points a day, or 138 GB, and a year of that is 50 TB.
Time series have 2 properties that allow far better. The points of a series arrive in order of time and are never modified afterwards, so that the store only ever appends. And successive points resemble one another closely: the times are evenly spaced, and the values change little from one reading to the next. A number that is close to its predecessor can be stored as the difference between them, and a small difference needs few bits.
For the times, the differences are themselves nearly constant, the interval of collection, so that storing the difference between successive differences yields 0 almost every time. For the values, the difference from the previous value is small whenever the quantity being measured is steady, which most are for most of the time.
The simulation for this chapter stores an hour of a response time measured every second, 3600 points. Stored plainly they occupy 57600 bytes. Stored as changes, with small numbers written in fewer bytes, they occupy 7223, which is 2 bytes for each point and an eighth of the plain size. Production systems encode at the level of individual bits and exploit the structure of floating-point numbers, and commonly reach between 1 and 2 bytes a point. The 50 TB a year computed above becomes about 5.
Compression of this kind has a consequence for the layout of the store. A point encoded as a difference can be decoded only by starting from the beginning of its run and working forwards. Points are therefore grouped into blocks, each covering a fixed span of time for 1 series, 2 hours for example, and each block is encoded and decoded as a unit. The block being written is held in memory, where it can be appended to, and is protected by a write-ahead log of the kind described in chapter 2, and when its span is over it is compressed and written to disk, never to be modified again. Reading a range of time means locating the blocks that cover it, which is quick because blocks are indexed by series and by time.
Keeping less of the past
Even well compressed, points every 10 seconds for a year are more than most questions about the past require. A graph of the last year, drawn on a screen 2000 pixels wide, can show at most 2000 values, and to compute them from 3 million points for each series would be slow and pointless.
Older data is therefore reduced in resolution, a process called downsampling or rollup. A background job reads the raw points of each series and writes 1 point for every minute, and later 1 for every hour. Recent data is retained at full resolution for some days, the 1-minute data for some months, and the hourly data for years. A query chooses the resolution suited to the range it covers.
What is written for each minute has to be chosen with care, since a single number cannot represent 6 or 60 readings without losing something, and what is lost depends on the number chosen.
In the simulation, 1 minute contains 57 seconds in which the response time is about 40 ms and 3 seconds in which it is 900. Kept as its average, that minute is recorded as 83 ms, which suggests a slight slowness and conceals entirely that for 3 seconds the service was nearly unusable. Kept as its largest value, the minute is recorded as 900 ms, which preserves the incident and says nothing of how brief it was. Neither is wrong, and neither is sufficient. A rollup therefore stores several numbers for each interval, usually the smallest value, the largest, the sum and the count, from which the average can be derived and the extremes are not lost.
Why an average of response times misleads
The same example exposes a wider difficulty. The time taken to answer a request is the most important single measure of a service, and its average is a poor description of it.
Response times are not distributed symmetrically about a typical value. Most requests are fast, and a small proportion are very much slower, because they met a busy disk, an expired cache entry or a retry. The average is pulled upwards a little by these slow requests and describes neither group. A service whose requests take 40 ms in 99 cases and 2 seconds in the hundredth has an average of 60 ms, which looks entirely healthy, while 1 user in every 100 waits 2 seconds, and a user who loads a page that issues 50 requests has a chance of 2 in 5 of encountering at least 1 of them.
The quantity that describes experience is the percentile, introduced in chapter 1. The 99th percentile, written p99, is the time within which 99% of requests complete, and it states directly how bad the experience is for the least fortunate 1 in 100.
Percentiles have an awkward property that averages lack, and it shapes how response times must be collected. They cannot be combined. The average of 2 machines can be computed from the average and the count of each. The p99 of 2 machines cannot be computed from the p99 of each: if one machine has a p99 of 50 ms and the other of 2 seconds, the combined p99 may be almost anything between, depending on how the requests were divided. A service that computes its own percentiles and reports them has produced numbers that can never be aggregated across machines or across time.
The histogram solves this. A service does not report a percentile. It counts its requests in buckets by duration, so many under 10 ms, so many under 50 ms, so many under 250 ms and so on, and reports the counts. Counts can be added, across machines and across intervals, and a percentile is estimated from the combined counts at the moment of the query. The estimate is only as fine as the buckets, so the boundaries of the buckets have to be chosen around the durations that matter, but it can be computed for any grouping of machines over any period, which no percentile computed in advance allows.
Alerts
A monitoring system that is only looked at when something is already known to be wrong has done half its work. The other half is to notice the trouble first, by means of alerts. An alert is a rule consisting of a query and a condition, which the system evaluates at regular intervals, notifying somebody when the condition holds.
Rules are easy to write and hard to write well, and the commonest failing of monitoring in practice is too many alerts. An engineer who is woken 3 times a night by alerts that required no action soon ceases to respond with any urgency, and the alert that matters is then lost among the rest. A few principles keep alerts worth answering.
An alert should concern what users experience. What machines are doing is a different matter. “The proportion of failed requests has exceeded 1%” is a statement about harm. “Processor usage is at 90%” is a statement about a machine, which may be working exactly as intended. Alerts on causes multiply endlessly, since there are countless possible causes, while the symptoms that matter are few.
A condition should have to hold for some time before the alert fires. A single reading above a threshold is very often a momentary disturbance, and a rule that requires the condition to persist for 5 minutes ignores the noise at the price of 5 minutes’ delay.
Every alert should demand an action. If the proper response to an alert is to observe it and go back to sleep, it belongs on a dashboard, and nobody should be notified of it.
And related alerts should be grouped. When a database fails, 50 services that depend on it fail too, and 50 alerts arriving together tell the engineer less than 1 that names the database. The notification itself is delivered by the notification service of chapter 13, with escalation to a second person if the first does not acknowledge it.
One further case deserves mention because it is so often overlooked. A rule that tests whether a value is too high does not fire when there is no value at all. If a service stops reporting altogether, its error rate is not high, it is absent, and a naively written alert remains silent during the worst failure of all. Every important series therefore needs a second rule that fires when data has ceased to arrive.
Watching the watcher
The monitoring system is relied upon precisely when other things are failing, and its design has to take account of that.
It must not share the fate of what it monitors. If it runs on the same machines, the same network and the same storage as the services it watches, a single failure may remove both, and the engineers are left without information exactly when they need it most. It is therefore given infrastructure of its own, and kept as simple and as free of dependencies as its function allows. A monitoring system that needs the message queue, the coordinator and the object store all to be healthy before it can display a graph will be unavailable in most serious incidents.
It must also withstand the load that incidents themselves produce. When something breaks, many engineers open many dashboards together, each issuing dozens of queries over long ranges, at the very moment when the stream of incoming measurements may also be surging. The paths for writing and for querying are separated, so that a flood of queries cannot delay the storing of new points, and queries are bounded in the time and memory they may consume.
Finally, something has to watch the monitoring system itself. The usual arrangement is a second, minimal instance that checks nothing except whether the first is alive and evaluating its rules, and that raises its own alert through an independent channel when it is not.
Use cases
Services report measurements. Engineers look at graphs, define alerts and are notified.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | The system collects measurements from every service at a fixed interval. |
| 1 | An engineer can query any series, or a combination of series selected by labels, over any range of time. |
| 2 | Percentiles of durations can be computed across any group of machines. |
| 3 | An engineer can define alerts, each a query, a condition and a duration. |
| 4 | When an alert fires, the people responsible are notified. |
| 5 | Data is kept at full resolution for 15 days and at lower resolution for 2 years. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Scale | 10 million series, sampled every 10 seconds: 1 million points per second. |
| 1 | Latency of queries | A dashboard covering the last 6 hours loads within 1 second. |
| 2 | Freshness | A point is visible in queries within 30 seconds of being measured. |
| 3 | Availability | 99.9%, and independent of the systems that are monitored. |
| 4 | Durability | The loss of a few seconds of points in a failure is acceptable. The loss of history is not. |
| 5 | Consistency | Eventual. 2 queries moments apart may differ in the most recent points. |
Non-functional requirement 4 distinguishes 2 things that are often run together. A single point of a metric is nearly worthless: if 10 seconds of 1 series are lost, a graph shows a small gap and no decision is affected. The accumulated history is valuable, since it is what allows this week to be compared with the same week of last year. The design accordingly accepts weak durability for incoming points, which keeps the write path cheap, and stores completed blocks with the full protection of the object store of chapter 16.
Core entities
Capacity estimate
Arithmetic ahead. 10 million series at 1 point every 10 seconds, compressed to 2 bytes a point, need about 170 GB a day at full resolution.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Points per second | 10 million series ÷ 10 s | 1 million |
| Points per day | 1 million × 86400 | 86.4 billion |
| Bytes per day, plain | 86.4 billion × 16 bytes | 1.4 TB |
| Bytes per day, compressed | 86.4 billion × 2 bytes | 173 GB |
| 15 days at full resolution | 173 GB × 15 | 2.6 TB |
| 2 years at 1 point per hour, 4 numbers each | 10 million × 17520 × 4 × 2 bytes | 1.4 TB |
| Memory for the current blocks | 10 million series × 720 points × 2 bytes | 14 GB |
The API
Under the pull model, a service takes no action to report. It answers a request from a collector with its current values. The API of the monitoring system itself is used by dashboards and by the people who define alerts.
What a collector receives from a service
GET /metrics HTTP/1.1
Host: payments-14.internal
HTTP/1.1 200 OK
Content-Type: text/plain
requests_total{service="payments",status="200"} 918234
requests_total{service="payments",status="500"} 417
queue_length{service="payments"} 12
request_duration_bucket{service="payments",le="0.05"} 880112
request_duration_bucket{service="payments",le="0.25"} 915540
request_duration_bucket{service="payments",le="+Inf"} 918651
Each line is the present value of 1 series. The lines of request_duration_bucket
are a histogram: le is the upper bound of a bucket in seconds, and each count
includes every request that took no longer than that.
Query a series over a range
GET /v1/series?query=rate(requests_total{service="payments",status="500"})&from=2026-10-14T08:00:00Z&to=2026-10-14T09:00:00Z&step=60 HTTP/1.1
Host: metrics.internal
HTTP/1.1 200 OK
Content-Type: application/json
{
"step": 60,
"points": [
{ "time": "2026-10-14T08:00:00Z", "value": 0.02 },
{ "time": "2026-10-14T08:01:00Z", "value": 0.03 }
]
}
Define an alert
PUT /v1/alerts/payments-errors HTTP/1.1
Host: metrics.internal
Content-Type: application/json
{
"query": "rate(requests_total{service=\"payments\",status=\"500\"}) / rate(requests_total{service=\"payments\"})",
"condition": "> 0.01",
"for_seconds": 300,
"notify": "payments-on-call"
}
HTTP/1.1 204 No Content
The rule fires when more than 1% of the requests to payments have failed continuously for 5 minutes.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Collector | visits each service and reads its values | the list of services | its services are visited by another collector |
| Series store | appends points, and compresses finished blocks | current blocks in memory, with a log, and finished blocks on disk | a replica that received the same points answers |
| Rollup job | reduces old points to 1 per minute and 1 per hour | none | it is run again |
| Query service | reads blocks and computes what a graph or a rule asks for | a cache of recent results | another instance answers |
| Alert evaluator | evaluates every rule once a minute | which alerts are firing | a second evaluator runs the same rules |
One measurement, step by step
The steps follow the arrows of figure 24.5.
- Every 10 seconds, a collector asks each of its services for its current values.
- The collector attaches the time to each value and sends the points to the series store, which appends each to the current block of its series.
- A dashboard sends a query. The query service finds the blocks that cover the range, decodes them, and computes the result.
- In the background, the rollup job reads blocks that are some hours old and writes 1 point for each minute.
- Once a minute, the alert evaluator runs the query of every rule, and notifies the people named when a condition has held for long enough.
So that the loss of 1 machine of the series store leaves no gap, every point is sent to 2 machines, each of which builds its own blocks independently. The 2 copies are not kept identical by any protocol, and may differ by a point here and there. For this data that is acceptable, and the query service merges whatever it receives.
The design, deployed
The system watches 100000 service instances with 60 collectors, arranged so that every group of services is visited by 2 of them. Each collector sends its points to 2 nodes of the series store, which build their blocks independently, and finished blocks are copied to an object store. The reading tier is kept apart from the writing tier, with its own machines, so that a flood of queries during an incident cannot delay the storing of new points. The last box is the smallest and among the most important: a second, minimal monitor in another place, which checks nothing except that the first is alive.
How real systems do it
The compression described in this chapter is that of Gorilla, an in-memory store that Facebook described in 2015. The paper introduced the 2 techniques together: storing each timestamp as the difference between successive differences, and storing each value as the result of combining its bits with those of the previous value, which for a floating-point number that changes little leaves mostly zeros. It reports an average of 1.37 bytes for each point against 16 uncompressed, a reduction of 12 times, which is somewhat better than the 2 bytes of the simulation here, whose encoding works on whole bytes. The paper also argues for the priorities adopted in this chapter, that a monitoring store should favour availability for writing and for recent reads over consistency, and should tolerate the loss of a small amount of data.
Prometheus is the widely used system that collects by pulling, and its storage engine adopts Gorilla’s encoding, with blocks of 2 hours that are written once and merged into larger blocks as they age. Google’s Monarch, described in 2020, is a system on a far larger scale that receives pushes and keeps recent data in memory in every region, and its paper gives as a design principle the independence from other systems that this chapter set out under watching the watcher.
The advice on alerts is drawn from Google’s book on site reliability engineering, which distinguishes symptoms from causes and proposes 4 signals to watch for any service that answers requests: latency, traffic, errors and saturation. The danger of the average, and the way in which a small proportion of slow requests comes to affect most users of a service built from many parts, is the subject of a well known article by Dean and Barroso.
For percentiles, fixed buckets are the simplest histogram, and better ones exist. More recent structures choose their buckets so that the relative error of any percentile is bounded, and can still be merged across machines, which removes the need to guess the boundaries in advance.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| A label with unbounded values | The number of series multiplies until memory is exhausted. | Keep labels to small, fixed sets of values, and put identifiers in logs. |
| Reporting gauges for things that are counted | Whatever happened between 2 readings is lost. | Report counters, and compute rates when querying. |
| Storing points plainly | Storage is 8 times what it need be. | Encode each point as its change from the one before. |
| Rolling up to the average alone | Short spikes vanish from the history. | Keep the smallest value, the largest, the sum and the count. |
| Watching the average response time | A slow hundredth of requests is invisible. | Watch percentiles. |
| Computing percentiles in each service | They cannot be combined across machines. | Report histograms, and compute percentiles when querying. |
| Alerts on causes | Engineers are woken by things that harm nobody. | Alert on what users experience. |
| Alerts that fire on 1 reading | Momentary noise pages people. | Require the condition to hold for some minutes. |
| No alert for missing data | A service that stops reporting raises no alarm. | Add a rule that fires when a series stops arriving. |
| Monitoring on the monitored infrastructure | A single failure removes both the system and the view of it. | Give monitoring its own machines, network path and storage. |
| Queries without limits | 1 dashboard over a year exhausts the query service during an incident. | Bound the time and memory of every query, and use rollups for long ranges. |
Failures
| Failure | What is observed | How the system recovers |
|---|---|---|
| A service stops answering the collector | its series end | the rule for missing data fires |
| A collector fails | its services are not visited | another collector takes over its list |
| A machine of the series store fails | no gap, since each point went to 2 machines | a replacement builds its blocks from then on, and older blocks are in the object store |
| The store restarts | up to a few seconds of points may be missing | the current blocks are rebuilt from the log |
| A flood of queries | dashboards load slowly | queries are limited, and writes are unaffected |
| The alert evaluator fails | no alerts fire | a second evaluator runs, and an independent check watches both |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Pulling measurements | a silent service is noticed, and the collectors set the pace | short-lived jobs need a separate way in |
| Bounded labels | a predictable number of series | no questions about individual users or requests |
| Compression by differences | about 2 bytes a point | a block must be decoded from its start |
| Rollups | fast queries over long ranges, and far less storage | the detail of the past is gone |
| 2 independent copies without agreement | a simple and cheap write path | the copies may differ by a few points |
| Histograms | percentiles over any group and period | accuracy limited by the buckets chosen |
The simulation
Simulation in the repository. The program code/metricsim simulates this design. To run it, clone the repository and type cargo run --release -p metricsim in the code directory.
References
- T. Pelkonen and others, “Gorilla: A Fast, Scalable, In-Memory Time Series Database”, VLDB 2015.
- C. Adams and others, “Monarch: Google’s Planet-Scale In-Memory Time Series Database”, VLDB 2020.
- Prometheus documentation, “Storage”.
- B. Beyer and others (editors), Site Reliability Engineering, O’Reilly, 2016, chapter 6, “Monitoring Distributed Systems”.
- J. Dean and L. Barroso, “The Tail at Scale”, Communications of the ACM, 2013.
- C. Masson, J. Rim and H. Lee, “DDSketch: A Fast and Fully-Mergeable Quantile Sketch with Relative-Error Guarantees”, VLDB 2019.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Metric | a number, measured repeatedly, that describes a running system |
| Counter | a metric that only increases, of which the rate is what matters |
| Gauge | a metric that rises and falls, of which the present value is what matters |
| Histogram | counts of events in buckets by size, from which percentiles are estimated |
| Label | a key and a value that say what a measurement refers to |
| Time series | the values of 1 metric with 1 set of label values, in order of time |
| Cardinality | the number of series that a metric gives rise to |
| Push, pull | services sending their measurements, and the monitoring system asking for them |
| Block | the points of 1 series for a fixed span of time, compressed together |
| Downsampling, rollup | reducing old data to fewer points |
| Percentile | the value below which a given percentage of measurements fall |
| Alert | a query and a condition that, when it holds, causes somebody to be notified |
| Logs, traces | records of individual events, and of the path of a single request |
Summary
- Metrics are numbers measured repeatedly, of 3 kinds: counters, gauges and histograms.
- Every combination of label values is a separate series, and a label with unbounded values will exhaust the system.
- Pulling lets the monitoring system notice a service that has fallen silent, and pushing suits jobs that live for seconds.
- Points arrive in order and resemble their predecessors, so that storing each as its change from the one before takes about 2 bytes.
- Old data is reduced to 1 point for each minute or hour, keeping the smallest value, the largest, the sum and the count.
- An average of response times hides the slow requests that users notice, and percentiles must be computed from histograms because they cannot be combined.
- An alert should concern what users experience, hold for some time before firing, demand an action, and have a companion that fires when data stops.
- The monitoring system runs on infrastructure of its own, and is itself watched by something independent.
File Sync: System Design
A file synchronisation service keeps the same folder on several devices. A person saves a document on a laptop, and a few seconds later the same document, with the same changes, is on their phone and their desktop computer, without anything having been copied by hand. If a device is switched off or out of reach at the time, it receives the changes when it next connects. From the user’s side the service is nearly invisible, a folder that is the same everywhere.
Behind that simplicity lies a problem of replication with a feature that the replicated stores of earlier chapters were spared. In chapter 3 a single leader accepted all the writes of a shard, and the followers had only to copy what the leader decided. Here every device is a place where writes originate, and devices write while they are disconnected, sometimes for days. A laptop on a flight and a phone in the mountains may each change the same file, in different ways, with neither able to learn of the other until both return. No arrangement of leaders can prevent this, since the devices cannot reach any leader at the time. The service has to accept that conflicting changes will be made, detect them reliably when they come to light, and resolve them without destroying anybody’s work.
The other half of the design is economy. Files are large, the connections of mobile devices are slow and often charged by volume, and a service that transferred a whole file of 500 MB because 1 paragraph of it had changed would be unusable. Much of the engineering lies in transferring exactly what has changed and nothing else.
This chapter covers
- a file as a list of chunks, with the bytes in 1 place and the list in another
- transferring only the chunks that have changed
- why cutting a file at fixed positions fails when a byte is inserted
- cuts that depend on the content
- versions, and detecting that 2 devices have changed the same file
- what to do with a conflict
- how a device learns that something has changed
- how a device catches up after being offline
- deleting, renaming, and the order in which things are uploaded
A file as a list of chunks
The service does not store a file as a single object. It follows the practice of the object store in chapter 16: a file is cut into chunks of moderate size, each chunk is identified by a checksum of its contents, and the file itself is represented by the list of the checksums of its chunks, in order.
The representation divides the data into 2 parts of very different character. The chunks are large, immutable and without structure, and are kept in a chunk store, which is an object store. The lists, together with the names of the files, their versions and their positions in folders, are the metadata. They are small, frequently changed, and have to be strongly consistent, and they are kept in a replicated database.
Several properties that the service needs follow from this division with little further effort. Identical chunks have identical checksums and are stored once, whether they occur in 2 versions of a file, in 2 files, or in the folders of 2 different users. The previous versions of a file cost almost nothing to retain, since an old version is merely an old list, most of whose chunks are shared with the new one. And a transfer that is interrupted can be resumed, since the chunks that arrived are already in the store and need not be sent again.
Transferring only what changed
When a file is modified, the device cuts the new contents into chunks, computes their checksums, and compares the resulting list with the list of the version it previously held. The chunks whose checksums already appear are in the store and need not be sent. Only the chunks with new checksums are uploaded, followed by the new list.
A device that receives the change proceeds in the mirror image. It obtains the new list, compares it with the chunks it holds locally, downloads those it lacks, and assembles the new version of the file from old and new chunks together.
If a paragraph is altered in the middle of a document of 100 MB that has been cut into chunks of 4 MB, 1 chunk of the 25 has changed, and 4 MB are transferred in place of 100. The scheme has, however, a weakness that depends on how the file is cut, and it is serious enough to determine the design.
Where to cut
The obvious way of cutting a file is at fixed positions, after every 4 MB. This serves well when a file is modified by overwriting some of its bytes in place, and very badly when bytes are inserted or removed.
Suppose that a single byte is added at the very beginning of a file. Every byte that follows is now 1 position later than it was. The first chunk consists of the new byte and the first 4 MB less 1 byte of the old content, the second chunk begins 1 byte earlier in the old content than it used to, and so on to the end. Although the content is all but unchanged, every chunk contains different bytes from before and has a different checksum, and the entire file is uploaded again. Inserting text near the start of a document, which is among the commonest of all edits, defeats the scheme completely.
The remedy is to let the content itself determine where the cuts fall. The device passes a small window, of a few dozen bytes, along the file, computing at each position a hash of the bytes in the window. Wherever the hash satisfies some fixed condition, for instance that its lowest 22 bits are all 0, a cut is made. Since the condition is met on average once in every 4 million positions, the chunks have an average size of 4 MB, though they vary in length. This technique is called content-defined chunking.
Its value lies in the fact that a cut now depends only on the few bytes around it. When a byte is inserted at the start of the file, the positions of all later cuts shift by 1 along with the content, and each still falls between the same 2 pieces of content as before. The chunks after the first therefore contain exactly the bytes they contained previously and have the same checksums.
The simulation for this chapter makes the comparison on a file of 64 KB, with small chunks so that the effect is visible. After 1 byte is added at the start, cuts every 1024 bytes leave all 65 chunks to be uploaded again, and cuts that depend on the content leave 1 of 62.
The hash of the window has to be computed at every position in the file, and would be slow if it were calculated from nothing each time. A rolling hash is designed so that when the window advances by 1 byte, the new value is obtained from the old by removing the contribution of the byte that has left and adding that of the byte that has entered, in a constant number of operations. Limits are also set on the size of a chunk in both directions, so that an unlucky stretch of content yields neither a swarm of tiny chunks nor a single enormous one.
Versions and conflicts
Every file has a version, a number that the metadata store increases by 1 each time the file is changed. A device records, for each file, the version on which its local copy is based.
When a device uploads a change, it states that version: “here is a new list of chunks for this file, which I produced by modifying version 3”. The service compares the stated version with the current one. If the file is still at version 3, nobody else has changed it in the meantime, and the upload is accepted as version 4. If the file is by now at version 4 or beyond, some other device has changed it since this device last looked, and the upload is rejected as a conflict.
This is the conditional write of chapter 1, the If-Match header under another
name, and it is the foundation on which the correctness of the whole service rests.
Without it, the later of 2 uploads would silently replace the earlier, and one
person’s work would vanish with no indication that it had ever existed.
The comparison and the update have to be a single atomic operation in the metadata store. If 2 devices upload within the same instant, exactly 1 must succeed, and this is the principal reason why the metadata is held in a strongly consistent database.
What to do with a conflict
Detecting a conflict is a precise matter. Deciding what to do about it is a question of policy, on which reasonable designs differ.
The simplest policy is that the last writer prevails, the later upload replacing the earlier. It needs no intervention from the user and silently discards somebody’s work, and it is acceptable only for data whose loss nobody would mind.
The policy adopted by most services for ordinary files is to keep both. The version that arrived first remains under the original name, and the other is saved beside it under a name that marks it as a conflicting copy, with the device and the date. Nothing is lost, and the user, who alone knows what the 2 edits were meant to achieve, combines them by hand. It is a slightly awkward outcome and an honest one.
For certain kinds of content the 2 changes can be merged automatically. If 2 people have edited different paragraphs of a text, both edits can be applied. This requires the service to understand the structure of the content, and is the subject of chapter 26. A service that synchronises arbitrary files treats them as opaque bytes and cannot attempt it, since 2 edits to a spreadsheet or an image cannot be combined by anything that does not know what a spreadsheet or an image is.
Learning that something changed
A device has to find out that a file was changed elsewhere. It could ask the service at regular intervals, and this is the polling that chapter 18 examined and found wanting: frequent polling wastes requests and battery when nothing is happening, and infrequent polling delays every change.
The service therefore uses the remedy of chapter 18. Each device keeps a connection open to a notification service, and when a change is accepted, the service informs the other devices of the same account over their connections. The message carries no content. It says only that something has changed, and the device, on receiving it, asks the metadata service what.
That question is answered by means of a cursor, exactly as in chapter 18. The metadata store keeps for each account an ordered list of every change, each with a sequence number: file 7 changed to version 4, file 12 deleted, folder 3 renamed. A device remembers the number of the last change it has applied and asks for everything after that. The same request serves a device that was told of a change a second ago and one that has been switched off for a month, and the service keeps no record of which device has received what.
The design of chapter 18 thus reappears almost unaltered: a signal to attract attention, which is permitted to be lost, and a cursor from which the true state is recovered, which is not. It is among the most reusable patterns in this book.
Deleting, renaming and order
A few matters of detail cause a disproportionate share of the defects in services of this kind.
A deletion has to be synchronised like any other change, and it cannot be represented by the mere absence of a file. Were a device to see only that a file is missing from the list, it could not distinguish a file that was deleted elsewhere from a file that it has itself created a moment ago and has yet to upload. A deletion is therefore recorded as an explicit entry in the list of changes, sometimes called a tombstone, and the file’s chunks are retained for a time so that the deletion can be undone.
A rename should be recorded as a rename, an operation on the metadata alone. If it were treated as the deletion of one file and the creation of another, every device would download again a file whose bytes it already held. With chunks identified by their checksums, even that naive treatment would in fact transfer nothing, since the “new” file consists of chunks that are already present, which is a further dividend of content addressing.
The order of an upload matters for the same reason that it did in chapter 16. The chunks are uploaded first, and the new list is committed to the metadata store last. The list is what makes the new version exist for other devices, and at that moment every chunk it refers to must already be in the store. An upload interrupted halfway leaves a few chunks that nothing refers to, to be collected later as garbage, and never a version that other devices are told of and cannot assemble.
Finally, a file that is still being written should not be uploaded. An application saving a large file writes it over several seconds, and a device that began uploading at the first change would send a partial file, followed at once by another. Devices wait until a file has been unchanged for a short period before treating it as modified.
Use cases
A person changes files on 1 device, sees the changes on another, and occasionally has to settle a conflict.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A change made to a file on 1 device appears on the user’s other devices. |
| 1 | A device that was offline receives every change made in its absence when it reconnects. |
| 2 | Only the parts of a file that have changed are transferred. |
| 3 | When 2 devices change the same file independently, neither change is lost. |
| 4 | Earlier versions of a file can be restored for 30 days. |
| 5 | A deleted file can be restored for 30 days. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Durability | A file whose upload was confirmed is not lost. |
| 1 | Consistency | Strong for metadata: of 2 uploads based on the same version, exactly 1 is accepted. Eventual between devices. |
| 2 | Latency | A change reaches a connected device within 5 seconds. |
| 3 | Economy | The bytes transferred are proportional to the size of the change and not to the size of the file. |
| 4 | Scale | 100 million users, 10 billion files, 1 EB of content. |
Non-functional requirement 1 sets 2 consistency models side by side, and it is worth being clear about where each applies. Among the devices, the system is eventually consistent by necessity, since a device that is switched off cannot possibly have the latest version. The metadata store that arbitrates among them is strongly consistent, and it has to be, because the detection of conflicts depends on every upload being compared with the true current version. The service as a whole can tolerate devices that are far behind precisely because, at its centre, there is 1 place that is never in doubt about what the latest version is.
Core entities
Capacity estimate
Arithmetic ahead. 1 EB of content in chunks of 4 MB is 250 billion chunks, and the metadata for 10 billion files comes to about 5 TB.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Chunks | 1 EB ÷ 4 MB | 250 billion |
| Metadata per file | name, version, folder, and 25 checksums of 16 bytes on average | about 500 bytes |
| Metadata for all files | 10 billion × 500 bytes | 5 TB |
| Open connections | 100 million users × 2 devices × 20% online | 40 million |
| Changes per second | 100 million users × 20 changes a day ÷ 86400 | about 23000 |
The API
A device first learns which chunks the store lacks, uploads those, and then commits the new version, stating the version it started from.
Ask which chunks are missing
POST /v1/chunk-queries HTTP/1.1
Host: sync.example
Content-Type: application/json
{ "checksums": ["9f2c", "41aa", "c07e"] }
HTTP/1.1 200 OK
Content-Type: application/json
{ "missing": ["c07e"] }
The resource created is a query, which is why the request is a POST to a
collection of queries and the answer comes back at once. Only the chunk c07e has
to be uploaded.
Upload a chunk
PUT /v1/chunks/c07e HTTP/1.1
Host: sync.example
Content-Type: application/octet-stream
Content-Length: 4194304
(4194304 bytes)
HTTP/1.1 201 Created
The address of a chunk is its checksum, so that uploading the same chunk twice is
harmless and PUT is the proper method.
Commit a new version of a file
PUT /v1/files/7 HTTP/1.1
Host: sync.example
If-Match: "3"
Content-Type: application/json
{ "path": "notes.txt", "chunks": ["9f2c", "41aa", "c07e"] }
HTTP/1.1 200 OK
ETag: "4"
Content-Type: application/json
{ "id": 7, "version": 4 }
The same commit from a device that is behind
PUT /v1/files/7 HTTP/1.1
Host: sync.example
If-Match: "3"
Content-Type: application/json
{ "path": "notes.txt", "chunks": ["9f2c", "77b0", "c07e"] }
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "/problems/version-conflict",
"title": "The file has been changed by another device",
"status": 412,
"current_version": 4
}
Fetch the changes since a cursor
GET /v1/changes?after=5120 HTTP/1.1
Host: sync.example
HTTP/1.1 200 OK
Content-Type: application/json
{
"changes": [
{ "number": 5121, "file": 7, "kind": "modified", "version": 4, "chunks": ["9f2c", "41aa", "c07e"] },
{ "number": 5122, "file": 12, "kind": "deleted" }
],
"next_after": 5122
}
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Device | watches a folder, cuts files into chunks, uploads and downloads | the files, and a cursor | it catches up from its cursor when it returns |
| Chunk store | keeps chunks by checksum | every chunk, as in chapter 16 | as in chapter 16 |
| Sync service | compares versions and commits changes | none | another instance serves the request |
| Metadata store | holds files, versions, chunk lists and the list of changes | all metadata, replicated | a follower is elected |
| Notifier | holds a connection to every online device | connections, in memory | devices reconnect and fetch from their cursors |
One change, step by step
The steps follow the arrows of figure 25.5.
- The laptop notices that a file has changed and has been still for 2 seconds. It cuts the file into chunks, asks which of them the store lacks, and uploads those.
- It sends the new list of chunks to the sync service, together with the version on which its change was based.
- The sync service asks the metadata store to replace the list if the file is still at that version. The store does so, increases the version, and appends an entry to the account’s list of changes.
- The sync service tells the notifier that the account has a change.
- The notifier passes the signal to the phone over its open connection. The phone asks for the changes after its cursor, downloads the chunks it lacks from the chunk store, assembles the file, and moves its cursor forward.
The design, deployed
The deployment separates 3 kinds of traffic that have nothing in common. Small requests about metadata go through load balancers to the 100 instances of the sync service and from there to the metadata store, which is sharded by account with 3 replicas of each shard. Long-lived connections, 1 for every online device, are held by 400 notifiers, a tier of the same kind as the gateways of chapter 18. And the bytes of the files travel directly between the devices and the chunk store, without passing through either of the other tiers, which is what keeps them small.
How real systems do it
Dropbox has described the protocol of its service, and it matches the outline of this chapter closely. A file is divided into blocks of 4 MB, each named by a cryptographic hash of its contents, and the list of those hashes identifies the contents of the file. To upload, a client sends the list to a metadata server. If the server lacks some of the blocks, it says which, the client sends them to a separate block server, and the commit is then repeated and recorded in a journal of changes for the account, which is the ordered list of changes from which other clients read with a cursor.
In 1 respect the published design differs from the recommendation of this chapter, and the difference should be stated plainly. Dropbox’s blocks are of fixed size. Cutting at positions determined by the content, which this chapter presents as the remedy for insertions, is the method of the Low-Bandwidth File System, a research system of 2001, and it is used today mainly by backup programs, for which deduplication across many versions matters most. A fixed block size is simpler, and a service can limit the damage of an insertion by other means, notably by transferring within a changed block only the differences from its previous version. That technique comes from rsync, whose rolling checksum is the original of the rolling hash described above.
The cost of the rolling hash has itself been reduced. A method published in 2016 replaces the traditional fingerprint by a much cheaper one and adjusts the condition for a cut so as to avoid very small and very large chunks, and reports chunking several times faster with nearly the same deduplication.
For the storage of the blocks, Dropbox built its own system of the kind described in chapter 16, holding exabytes, and has described rewriting its client’s synchronisation engine after concluding that the hardest part of the problem is the client, which has to reconcile the server’s view with a local file system that other programs are changing all the time.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Uploading whole files | A small edit to a large file transfers all of it. | Cut files into chunks and upload only the new ones. |
| Cutting at fixed positions | An insertion near the start changes every chunk. | Let the content determine the cuts. |
| Accepting an upload without its base version | The later of 2 edits silently replaces the earlier. | Require the version the change was based on, and compare it atomically. |
| Resolving conflicts by letting the last writer prevail | Somebody’s work is discarded without notice. | Keep both versions, and let the user decide. |
| Committing the list before the chunks | Other devices are told of a version they cannot assemble. | Upload the chunks first and commit the list last. |
| Polling for changes | Battery and requests are wasted, or changes arrive late. | Signal over an open connection, and fetch from a cursor. |
| Trusting the signal to carry the change | A lost signal is a lost change. | Treat the signal as a hint, and recover from the cursor. |
| Representing a deletion by absence | A device cannot tell a deleted file from one it has just created. | Record deletions as explicit entries. |
| Treating a rename as delete and create | The history of the file is broken, and work may be repeated. | Record a rename as a change to metadata. |
| Uploading a file while it is being written | Partial files are synchronised, followed by more partial files. | Wait until the file has been still for a moment. |
| Deleting chunks as soon as a file is deleted | Chunks shared with other files or needed for restoring are lost. | Collect unreferenced chunks later, after a period of grace. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A device loses its connection during an upload | the file shows as still synchronising | the chunks already uploaded are kept, and the upload resumes with the rest |
| 2 devices change the same file | a second file marked as a conflicting copy | both versions are kept |
| The signal of a change is lost | the change appears a little later | the device also fetches from its cursor at intervals and on reconnecting |
| A device is offline for a month | nothing, until it reconnects | it fetches every change after its cursor |
| The reply to a commit is lost | the device tries again | the retry finds the file already at the new version with the same chunks, and treats it as success |
| The metadata store’s leader fails | commits fail for about 1 second | a follower is elected |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Chunks identified by checksum | small transfers, shared storage, cheap old versions | metadata for every chunk, and garbage to collect |
| Cuts that depend on the content | insertions change 1 chunk | chunks of uneven size, and a hash at every byte |
| Strong consistency for metadata | conflicts are always detected | commits wait for a majority |
| Keeping both versions in a conflict | nothing is lost | the user has to combine them |
| A signal and a cursor | prompt delivery that survives lost signals | a connection for every online device |
The simulation
Simulation in the repository. The program code/syncsim simulates this design. To run it, clone the repository and type cargo run --release -p syncsim in the code directory.
References
- N. Koorapati, “Streaming File Synchronization”, Dropbox tech blog, 2014.
- A. Muthitacharoen, B. Chen and D. Mazieres, “A Low-bandwidth Network File System”, SOSP 2001.
- A. Tridgell and P. Mackerras, “The rsync algorithm”, Australian National University technical report, 1996.
- W. Xia and others, “FastCDC: A Fast and Efficient Content-Defined Chunking Approach for Data Deduplication”, USENIX ATC 2016.
- Dropbox tech blog, “Rewriting the heart of our sync engine”, 2020.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Synchronisation | bringing copies of the same data on several devices into agreement |
| Chunk | a piece of a file, identified by a checksum of its contents |
| Metadata | the names, versions, folders and chunk lists of files |
| Content-defined chunking | cutting a file where its content satisfies a condition |
| Rolling hash | a hash of a moving window that is updated in constant time as the window advances |
| Version | a number that increases with every change to a file |
| Conflict | 2 changes to the same file, each made without knowledge of the other |
| Conflicting copy | the second of 2 conflicting versions, kept under another name |
| Cursor | the number of the last change a device has applied |
| Tombstone | an explicit record that something was deleted |
Summary
- A file is a list of chunks identified by checksum, with the chunks in an object store and the lists in a strongly consistent metadata store.
- A device uploads only the chunks that the store does not already hold.
- Cuts at fixed positions make an insertion change every chunk, and cuts that depend on the content confine the change to 1.
- Every upload states the version it was based on, and an upload based on an old version is a conflict.
- In a conflict both versions are kept, since the service cannot know which edit the user would prefer.
- Devices are signalled over an open connection and fetch changes from a cursor, so that a lost signal delays a change and loses nothing.
- Chunks are uploaded before the list that refers to them, and deletions are recorded explicitly.
Collaborative Editing: System Design
In a collaborative editor, several people work on the same document at once. Each sees the others’ changes appear as they are made, character by character, with a coloured cursor marking where each person is typing, and nobody has to save, send or merge anything. The experience is that of several pens writing on a single sheet of paper.
The previous chapter dealt with 2 devices that had changed the same file, and resolved the matter by keeping both versions and asking the user to choose. In an editor that answer is not available. Conflicting changes are here the ordinary condition, by no means an occasional accident: whenever 2 people type within the same fraction of a second, each has changed the document without knowledge of the other’s change, and this happens many times a minute. The editor cannot present a conflict for a human to settle on every keystroke. It has to combine concurrent changes automatically, in a way that keeps every participant’s intention, and it has to do so in such a manner that everybody ends with exactly the same document.
That last requirement, known as convergence, is the difficult one. Each person’s own keystrokes must appear on their own screen at once, since nobody will tolerate typing into an editor that waits for a server before showing a letter. Every participant therefore applies their own changes first and the others’ afterwards, which means that different participants apply the same changes in different orders, and the result must nevertheless be identical for all of them. This chapter explains why the obvious representation of a change fails that test, and then the 2 families of solution that are in use.
This chapter covers
- why each person’s changes must take effect at once on their own screen
- changes described by position, and how 2 of them go wrong
- operational transformation, which adjusts a change for what its author had not seen
- the role of a central server in ordering changes
- conflict-free replicated data types, which give every character a name
- deleting, and why deleted characters cannot disappear
- cursors, presence and working offline
- storing a document as a log of operations with snapshots
Changes that take effect at once
The simplest way of building a shared editor would be to treat the document as a value in a strongly consistent store. A keystroke would be sent to the server, the server would apply it, and the new document would be returned to everybody. All participants would see the same thing, because there would be only 1 copy.
It would also be unusable. Every character would appear on the typist’s own screen only after a round trip to the server, some 50 to 200 ms later, and on a poor connection later still. People notice a delay of that size in their own typing within a few characters, and find it intolerable.
An editor therefore applies each change to the local copy of the document immediately, and sends it to the others afterwards. This is the same decision that chapter 18 took when it displayed a sent message before the server had confirmed it, and it has a far-reaching consequence. Each participant now holds a copy of the document that is momentarily ahead of everyone else’s in respect of their own changes, and behind in respect of the others’. The copies are replicas in the full sense, every one of them accepts writes, and the system is one of replication with many writers and no possibility of coordination before a write.
2 changes by position
A change to a text is most naturally described as an operation on a position: insert this character at position 5, or delete the character at position 9. This is how a single-user editor works internally, and it is entirely adequate so long as every operation is applied to the same text that its author was looking at.
With 2 people editing concurrently, that condition fails, and the consequences can be followed in a small example.
Ada inserted an h at position 1, and Bob an s at position 3, which at the time
was the end of the word. When Bob’s operation reaches Ada, her text has already
changed: her own h has moved every later character 1 place to the right, and
position 3 no longer means what it meant to Bob. The operation is applied faithfully
and lands in the wrong place. Ada’s operation, arriving at Bob’s copy, is unaffected,
since nothing before position 1 has moved. The 2 copies now read chast and
chats.
The simulation for this chapter runs exactly this case and prints the 2 different results. The copies have diverged, and the fault will never correct itself, since each later operation is applied to 2 different texts and can only compound the difference.
The root of the trouble is that a position is not a stable way of referring to a place in a text. It is meaningful only relative to a particular state of the document, and an operation that travels to another replica arrives at a different state. The 2 families of solution correspond to the 2 possible remedies. One may keep positions and correct them on arrival, or one may abandon positions for something that does not go out of date.
Adjusting a change for what its author had not seen
The first remedy is called operational transformation, usually abbreviated OT. Before an operation that was made concurrently with others is applied, it is transformed so as to take account of them.
In the example, Bob’s insertion at position 3 was made without knowledge of Ada’s
insertion at position 1. Ada’s character was placed before position 3 and so
displaced it by 1. The transformed operation is therefore an insertion at position
4, and applied to Ada’s text it yields chats. Ada’s operation is transformed
against Bob’s in turn. Bob’s character was placed after position 1 and displaced
nothing before it, so Ada’s operation is unchanged, and Bob’s text becomes chats as
well. The last step of the picture above shows the outcome, and the simulation
confirms it.
For insertions the rule is short. An insertion is moved 1 place to the right for every concurrent insertion that was made at an earlier position. A further rule is needed for the case in which 2 people insert at the very same position, where some fixed convention, such as giving precedence to the participant with the smaller identifier, must decide which character comes first, and must decide it identically on every replica.
Deletions add further cases. An insertion after a concurrently deleted character moves 1 place to the left. 2 participants who both delete the same character must between them delete it once. An insertion in the middle of a range that somebody else is deleting must survive. For each pair of kinds of operation there is a transformation, and the difficulty of OT lies in the number of these cases and in the requirement that they agree with one another: whichever order a replica happens to receive 2 concurrent operations in, transforming and applying them must lead to the same text. Published algorithms have been found, years afterwards, to violate this requirement in obscure combinations.
What a central server contributes
The difficulty is greatly reduced by a decision about architecture. If participants exchanged operations directly with one another, each might see the operations in a different order, and the transformations would have to be correct for every possible order among any number of participants. Nearly all practical systems avoid this by routing every operation through a central server.
The server gives the document a single, agreed history. It maintains a revision number, which counts the operations applied so far. A participant sends each operation together with the revision of the document on which it was based. The server compares that revision with its current one. If others have been applied since, the server transforms the incoming operation against each of them in order, applies the result, assigns it the next revision number, and sends the transformed operation to all the other participants. Each participant, meanwhile, transforms the operations it receives from the server against any of its own that the server has not yet acknowledged.
With this arrangement, every transformation involves only 2 sequences: what the server has applied, and what 1 client has pending. The server is the single place at which order is decided, exactly as the leader of a shard was in chapter 3 and the message store was in chapter 18, and the general lesson holds here too, that a problem of ordering among many parties becomes tractable when 1 of them is appointed to decide the order.
The price is the usual one. The server must be reachable for collaboration to proceed. A participant who is offline can continue to type, and the operations accumulate locally, but they are merged only on reconnection, by transformation against everything that has happened in the meantime.
A name for every character
The second remedy removes the cause of the trouble. If positions go out of date, characters can be referred to by something that does not.
In this approach every character is given, at the moment it is typed, a unique identifier that it keeps for ever. The identifiers are chosen so that they can be ordered, and the text is defined as the characters arranged in the order of their identifiers. An insertion no longer says “at position 3”. It says “a new character with this identifier”, and the identifier is chosen to fall between those of the 2 characters where the author placed it.
An operation of this form has a remarkable property. It refers to nothing that another operation can alter, and applying it consists merely in adding 1 element to a sorted collection. 2 such operations can be applied in either order with the same result, and an operation applied twice has the same effect as once. No transformation is required, and no server is needed to decide an order, since order is irrelevant.
A data structure whose operations can be applied in any order, any number of times,
with every replica arriving at the same state, is called a conflict-free replicated
data type, or CRDT. In the simulation, the same 2 insertions are applied in both
orders to a text represented in this way, and both replicas read chats. When both
participants insert at the same place, the identifiers are distinguished by the
identity of the device, and both replicas read carnt, with the characters in the
same order on each.
The identifiers have to satisfy 2 conditions. They must be unique, which is arranged by including the identity of the device in each. And it must always be possible to create a new identifier between any 2 existing ones, however close together. Whole numbers do not suffice, since nothing lies between 1500 and 1501. Practical schemes use sequences of numbers of variable length, compared as the entries of a dictionary are, so that between any 2 a longer one can be found, or else define each character’s place by reference to the identifier of the character it was typed after.
The cost of a name
The simplicity of the operations is bought with metadata. A plain text of 10000 characters occupies 10000 bytes. The same text as a CRDT carries an identifier for every character, which may be 10 or 20 times the size of the character itself, and identifiers of the dictionary kind grow longer wherever a great deal of text has been inserted in the same place. Much of the engineering in CRDT libraries is devoted to compressing this, for instance by storing a run of characters typed in succession by 1 person as a single unit with 1 identifier and a length.
Automatic merging has a further limit, which applies to both families and which no algorithm can remove. Convergence guarantees that all replicas end with the same text. It does not guarantee that the text is what anybody meant. If 2 people correct the same misspelt word simultaneously, each by deleting it and typing it again, the merged document contains the corrected word twice. The result is consistent and wrong, and what collaborative editors rely upon in practice is that people can see each other’s cursors, and tend not to edit the same word at the same moment.
Deleting
Deletion presents a difficulty of its own in a CRDT. Suppose that Ada deletes a character at the same moment that Bob inserts a new one immediately after it, his operation saying in effect “after the character with identifier 2000”. If Ada’s replica had removed that character altogether, Bob’s operation would arrive referring to something that no longer exists there.
A deleted character is therefore not removed. It is marked as deleted and hidden from view, and its identifier remains in the structure to serve as a point of reference. Such a marker is the tombstone of chapter 25. The consequence is that a document which has been heavily edited contains far more tombstones than visible characters, and they can be discarded only when it is certain that no replica anywhere still holds an operation that refers to them, which, with participants who may be offline for weeks, is difficult to establish.
The 2 approaches compared
| Operational transformation | CRDT | |
|---|---|---|
| How an operation refers to a place | by position | by the identifier of a character |
| What makes replicas converge | transforming operations against concurrent ones | operations that give the same result in any order |
| Central server | required, to fix the order | not required |
| Working offline, or peer to peer | awkward: everything is transformed on return | natural: operations are merged whenever they meet |
| Size of the document | close to the plain text | several times the plain text, with tombstones |
| Where the complexity lies | in the transformation rules | in the identifiers and their compaction |
Services in which a server is always in the path, and in which documents are large and long-lived, have mostly used operational transformation. Applications that must work offline for long periods, or without any central server, have mostly used CRDTs. The design in the remainder of this chapter uses a central server with operational transformation.
Around the text
A collaborative editor conveys more than the text. Each participant sees where the others’ cursors and selections are, and who is present in the document. This information is ephemeral: it is forwarded to the other participants as it changes and is stored nowhere, since a cursor position that is lost is replaced by the next one a moment later. It is the presence of chapter 18, with the same loose guarantees.
Cursors are nonetheless subject to the central problem of this chapter. A cursor is a position in the text, and when another participant inserts characters before it, it has to move. A cursor is therefore transformed against incoming operations in the way that an operation would be, or, in a CRDT, is attached to the identifier of a character, which does not move.
Undo also needs rethinking. In an editor for 1 person, undo reverses the most recent change. In a shared document the most recent change may be somebody else’s, and to reverse it would be bewildering. Undo is accordingly made to reverse the most recent change of the person who invokes it, which requires the inverse of an operation that is no longer the latest and has to be transformed against everything that came after.
Storing the document
The server retains every operation, in order, in a log, each with its revision number. The document at any revision is the result of applying the operations up to that point, so that the log is the authoritative record and the current text is derived from it. This is the relation between records and entries from chapter 1, and it yields the complete history of the document, by which an author can see what it contained at any earlier moment and who changed what.
To replay the whole log each time a document is opened would grow slower with every keystroke ever made. The server therefore saves a snapshot of the text at intervals, every few hundred operations, and a document is opened by loading the latest snapshot and applying the operations that follow it. The operations before a snapshot are kept as long as history is wanted and are not needed for opening.
Each document is served, at any moment, by a single server process, which holds its current text in memory and decides the order of its operations. Documents are assigned to servers by hashing their identifiers, and every participant in a document is connected, through a gateway of the kind described in chapter 18, to that document’s server. The operation is appended to the log before it is acknowledged and broadcast, so that if the process fails, another can take over the document from the log without losing anything that was confirmed.
Use cases
An author types in a shared document, sees what the others type, and may continue while offline.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | Several authors can edit the same document at the same moment. |
| 1 | An author’s own change appears on their screen immediately. |
| 2 | A change by another author appears within 200 ms. |
| 3 | When all changes have been delivered, every author sees the same document. |
| 4 | An author sees the cursors of the other authors. |
| 5 | An author who is offline can continue to edit, and the changes are merged on reconnection. |
| 6 | The history of a document can be viewed, and an earlier state restored. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Convergence | All replicas reach the same state once every operation has been delivered, in whatever order they were applied locally. |
| 1 | Durability | An operation that the server has acknowledged is not lost. |
| 2 | Consistency | Eventual between authors. Strong for the order of operations in the server’s log. |
| 3 | Latency | A local change takes effect in under 16 ms, and a remote one within 200 ms. |
| 4 | Scale | 10 million documents open at once, with up to 100 authors in a document. |
Non-functional requirement 2 has the form that has now appeared in several chapters. The replicas that people look at are eventually consistent, each running ahead with its own changes. At the centre is a log whose order is strongly consistent and never in dispute, and the eventual consistency of the edges is made safe by the certainty of the centre. Convergence, non-functional requirement 0, is the precise statement of what “eventual” promises here.
Core entities
Capacity estimate
Arithmetic ahead. 10 million open documents with 2 authors typing in each produce about 10 million operations per second at the peak, and far fewer in practice, since people pause.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Authors typing at any moment | 10 million documents × 2 authors × 10% actually typing | 2 million |
| Operations per second | 2 million × 5 keystrokes per second | 10 million |
| Size of an operation in the log | document, author, revision, position, character | about 30 bytes |
| Log growth | 10 million × 30 bytes | 300 MB per second at the peak |
| Memory for open documents | 10 million × 50 KB | 500 GB, across the session servers |
In practice operations are sent in small batches, several keystrokes together, which reduces the number of messages by a large factor without any perceptible delay.
The API
A document is opened with an ordinary request, which returns the text and its revision. From then on, operations travel in both directions over an open connection.
Open a document
GET /v1/documents/8812 HTTP/1.1
Host: docs.example
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 8812, "revision": 4410, "text": "cat" }
An operation sent by a client, over the open connection
{ "document": 8812, "client_id": "ada-17", "based_on": 4410, "insert": { "at": 1, "text": "h" } }
based_on is the revision that the author was looking at. client_id identifies
the operation, so that if it is sent a second time after a lost acknowledgement, the
server recognises it.
The server’s acknowledgement to the sender
{ "document": 8812, "client_id": "ada-17", "revision": 4412 }
The same operation as broadcast to the other authors
{ "document": 8812, "author": "ada", "revision": 4412, "insert": { "at": 1, "text": "h" } }
The operation broadcast is the transformed one, and its revision is the position it was given in the log. Revision 4411 belonged to an operation by another author that reached the server first.
Read the history of a document
GET /v1/documents/8812/operations?after=4400&limit=100 HTTP/1.1
Host: docs.example
HTTP/1.1 200 OK
Content-Type: application/json
{
"operations": [
{ "revision": 4411, "author": "bob", "insert": { "at": 3, "text": "s" } },
{ "revision": 4412, "author": "ada", "insert": { "at": 1, "text": "h" } }
],
"next_after": 4412
}
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Client | applies the author’s changes at once, and transforms incoming ones | its copy of the document, and operations not yet acknowledged | it reopens the document and sends its pending operations again |
| Session server | orders the operations of a document, transforms them, and broadcasts | the current text of each of its documents, in memory | another server loads the document from its snapshot and log |
| Operation log | every operation of every document, in order | the log, replicated | a follower is elected |
| Snapshots | the text of a document at intervals | snapshots, in an object store | as in chapter 16 |
One operation, step by step
The steps follow the arrows of figure 26.5.
- Author A types a character. The client applies it to its own copy at once, and sends the operation to the session server with the revision on which it was based.
- The session server transforms the operation against every operation applied since that revision, appends the result to the log under the next revision number, and acknowledges it to A.
- The server sends the transformed operation to author B, whose client transforms it against any operations of its own that the server has not yet acknowledged, and applies it.
- Every few hundred operations, the current text is saved as a snapshot.
The design, deployed
Authors hold connections to gateways, as in the chat service. What is particular to this system is the tier below. Each open document is owned by exactly 1 of the 300 session servers, which holds its text in memory and decides the order of its operations, and a small directory records which server owns which document, so that the gateways can route every author of a document to the same place. The session servers are therefore not interchangeable in the way that stateless servers are. If 1 fails, each of its documents is taken over by another server, which loads the latest snapshot and replays the log from there.
How real systems do it
Operational transformation was introduced in 1989, in a paper on a group editor that exchanged operations directly among its participants. The arrangement with a central server, in which each client synchronises with the server alone and the server fixes the order, is that of the Jupiter system of 1995, and it is the form that Google adopted for its collaborative documents. Its attraction is the one given in this chapter: with a server in the middle, the transformation functions have to satisfy only the simpler of the 2 correctness conditions that the general case requires. The warning that published algorithms have been found faulty refers to the other condition, needed when there is no server, for which several algorithms were shown by formal analysis to diverge in particular cases, years after they were published.
Conflict-free replicated data types were given their name and a systematic theory in 2011. For text, the scheme of this chapter, in which each character has a position drawn from a dense ordered set, is that of a family of early designs. It has a defect that should be mentioned, since the simulation does not show it. When 2 people type whole words at the same place at the same time, their characters can become interleaved letter by letter, because the positions that each chooses fall among the other’s. Designs in which a new character names the character it was typed after, and ties are broken consistently, keep each person’s run together, and the libraries in common use today work in that way.
Not every collaborative product uses either method in its pure form. The makers of Figma have described a design in which a central server orders changes, as with operational transformation, while each property of each object is a simple register in which the last write prevails, as in a basic CRDT. Their argument is that a design tool’s documents are trees of objects with properties, for which that is enough, and that the full generality of either method serves the editing of long texts and is not needed elsewhere.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Waiting for the server before showing a keystroke | Typing feels delayed and becomes unusable on a slow connection. | Apply every local change at once, and reconcile afterwards. |
| Applying operations by position as they arrive | Replicas diverge, and never recover. | Transform them, or use identifiers in place of positions. |
| Exchanging operations between clients directly | Every client sees a different order, and the transformations must be right for all of them. | Route every operation through a server that fixes the order. |
| No fixed rule for 2 insertions at the same place | Replicas order the 2 characters differently. | Break the tie by a rule that every replica applies alike. |
| Removing deleted characters from a CRDT | A concurrent operation refers to a character that is gone. | Keep a tombstone. |
| Acknowledging an operation before logging it | A crash loses an edit that the author believes is saved. | Append to the log first. |
| Replaying the whole log to open a document | Opening grows slower with every edit ever made. | Save snapshots, and replay from the latest. |
| Storing cursors durably | A flood of writes for information that is worthless a second later. | Forward cursors, and store nothing. |
| Undo that reverses the latest operation | An author undoes somebody else’s work. | Undo the author’s own latest operation, transformed against what followed. |
| Assuming that convergence means correctness | 2 simultaneous corrections of 1 word leave it doubled. | Show cursors, so that people avoid editing the same place. |
Failures
| Failure | What authors see | How the system recovers |
|---|---|---|
| An author’s connection drops | their own typing continues, and others’ changes stop arriving | on reconnection, pending operations are sent and missed ones fetched from the log |
| The acknowledgement of an operation is lost | nothing | the client sends it again, and the server recognises its identifier |
| A session server fails | a pause of a second or 2 | another server loads the snapshot and the log, and the clients reconnect to it |
| An author is offline for a day | their copy and the shared one differ | every local operation is transformed against the day’s operations and merged |
| 2 authors edit the same word | a result that neither intended, the same on both screens | the authors see it and correct it |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Local changes applied at once | typing with no delay | replicas that differ for a moment, and the whole problem of merging |
| A central server | a single order, and far simpler transformation | no collaboration without the server |
| Operational transformation | documents close to the size of their text | intricate rules that are hard to get right |
| A log with snapshots | full history, and fast opening | storage that grows with every keystroke |
| Automatic merging | no conflict is ever put to the author | results that are consistent and sometimes unintended |
The simulation
Simulation in the repository. The program code/editsim simulates this design. To run it, clone the repository and type cargo run --release -p editsim in the code directory.
References
- C. Ellis and S. Gibbs, “Concurrency Control in Groupware Systems”, SIGMOD 1989.
- D. Nichols and others, “High-Latency, Low-Bandwidth Windowing in the Jupiter Collaboration System”, UIST 1995.
- M. Shapiro, N. Preguica, C. Baquero and M. Zawirski, “Conflict-free Replicated Data Types”, SSS 2011.
- M. Kleppmann and others, “Interleaving Anomalies in Collaborative Text Editors”, PaPoC 2019.
- E. Wallace, “How Figma’s multiplayer technology works”, Figma blog, 2019.
- M. Kleppmann and others, “Local-First Software: You Own Your Data, in Spite of the Cloud”, Onward! 2019.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Operation | a description of 1 change to a document |
| Concurrent | made without knowledge of each other |
| Convergence | all replicas reaching the same state once every operation has been delivered |
| Divergence | replicas that hold different states and will not come together |
| Operational transformation, OT | adjusting an operation to take account of concurrent ones |
| Revision | the number of operations that the server has applied to a document |
| Conflict-free replicated data type, CRDT | a data structure whose operations give the same result in any order |
| Tombstone | a deleted element that is kept, hidden, as a point of reference |
| Ephemeral | forwarded and not stored |
| Snapshot | the state of a document at 1 revision, saved so that earlier operations need not be replayed |
Summary
- Each author’s changes are applied to their own copy at once, so that every copy is a replica that accepts writes without coordination.
- An operation described by position is wrong on a replica where earlier concurrent changes have moved the text, and the replicas diverge.
- Operational transformation adjusts each operation for the concurrent ones its author had not seen.
- A central server fixes a single order of operations, which reduces transformation to a manageable problem.
- A CRDT gives every character an unchanging identifier, so that operations can be applied in any order and no server is needed.
- Deleted characters are kept as tombstones, since concurrent operations may refer to them.
- Convergence makes all copies equal, and does not make the result what anybody intended.
- The document is a log of operations with periodic snapshots, served for each document by a single process.
Stock Exchange: System Design
A stock exchange is a place where people who wish to buy a share and people who wish to sell it are brought together. Each states the price at which they are willing to trade, and whenever a buyer’s price meets a seller’s, a trade is made. The program at the centre of a modern exchange, which keeps the outstanding orders and decides who trades with whom, is the matching engine.
The requirements laid on this program are unlike those of any other system in this book, and they lead to a design that at first sight contradicts much of what the book has recommended. An exchange must be fair, in a precise and legally enforceable sense: of 2 orders at the same price, the one that arrived first must trade first, with no exception and no ambiguity about which was first. It must be fast, answering an order in well under a millisecond, since the participants are themselves programs that compete on speed. And it must be deterministic, so that its every decision can be reconstructed afterwards and shown to a regulator.
The preceding chapters have met demands for throughput by dividing work among many machines and many threads. A matching engine does the opposite. The trading of each share is handled by a single thread on a single machine, with all its data in memory and no database in its path. This chapter explains why that is the right design for this problem, and how such a system, which appears to have a single point of failure at its heart, is nevertheless made to survive the loss of any machine.
This chapter covers
- orders, the order book, and the rule of price and time
- matching an incoming order against the book
- why fairness requires a single agreed order of events
- why the engine is 1 thread, and why that is fast
- the journal, and recovering by replaying it
- a standby that is ready to take over
- the gateway, and the checks that precede matching
- publishing prices to everybody at the same moment
- what happens after a trade
Orders and the book
A participant expresses a wish to trade by submitting an order. The commonest kind, the limit order, names a side, a quantity and a price: buy 100 shares at 10.05 or less, or sell 50 shares at 10.10 or more. The price is a limit beyond which the participant will not go, and a better price is always acceptable.
An order that cannot be satisfied at once waits. The collection of waiting orders for 1 share is its order book, and it has 2 halves. The buy side holds the orders to buy, of which the most attractive to a seller is the one with the highest price. The sell side holds the orders to sell, of which the most attractive to a buyer is the one with the lowest. The highest price that any buyer is offering is the best bid, the lowest that any seller is asking is the best ask, and the difference between them is the spread. So long as the best bid is below the best ask, nobody is willing to trade with anybody, and the book is at rest.
Within each half the orders are ranked by a rule called price-time priority. An order at a better price ranks ahead of an order at a worse one. Among orders at the same price, the one that arrived earlier ranks ahead. The rule rewards a participant for offering a better price, and, at a given price, for having committed to it sooner, and it is the definition of fairness on which the exchange rests.
Prices are held as whole numbers of the smallest unit in which the share is quoted, for the reasons set out in chapter 19. 10.05 is the integer 1005.
Matching
When a new order arrives, the engine compares it with the best order on the opposite side of the book. If their prices meet, which for an incoming buy means that its limit is at or above the best ask, a trade is made between them, for the smaller of their 2 quantities. The trade takes place at the price of the order that was waiting in the book, and this is why an incoming order may obtain a better price than its limit. If the incoming order has quantity remaining, the engine turns to the next best waiting order and repeats. When the prices no longer meet, or the opposite side is exhausted, whatever remains of the incoming order is placed in the book to wait.
The simulation for this chapter processes these 7 orders. The first 4 rest in the book, since the cheapest seller asks 1005 and the only buyer offers 1000. Order 5, to buy 50 at 1005, trades with the 2 orders waiting at that price in the order of their arrival, taking 30 from order 2 and 20 from order 3. Order 6 takes the remainder of order 3 and all of order 1, and its unfilled 30 shares become the best bid. Order 7, a sale at 995 or better, trades at 1010 and at 1000, the prices of the buyers who were waiting. 200 shares change hands, and 10 remain in the book.
Several other kinds of order are built from the same mechanism. A market order names no price and trades at once with whatever is best on the other side, until it is filled. A cancellation removes a waiting order from the book. An order may be marked to trade immediately as far as it can and to cancel the rest, without ever resting in the book. None of these changes the essential structure.
The book has to support 3 operations efficiently: finding the best price on a side, adding an order at a price, and removing an order from anywhere within it, since most orders on a real exchange are cancelled before they trade. A usual arrangement keeps, for each side, a map from price to the queue of orders at that price, ordered by price, together with an index from order identifier to the order’s place in its queue, so that a cancellation finds its target directly.
One agreed order of events
The rule of price and time presupposes that, of any 2 orders, it is known which arrived first. On a single machine with a single connection this is trivial. On a real exchange, orders arrive over thousands of connections at several gateway machines, within microseconds of one another, and the question of which was first is by no means settled by the fact of their arrival.
The clocks of different machines cannot settle it, as chapter 8 explained at length. 2 gateways that each stamp their orders with the time will disagree by more than the interval that separates competing orders, and whichever gateway’s clock runs slow will systematically favour its own participants.
The exchange therefore establishes the order of events by decree, at a single point. Every order, cancellation and other input passes through a component called the sequencer, which assigns to each the next number in a single sequence. That number, and nothing else, defines the order in which events are deemed to have occurred. An order is “first” if the sequencer numbered it first.
This is the fourth appearance in this book of the same idea. The leader of a shard assigned sequence numbers to records in chapter 3, the message store numbered the messages of a conversation in chapter 18, and the session server numbered the operations on a document in chapter 26. In each case a question about order among many parties, which could not be answered by comparing clocks, was resolved by appointing a single party to decide.
Why the engine is a single thread
Given a single sequence of inputs, the matching of a share is an inherently sequential computation. Whether order 6 trades, and at what price, depends on exactly what the book contained after order 5, which depends on order 4, and so on to the beginning. 2 orders for the same share cannot be matched in parallel, since the result for the second depends on the first.
One might still be tempted to use several threads, protecting the book with locks. This would be both slower and worse. It would be slower because the work for each order is very small, a few lookups and updates in memory, and the cost of taking and releasing locks and of moving data between processor cores would exceed the cost of the work itself. It would be worse because the order in which threads acquire a lock is decided by the operating system’s scheduler and varies from run to run, so that the same inputs could produce different trades on different occasions.
The engine is therefore a single thread that takes inputs from the sequence one after another and processes each to completion before looking at the next. It holds the entire book in memory, waits for no disk and no network during matching, and takes no locks, since nothing else touches its data. An engine of this kind can process several hundred thousand orders a second on 1 core, with a response time of a few microseconds, which is far more than a design with locks and threads achieves. The general lesson, which applies well beyond exchanges, is that for work which is small, sequential by nature and entirely in memory, a single thread is the fastest arrangement, and no limitation that calls for apology. Chapter 6 reached a related conclusion when it gave the store to a single committer thread.
Capacity beyond 1 core is obtained by partitioning. Each share has its own book, and the books of different shares are independent of one another. Shares are divided among engines, each engine a single thread serving some hundreds of shares, and a share that is exceptionally busy can be given an engine to itself. This is sharding by share, with the order book as the unit that must not be divided.
The journal and the replay
An engine that keeps everything in memory loses everything when its machine fails, and an exchange cannot lose its order book. The apparent conflict is resolved by a property that the design has been careful to preserve.
The engine is deterministic. Its output depends on its sequence of inputs and on nothing else. It consults no clock, generates no random numbers, and is not affected by the timing of threads, there being only 1. From this it follows that the state of the engine can always be recomputed by starting from an empty book and applying the same inputs again in the same order.
It is therefore sufficient to make the inputs durable. The sequencer writes every input to a journal on disk, in sequence, before the engine processes it. This is the write-ahead log of chapter 2, and it is kept on several machines by the replication of chapter 3, so that it survives the loss of any 1 of them.
If the engine’s machine fails, a replacement reads the journal from the beginning and arrives at exactly the book that the failed engine held, having made exactly the same trades. The state was never the thing that needed protecting. The inputs were, and the state follows from them.
To replay a whole day’s journal would take too long to be acceptable, and in practice a standby engine runs continuously on another machine, reading the same journal and applying the same inputs a moment behind the primary. It holds an identical book at all times and suppresses its outputs. When the primary fails, the standby is promoted and begins to publish, and the changeover takes milliseconds. Since the 2 engines processed the same inputs deterministically, there is no question of their disagreeing about the state of the book.
The same property serves purposes other than recovery. A regulator who asks why a particular order did not trade can be answered by replaying the journal to that moment and examining the book. A new version of the engine can be tested by feeding it yesterday’s journal and comparing its trades with those that were actually made. And a defect that occurred in production can be reproduced exactly, as often as required, on a developer’s machine.
Determinism has to be defended deliberately, since it is easily lost. An engine that reads the time from the system clock in order to stamp a trade has made its output depend on something outside the journal, and its replay will produce different stamps. The time must therefore itself be an input, assigned by the sequencer and recorded in the journal. Iterating over a hash table whose order varies between runs, or performing arithmetic in floating point that differs between processors, would break determinism in the same way.
The gateway
Participants do not communicate with the engine directly. They connect to gateways, which authenticate them, check the syntax of what they send, and apply the checks that have to be made before an order is allowed to reach the book.
The most important of these are the checks of risk. A participant must have the funds to pay for what they buy and the shares to deliver what they sell, and must be within the limits their broker has set. An order of obviously mistaken size or price, such as a sale at a hundredth of the current price, is rejected, since such errors have on more than one occasion caused very large losses within seconds. And each participant is limited in the rate at which they may send orders, by the rate limiter of chapter 7.
It matters where these checks are made. They are performed before the sequencer, so that an order which fails them never enters the journal and never affects the book. Checks that depend on the state of the book itself, on the other hand, must be made by the engine, after sequencing, since only there is the state well defined.
Since the gateways lie before the sequencer, there is a subtle demand of fairness on them as well. If 1 gateway were consistently faster than another, the participants connected to it would have their orders sequenced earlier. Exchanges go to considerable lengths to equalise the path from every participant to the sequencer, to the extent of giving all participants network cables of the same length.
Publishing prices
Every change to the book is of interest to all participants: each trade, and each change in the best bid and ask. The engine publishes a stream of these events, the market data, and participants decide what to do next on the basis of it.
The demand of fairness applies here with particular force. A participant who learned of a trade a millisecond before the others could act on it first, to their profit and at the others’ expense. Market data is therefore published to all participants simultaneously, by a mechanism designed to deliver the same message to every recipient at as nearly the same instant as can be arranged.
The engine does not wait for the market data to be delivered, or even to be sent. It hands each event to a separate publishing component and proceeds to the next input. Every event carries its sequence number, and a participant who notices a gap in the numbers requests the missing events again, which is the recovery by sequence number that chapter 18 used for messages.
After the trade
A trade made by the engine is an agreement, and the exchange of shares for money that it calls for happens later. The record of each trade passes to a clearing system, which works out what each participant owes and is owed, and thence to settlement, in which shares and money actually change hands, typically 1 or 2 days afterwards.
These later stages have requirements of the kind that chapter 19 described. They must be exact, every movement must be recorded twice in a ledger, and nothing may be lost, while speed is of little account. They are built on ordinary strongly consistent databases, and they receive the trades from the engine through a durable queue, so that the engine is never made to wait for them. The architecture of an exchange is thus divided at the trade: before it, a path built for speed and determinism, in memory and on a single thread, and after it, a path built for exactness and durability, with databases and reconciliation.
Use cases
A trader places orders, cancels them, and watches the prices.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | A participant can place an order to buy or sell a quantity of a share at a limit price. |
| 1 | A participant can cancel an order that has not been completely filled. |
| 2 | An incoming order trades with waiting orders on the other side whose prices meet its own, best price first, and earliest first at each price. |
| 3 | A trade is made at the price of the order that was waiting. |
| 4 | What remains of an order waits in the book. |
| 5 | Every trade and every change in the best prices is published to all participants. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Fairness | Events are processed in the order of a single sequence, which is the same for every participant. |
| 1 | Determinism | The same sequence of inputs always produces the same trades. |
| 2 | Latency | An order is answered within 1 ms, 99% of the time. |
| 3 | Durability | No input that was sequenced is lost. |
| 4 | Availability | 99.99% during trading hours, with a changeover of under 1 second when a machine fails. |
| 5 | Consistency | Strong. There is exactly 1 order book for each share. |
The system is strongly consistent in the most literal sense available. For each share there is 1 sequence of events and 1 book, and no participant can ever be shown a state that another participant’s view contradicts. Where earlier chapters accepted eventual consistency in order to gain availability or speed, an exchange cannot, since 2 books that disagreed about who had bought what would be a failure of the exchange’s entire purpose. It obtains its speed by other means, namely by keeping the consistent part small enough to be executed by a single thread.
Core entities
Capacity estimate
Arithmetic ahead. A busy share receives a few thousand orders per second at its peak, which a single thread handles with a great deal to spare, and a day’s journal for the whole exchange is some tens of gigabytes.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Orders and cancellations per day | given | 500 million |
| Average per second, in a trading day of 6.5 hours | 500 million ÷ 23400 s | about 21000 |
| Peak per second | 10 times the average | about 210000 |
| Capacity of 1 engine thread | measured, in memory, without locks | several hundred thousand per second |
| Size of a journal entry | sequence, time, participant, share, side, price, quantity | about 64 bytes |
| Journal per day | 500 million × 64 bytes | 32 GB |
| Orders resting in the books | 5000 shares × 10000 orders | 50 million, about 3 GB in memory |
The API
Participants on a real exchange use compact binary protocols over persistent connections, since every microsecond counts. The operations themselves are few, and are shown here in the form of HTTP for clarity.
Place an order
POST /v1/orders HTTP/1.1
Host: exchange.example
Idempotency-Key: firm-22-000481
Content-Type: application/json
{ "share": "ACME", "side": "buy", "price": 1005, "quantity": 50 }
HTTP/1.1 201 Created
Location: /v1/orders/5
Content-Type: application/json
{
"id": 5,
"sequence": 88213,
"state": "filled",
"trades": [
{ "price": 1005, "quantity": 30, "with": 2 },
{ "price": 1005, "quantity": 20, "with": 3 }
]
}
The sequence is the number that the sequencer assigned, which fixes the position
of this order relative to every other event. The key guards against an order’s being
placed twice when a reply is lost, a mistake that in this setting costs money at
once.
Cancel an order
DELETE /v1/orders/4 HTTP/1.1
Host: exchange.example
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 4, "state": "cancelled", "remaining": 10 }
A cancellation is an input like any other. It is sequenced, and it takes effect at
its place in the sequence. If the order traded in full before the cancellation was
sequenced, the answer is 409 Conflict, since there is nothing left to cancel.
An event of the market data stream
{ "sequence": 88213, "share": "ACME", "trade": { "price": 1005, "quantity": 30 }, "best_bid": 1000, "best_ask": 1005 }
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Gateway | authenticates participants, and applies checks of syntax, risk and rate | connections, and limits | participants reconnect to another gateway |
| Sequencer | gives every input the next number, and writes it to the journal | the sequence number | a standby continues from the journal |
| Journal | holds every input, in order | the journal, replicated on several machines | a follower is elected |
| Matching engine | applies inputs in order to the books it serves | the order books, in memory | the standby, which has applied the same inputs, takes over |
| Market data | publishes trades and best prices to all participants | recent events, for requests to resend | a second publisher sends the same stream |
One order, step by step
The steps follow the arrows of figure 27.5.
- A trader sends an order to a gateway over a connection that is already open.
- The gateway checks the order: that it is well formed, that the trader is within their limits, and that its price is not absurd. It then passes the order to the sequencer.
- The sequencer assigns the next sequence number and a time, and appends the order to the journal, which is confirmed when a majority of its replicas hold it.
- The engine takes the order from the sequence, matches it against the book, and updates the book. The standby engine does the same, a moment later.
- The engine emits the trades and the new best prices, which are published to every participant and passed on for clearing.
The design, deployed
This is the 1 deployment in the book without a load balancer on its main path, and the omission is deliberate. A participant connects to a particular gateway over a path whose length is controlled, every gateway feeds the single sequencer, and each share is matched by 1 particular engine. There is nowhere that a request could be sent to “any available server”, since the whole design depends on there being exactly 1 place for each decision. Redundancy is provided by duplicates that follow along, a standby sequencer and a standby for every engine, and by a journal with 3 replicas in 2 sites. The clearing systems at the bottom are of the ordinary kind, and are fed from the engines through a queue.
How real systems do it
The architecture of this chapter is that of LMAX, a trading venue in London whose engineers described it publicly around 2011. All the business logic of the venue, the matching of orders and the management of risk for every customer, ran on a single thread, with its state entirely in memory, and that thread was reported to process 6 million orders per second on the hardware of the time. The state was made durable in the manner described here. Every input event was written to a journal before it was processed and was replicated to other nodes, which processed the same events, and the state could be rebuilt by replaying the journal from a snapshot taken each night. The engineers’ own account is that they arrived at the single thread after trying the alternatives, and found that the cost of coordinating threads exceeded the cost of the work.
The component that carried events between the thread and the outside world, a ring buffer written and read without locks, was published separately as the Disruptor, and it embodies the observation that queues between threads are expensive mainly because of contention for the processor’s caches.
For entry and for market data, exchanges use compact binary protocols over persistent connections. The protocols of Nasdaq are published and give a concrete picture: 1 for entering orders, and another that publishes every event of the order book, each with a sequence number, from which a recipient detects a gap and asks for it to be filled, as described under publishing prices.
The equalising of paths is taken further at 1 American exchange, IEX, which passes every incoming order through a coil of 38 miles of optical fibre. The resulting delay of 350 microseconds is intended to remove the advantage of those who learn of a price change a few microseconds before others. Fairness in this business is thus partly a matter of physics, and the designers of an exchange have to say what they mean by it.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Ordering events by the clocks of the gateways | Clocks differ by more than the gap between competing orders, and priority is decided by clock error. | Number every input in a single sequencer. |
| Matching with several threads and locks | Slower than 1 thread, and no longer the same from run to run. | Use 1 thread for each book, and partition by share. |
| A database in the path of matching | Every order waits for a disk and a network. | Keep the book in memory, and make the inputs durable in a journal. |
| Protecting the state and not the inputs | Recovery needs a consistent copy of a book that changes thousands of times a second. | Journal the inputs, and recompute the state. |
| Reading the clock inside the engine | A replay gives different results from the original run. | Make the time an input, assigned by the sequencer. |
| Processing an input before it is in the journal | A crash loses an order that has already traded. | Journal first, then process. |
| Prices in floating-point numbers | Prices that ought to be equal compare as different. | Use whole numbers of the smallest unit. |
| Waiting for market data to be delivered | A slow recipient delays the engine for everybody. | Hand events to a publisher, and let recipients recover gaps by sequence number. |
| Publishing to some participants before others | Those who see a trade first profit at the others’ expense. | Publish to all at the same moment. |
| No checks before the book | A mistaken order moves the price in milliseconds. | Check size, price and limits at the gateway. |
| Orders without an idempotency key | A retry after a lost reply places the order twice. | Require a key for every order. |
Failures
| Failure | What participants see | How the system recovers |
|---|---|---|
| The engine’s machine fails | a pause of under a second | the standby, which holds the same book, begins to publish |
| The sequencer fails | a pause of under a second | a standby sequencer continues from the last number in the journal |
| A gateway fails | its participants are disconnected | they reconnect elsewhere, and their waiting orders are untouched in the book |
| The reply to an order is lost | the participant does not know whether the order was placed | the retry carries the same key and returns the original result |
| A participant misses market data | a gap in the sequence numbers | the missing events are requested again |
| A defect is found in the engine | a wrong trade, or a halt in trading | the journal is replayed against a corrected engine to establish what should have happened |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| A single sequencer | an unambiguous order, and hence fairness | every input passes through 1 point |
| A single thread for each book | speed and determinism together | the capacity of a book is limited to 1 core |
| State in memory, inputs in a journal | no database in the path, and exact recovery | recovery requires replay, hence a standby |
| A standby that applies the same inputs | changeover in milliseconds | a second machine doing the same work |
| Strict determinism | replay, audit and exact reproduction of defects | no clocks, no randomness, and great care with every change |
The simulation
Simulation in the repository. The program code/matchsim simulates this design. To run it, clone the repository and type cargo run --release -p matchsim in the code directory.
References
- M. Fowler, “The LMAX Architecture”, martinfowler.com, 2011.
- M. Thompson and others, “Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads”, LMAX technical paper, 2011.
- Nasdaq, TotalView-ITCH and OUCH protocol specifications.
- IEX Exchange, “The Speed Bump”, exchange documentation.
- FIX Trading Community, Financial Information eXchange (FIX) Protocol specification.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Order | a statement of willingness to buy or sell a quantity at a price |
| Limit order | an order with a price beyond which it will not trade |
| Market order | an order that trades at once at the best price available |
| Order book | the waiting orders for 1 share, on the buy side and the sell side |
| Best bid, best ask | the highest price offered by a buyer, and the lowest asked by a seller |
| Spread | the difference between the best ask and the best bid |
| Price-time priority | ranking orders by price, and by time of arrival at the same price |
| Matching engine | the program that matches incoming orders against the book |
| Trade | an exchange agreed between a buy order and a sell order |
| Sequencer | the component that gives every input its place in a single sequence |
| Journal | the durable record of every input, in sequence |
| Deterministic | producing the same output whenever it is given the same input |
| Replay | recomputing state by applying the journal again |
| Market data | the published stream of trades and best prices |
| Clearing, settlement | working out what is owed after trades, and the actual exchange of shares and money |
Summary
- An order book ranks waiting orders by price and, at the same price, by time of arrival, and an incoming order trades with the best of them at their prices.
- Which order was first cannot be decided by clocks, and is decided by a sequencer that numbers every input.
- Matching is sequential by nature, and a single thread with the book in memory is both the fastest and the only repeatable way of doing it.
- Capacity is added by giving different shares to different engines, and never by dividing 1 book.
- The engine is deterministic, so its state can be recomputed from a journal of its inputs, which is what is made durable.
- A standby applies the same inputs and holds the same book, and takes over within milliseconds.
- Checks are made at the gateway before sequencing, and prices are published to all participants at once.
- After the trade, clearing and settlement proceed on ordinary strongly consistent databases, without delaying the engine.
Click Aggregation: System Design
An advertiser pays for each click on an advertisement, and wishes to know, minute by minute, how many clicks there have been. A system that answers this question takes in a stream of events, each recording that somebody clicked on something at some moment, and turns it continuously into counts: 412 clicks on advertisement 7 between 10:00 and 10:01, 398 in the minute after. The counts feed dashboards that people watch, budgets that stop an advertisement when its money is spent, and, at the end of the month, invoices.
Counting sounds like the easiest task a computer could be given, and for events that arrived promptly, once each and in order, it would be. The events of a real system do none of those things. A click is recorded on a phone that may be in a tunnel, and reaches the service seconds or hours after it happened. Events that were sent in 1 order arrive in another. A browser that received no acknowledgement sends the same click again. And the machines that do the counting fail in the middle of their work, after they have counted some events and before they have recorded that they did so. Since money changes hands on the strength of the totals, a count that is 2% too high is an advertiser overcharged, and one that is 2% too low is revenue lost.
A system that computes results continuously from an unending stream of events is said to do stream processing, and the counting of clicks is its standard example. The ideas of this chapter apply equally to counting page views, summing sales, detecting fraud and computing the metrics of chapter 24.
This chapter covers
- counting once a day, and why that is too slow
- the 2 times of every event: when it happened, and when it arrived
- windows, and which window an event belongs to
- watermarks, which decide when a window is finished
- events that arrive after their window has been closed
- counting each click once, although it is delivered more than once
- recovering from a failure without counting anything twice
- keeping every raw event, so that the counts can be made again
- a key that receives far more events than the others
Counting once a day
The oldest way of producing such counts makes no attempt at continuity. Every click is appended to a file, and once a day a job reads the day’s file from beginning to end, counts the clicks for each advertisement, and writes the totals. This is batch processing, and it has virtues that should not be underrated. It is simple. It sees all the data at once, so that late and disordered events present no difficulty provided they arrived before the job ran. And if the job is found to contain a defect, it is corrected and run again on the same file.
Its failing is delay. The count for a click made just after midnight is available some 24 hours later. An advertiser cannot see during the day how a campaign is performing, and a budget of 1000 that was exhausted by 9 in the morning goes on being spent until the following night.
Stream processing aims to produce the same counts within seconds, by processing each event as it arrives. Nearly everything that makes it difficult comes from having given up the 2 comforts of the batch job, namely that all the data was present before the work began, and that the whole job could be run again.
The 2 times of an event
Every event has 2 times associated with it, and to confuse them is the most common single error in the processing of streams.
The event time is the moment at which the thing happened: the instant at which the visitor’s finger touched the advertisement, as recorded by the visitor’s device. The processing time is the moment at which the event reaches the system that counts it. The 2 differ by the time the event spent in transit, which is usually a fraction of a second and occasionally very much more. A phone without a connection holds its events until the connection returns. A collector that is restarted delivers a backlog when it comes back.
The question an advertiser is asking concerns event time. “How many clicks between 10:00 and 10:01” means clicks that were made in that minute, wherever they have been since. A system that counts by processing time answers a different question, how many clicks happened to reach the counter in that minute, and its answers are wrong whenever anything is delayed. Should the collectors be down for 5 minutes and then deliver everything together, counting by processing time shows 5 minutes with no clicks followed by 1 minute with 6 times the usual number, and an advertiser whose campaign ended at 10:03 is charged for clicks made before the end and delivered after it.
Windows
A stream has no end, and a count is of a finite set. The stream is therefore divided into windows, finite spans of time, and a count is produced for each. The simplest kind, the tumbling window, divides time into consecutive intervals of equal length that do not overlap: 10:00 to 10:01, 10:01 to 10:02, and so on. Each event belongs to exactly 1 window, the one that contains its event time.
Other shapes serve other questions. A sliding window covers a fixed span and advances by a smaller step, as in “the clicks of the last 5 minutes, recomputed every minute”, so that each event belongs to several windows. A session window groups the events of 1 user that follow one another closely, and ends when the user has been inactive for some time. This chapter uses tumbling windows, which are sufficient to exhibit every difficulty.
To assign an event to its window by event time is easy: the window is determined by the timestamp. The hard question is a different one. Since events arrive late and out of order, the system needs to know when it has received all the events of a window, so that it may declare the count final and publish it. At 10:01:00 by the clock, the window from 10:00 to 10:01 is over, in the sense that no more clicks will be made in it. But clicks that were made in it may still be on their way.
Watermarks
There is no certain answer to that question, since an event can in principle be delayed without limit. The system has to make a judgement, and the device by which the judgement is expressed is the watermark.
A watermark is a statement by the system about its own progress through event time: “I believe I have now received every event that happened before time T”. It is an estimate, derived from the events that have been seen. A simple and common rule takes the largest event time observed so far and subtracts a fixed allowance for lateness. If the newest event seen happened at 10:01:09 and the allowance is 5 seconds, the watermark stands at 10:01:04, and the system proceeds on the assumption that nothing earlier than that is still to come.
A window is declared finished when the watermark passes its end. Until then it is kept open, and events that belong to it are added to its count although they arrive after the clock has moved past it.
In these steps the windows are 10 seconds long and the allowance is 5 seconds. A click that happened at 7 seconds and arrived at 13 is counted in the first window, where it belongs, since the watermark had not yet passed the end of that window. When events from 19 seconds appear, the watermark reaches 14, and the first window is closed with a count of 7.
The simulation for this chapter runs the same stream through 2 counters. Counting by arrival time gives 7, 5, 1 and 1 for 4 windows, which is wrong in every window, and its first figure is right in size only by coincidence, since it includes a duplicate and omits 2 late clicks. Counting by event time with a watermark gives 7 and 4 for the first 2 windows, against a truth of 8 and 4.
The size of the allowance is the central parameter of the design, and it expresses a compromise that cannot be avoided. A generous allowance waits longer, catches more of the late events, and delays every result by as much. A small allowance publishes promptly and misses more. Results cannot be both immediate and complete, since completeness is only known in retrospect, and the choice of where to stand between the 2 depends on what the counts are used for.
Events that are later still
Whatever the allowance, some events will arrive after their window has been declared finished. In the steps above, a click made at 2 seconds arrives a minute late, long after the first window was closed with a count of 7. The true count for that window was 8.
There are 3 things that can be done with such an event. It can be discarded, which is simple and makes the published counts slightly too low for ever. It can be used to revise the count, with the window’s result published a second time as 8, which requires everything downstream to be able to accept a correction to a figure it has already received. Or it can be set aside in a separate stream of late events, to be taken into account by a later and more leisurely process.
For data on which invoices are based, discarding is unacceptable, and the usual design distinguishes 2 grades of result. The counts published within seconds are labelled provisional. They drive the dashboards and the budgets, where promptness matters more than the last fraction of a percent. A final count for each window is computed later, when even very late events have had time to arrive, and it is the final counts that are billed. The system states openly which of its figures are of which grade, and does not present an estimate as though it were settled.
Counting each click once
The message queue that carries the clicks delivers at least once, as chapter 11 explained, and the devices that send them retry when no acknowledgement comes. The same click may therefore reach the counter 2 or 3 times, and a counter that counted every delivery would overcharge the advertiser by the rate of retries.
The remedy is the one that has served throughout this book. Every click is given a unique identifier at its origin, by the visitor’s device, and the counter keeps a record of the identifiers it has counted, declining to count any that it has seen before. In the steps above, the second delivery of click 3 is recognised and ignored.
The set of identifiers seen cannot be allowed to grow for ever, and here the watermark gives a second service. An identifier needs to be remembered only for as long as a duplicate of it could still arrive and be counted, which is as long as its window remains open. When a window is closed, the identifiers that belong to it can be discarded, since any later duplicate will be classed as a late event and dealt with by that path.
Recovering without counting twice
The processor holds state in its memory: the partial counts of the windows that are open, the identifiers it has seen, and the position it has reached in the log of clicks. When it fails, a replacement has to continue, and the manner in which it continues determines whether the counts are right.
If the replacement started with empty state from the present position in the log, the partial counts of the open windows would be lost, and those windows would be published too low. If it started with empty state from an earlier position, it would count again events whose windows had already been published. The difficulty is that of chapter 11, the gap between doing work and recording that it was done, with the addition that the work now accumulates in memory over the life of a window.
The solution is to save the state and the position together. At regular intervals the processor writes a checkpoint, which consists of its entire state and the position in the log up to which that state reflects the input. The essential property is that the 2 are saved as a unit, so that the state corresponds exactly to the position. On recovery, the replacement loads the latest checkpoint and resumes reading from the recorded position. Every event before that position is reflected in the restored state and is not read again, and every event after it is absent from the restored state and is read. Each event affects the state exactly once.
There remains the output. Between the last checkpoint and the failure, the processor may have published the results of some windows, and after recovery it will compute and publish those windows again. The results must therefore be written in such a way that writing them twice is harmless. A count is stored under a key formed from the advertisement and the start of the window, and writing the same count under the same key a second time merely replaces a value with an equal one. This is an idempotent write, and it completes the chain. With a log that can be read again from any position, a checkpoint that pairs state with position, and output that may safely be repeated, each click contributes to the published totals exactly once, although individual steps along the way may be performed more than once. This is the “effectively once” of chapter 11, extended to a computation that keeps state.
Keeping the raw events
However carefully the stream processor is built, the time will come when its results are found to be wrong. A defect in the code will have miscounted for a week, or a definition will change, as when clicks from automated programs, hitherto counted, are to be excluded. The counts must then be made again, and that is possible only if the original events still exist.
Every click is therefore retained in its raw form, in the log for some days and in an archive in the object store of chapter 16 for much longer. The archive is the authority, and every count is derived from it. A corrected version of the computation can be run over the archived events to produce corrected counts, which replace the old ones under the same keys.
This arrangement also provides the final counts mentioned earlier. A batch job run each night over the complete events of the previous day sees every late arrival, needs no watermark, and produces figures that are definitive. The stream gives the prompt, provisional answer, and the batch job gives the slow, exact one. Many systems run both for this reason, and the chief burden of doing so is that the same logic has to be maintained in 2 places and kept in agreement. Where the stream processor can itself reread old events from the log, the reprocessing can be done by running the same code over the retained log from an earlier position, and the second implementation is avoided.
A key with too many events
For the sake of throughput, the stream is divided among several processors, each handling the events of some of the advertisements. The division is made by hashing the identifier of the advertisement, as chapter 11 divided messages by key, so that all the clicks on 1 advertisement go to the same processor and can be counted together.
Advertisements are very unequal. A single campaign shown during a major sporting event may receive more clicks than all the others together, and every one of them is directed to the same processor, which falls behind while its neighbours are idle. This is the hot key of chapters 9 and 11 in yet another setting.
The usual remedy divides the work into 2 stages. In the first, the events of the busy advertisement are spread across several processors, by adding a small random number to the key, and each processor counts its share for each window. In the second, a single processor adds the partial counts together. The second stage receives 1 small message from each first-stage processor for each window, in place of every event, and is easily able to keep up. Since addition gives the same result in whatever grouping it is performed, the total is unaffected.
Use cases
A visitor’s click is recorded. An advertiser sees the counts, and is billed on the final ones.
Functional requirements
| No. | Functional requirement |
|---|---|
| 0 | Every click on an advertisement is recorded, with the time at which it happened. |
| 1 | The number of clicks on each advertisement in each minute is available within 30 seconds of the end of that minute. |
| 2 | Clicks are counted in the minute in which they happened, whenever they arrive. |
| 3 | A click that is delivered more than once is counted once. |
| 4 | Final counts for each day are produced, which include clicks that arrived late. |
| 5 | Counts can be computed again from the recorded clicks. |
Non-functional requirements
| No. | Property | Non-functional requirement |
|---|---|---|
| 0 | Accuracy | Final counts are exact. Provisional counts are within 1% of the final ones. |
| 1 | Freshness | Provisional counts appear within 30 seconds. |
| 2 | Durability | A click that was accepted is not lost. |
| 3 | Consistency | Eventual for provisional counts, which may be revised. Final counts do not change. |
| 4 | Scale | 1 billion clicks per day, with peaks of 100000 per second. |
| 5 | Availability | 99.9% for accepting clicks, which matters more than availability for reading counts. |
The last requirement reflects an asymmetry that is typical of systems of this kind. A dashboard that is unavailable for a minute is an inconvenience, and its figures are there when it returns. A click that is not accepted is lost for good, since the visitor will not make it again. The collectors that accept clicks are therefore kept as simple as possible, doing nothing beyond appending each click to a durable log, so that they remain available whatever is happening to the more elaborate machinery downstream.
Core entities
Capacity estimate
Arithmetic ahead. 1 billion clicks a day of 200 bytes each are 200 GB of raw events a day, while the counts for a million advertisements are a few gigabytes.
Show the calculation
| Quantity | Calculation | Result |
|---|---|---|
| Clicks per second, on average | 1 billion ÷ 86400 | about 11600 |
| Raw events per day | 1 billion × 200 bytes | 200 GB |
| Raw events per year, in the archive | 200 GB × 365 | 73 TB |
| Count rows per day | 1 million advertisements × 1440 minutes, of which 10% have any clicks | 144 million |
| Size of the counts per day | 144 million × 24 bytes | about 3.5 GB |
| State of the processors | identifiers of clicks in open windows: 100000 per second × 90 s × 8 bytes | about 72 MB |
The API
A click is reported by the visitor’s browser, with an identifier that the browser generates and the time at which the click was made. An advertiser reads counts for a range of time.
Report a click
POST /v1/clicks HTTP/1.1
Host: clicks.example
Content-Type: application/json
{ "id": "c-7f3a91", "ad": 7, "happened": "2026-10-14T10:00:07Z" }
HTTP/1.1 202 Accepted
The status is 202, since the click has been stored and has not yet been counted.
The identifier allows a repeated report to be recognised.
Read the counts of an advertisement
GET /v1/ads/7/counts?from=2026-10-14T10:00:00Z&to=2026-10-14T10:03:00Z HTTP/1.1
Host: clicks.example
HTTP/1.1 200 OK
Content-Type: application/json
{
"ad": 7,
"window_seconds": 60,
"counts": [
{ "start": "2026-10-14T10:00:00Z", "count": 412, "final": true },
{ "start": "2026-10-14T10:01:00Z", "count": 398, "final": false },
{ "start": "2026-10-14T10:02:00Z", "count": 87, "final": false }
]
}
Every count states whether it is final. A caller that draws a graph may use them all, and a caller that computes an invoice uses only those marked as final.
High-level design
| Component | Role | State it holds | When 1 instance fails |
|---|---|---|---|
| Collector | accepts clicks and appends them to the log | none | another collector accepts |
| Click log | holds every click, in partitions by advertisement | the log, replicated, as in chapter 11 | a follower is elected |
| Stream processor | assigns clicks to windows, removes repeats, and counts | open windows, identifiers seen, and its position, saved in checkpoints | a replacement resumes from the latest checkpoint |
| Count store | holds the count for each advertisement and window | the counts, replicated | a follower is elected |
| Archive | keeps every raw click | the clicks, in an object store | as in chapter 16 |
One click, step by step
The steps follow the arrows of figure 28.4.
- A visitor clicks, and the browser reports the click with an identifier and the time.
- A collector appends the click to the log, in the partition chosen by the advertisement, and answers
202. - The stream processor reads the click from the log. If it has seen the identifier, it does nothing. Otherwise it adds 1 to the open window that contains the click’s event time, and moves the watermark forward if the click is the newest it has seen.
- The log is also copied to the archive, from which counts can later be made again.
- When the watermark passes the end of a window, the processor writes that window’s count to the count store, under a key formed from the advertisement and the start of the window.
The design, deployed
The tiers follow the path of a click. The collectors are a large and deliberately simple stateless tier, since their availability is what matters most. The click log of 12 brokers is the point at which the system changes character: above it, everything must be available at every moment, and below it, everything can be stopped, corrected and run again from a position in the log. The 24 stream processors hold their state in checkpoints, and the nightly batch reads the archive. At the bottom, a small serving tier answers dashboards and the billing system from the count store.
How real systems do it
The vocabulary of this chapter, event time and processing time, windows, watermarks and late data, was fixed by a paper from Google in 2015 that presents the Dataflow model. The paper’s central argument is the one made here, that a stream is unbounded and its completeness is never known, so that a system must offer the means to trade correctness, delay and cost against one another explicitly. It adds a concept that this chapter touched on without naming, the trigger, which says when the result of a window is to be emitted: early and provisionally, at the watermark, and again for each late arrival. The earlier MillWheel paper describes the system beneath, including low watermarks and the removal of duplicate records by recording the identifiers of those processed.
Apache Flink is the open implementation in widest use, and its checkpoints are the ones described here. They are taken without stopping the stream, by passing markers through it that tell each operator when to record its state, a method derived from a classical algorithm for taking a consistent snapshot of a distributed system.
The problem of this very chapter, the joining and counting of clicks for billing, is the subject of a paper on Photon, the system that Google built for its advertising business. Photon runs in several data centres at once, so that the loss of 1 does not delay the counts, and it prevents a click from being processed twice by recording the identifier of every processed click in a store that is replicated between the data centres by Paxos. A click is thereby counted at most once at all times and exactly once eventually, which is the guarantee sought here, obtained across data centres.
The practice of running a stream and a batch side by side was named the lambda architecture. The objection that the same logic then exists twice, and the proposal to reprocess by running the stream code again over a retained log, were made in an article by an author of Kafka. And for counting the number of distinct visitors, which cannot be done by adding partial counts, the standard tool is a sketch that estimates the number from a few kilobytes of state.
Mistakes to avoid
| Mistake | What goes wrong | What to do |
|---|---|---|
| Counting by the time of arrival | Delayed clicks are counted in the wrong minute, and an outage produces an empty stretch followed by a spike. | Count by the time at which the click happened. |
| Closing a window when the clock passes its end | Every click still in transit is missed. | Close a window when the watermark passes its end. |
| Discarding late clicks silently | Billed counts are always slightly too low. | Record late clicks, and include them in the final counts. |
| Presenting provisional counts as final | A figure that has already been billed changes afterwards. | Mark every count as provisional or final. |
| Counting every delivery | Retries and redeliveries inflate the counts. | Give every click an identifier, and count each identifier once. |
| Remembering every identifier for ever | Memory grows without limit. | Forget the identifiers of a window when it closes. |
| Saving state and position separately | After a failure, clicks are counted twice or not at all. | Save them together, in a checkpoint. |
| Writing results by adding to a stored count | A result written twice after a recovery is added twice. | Write the count of a window under its own key, replacing whatever is there. |
| Keeping only the counts | A defect or a change of definition cannot be corrected. | Keep every raw click. |
| Trusting the timestamp of the visitor’s device entirely | A device with a wrong clock places its clicks in the wrong day. | Record the time of arrival too, and reject event times that are impossibly far from it. |
| Sending all the clicks of a busy advertisement to 1 processor | That processor falls behind while the others are idle. | Count in 2 stages, spreading the busy key in the first. |
| Doing work in the collector | Clicks are lost when the downstream machinery is slow. | Let the collector append to the log and nothing else. |
Failures
| Failure | What users see | How the system recovers |
|---|---|---|
| A stream processor fails | counts stop advancing for some seconds | a replacement loads the latest checkpoint and resumes from its position |
| The collectors are unreachable for a time | no new counts, then counts for the missed minutes | browsers retry, and the clicks are counted in the minutes in which they happened |
| A click arrives after its window was closed | the provisional count is slightly low | the final count includes it |
| A click is delivered twice | nothing | its identifier is recognised |
| A window’s result is written twice | nothing | the second write replaces the first with the same value |
| The counts are found to be wrong | corrected figures | the computation is run again over the archive |
Trade-offs
| Decision | What it gains | What it costs |
|---|---|---|
| Counting by event time | counts that answer the question asked | windows have to be kept open, and a watermark maintained |
| A larger allowance for lateness | more complete provisional counts | every result is published later |
| Provisional and final counts | prompt figures and exact invoices | 2 grades of result that callers must distinguish |
| Checkpoints | recovery with each click counted once | periodic pauses to save state, and reprocessing since the last checkpoint |
| Keeping raw events | counts can always be made again | 73 TB a year of storage |
| A stream and a nightly batch | speed and exactness together | the same logic in 2 places |
The simulation
Simulation in the repository. The program code/clicksim simulates this design. To run it, clone the repository and type cargo run --release -p clicksim in the code directory.
References
- T. Akidau and others, “The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing”, VLDB 2015.
- T. Akidau and others, “MillWheel: Fault-Tolerant Stream Processing at Internet Scale”, VLDB 2013.
- R. Ananthanarayanan and others, “Photon: Fault-tolerant and Scalable Joining of Continuous Data Streams”, SIGMOD 2013.
- P. Carbone and others, “Lightweight Asynchronous Snapshots for Distributed Dataflows”, 2015.
- K. Chandy and L. Lamport, “Distributed Snapshots: Determining Global States of Distributed Systems”, ACM Transactions on Computer Systems, 1985.
- J. Kreps, “Questioning the Lambda Architecture”, O’Reilly Radar, 2014.
- P. Flajolet and others, “HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm”, AofA 2007.
Terms introduced in this chapter
| Term | Meaning |
|---|---|
| Stream processing | computing results continuously from an unending sequence of events |
| Batch processing | computing results from a complete, finite set of data |
| Event time | the time at which an event happened |
| Processing time | the time at which an event reaches the system that processes it |
| Window | a finite span of time over which events are aggregated |
| Tumbling window | 1 of a series of consecutive windows of equal length that do not overlap |
| Sliding window | a window of fixed length that advances by a smaller step |
| Session window | the events of 1 user that follow one another closely |
| Watermark | the system’s estimate of the event time before which everything has arrived |
| Allowed lateness | how long the system waits for older events |
| Late event | an event that arrives after its window was declared finished |
| Provisional, final | a count that may still be revised, and one that will not |
| Checkpoint | the state of a processor saved together with its position in the input |
| Reprocessing | computing results again from the original events |
Summary
- Counting events once a day is simple and exact, and its results come a day late.
- Every event has the time it happened and the time it arrived, and counts must go by the first.
- A stream is divided into windows, and the hard question is when a window has received all its events.
- A watermark estimates how far the system has progressed through event time, and a window is finished when the watermark passes its end.
- Events later than that are recorded and included in final counts, which are distinguished from the provisional ones published at once.
- Every click carries an identifier, and is counted once however often it is delivered.
- A checkpoint saves the processor’s state together with its position in the log, and results are written under keys that make a repeated write harmless.
- The raw events are kept, so that every count can be computed again.