The previous three lessons took replication for granted: there were replicas, inv-bcn and inv-vlc, that lagged, diverged or reached agreement, but we never explained how data gets from one to the other or what happens along the way. This lesson opens that box. Replication is the mechanism by which the same piece of data is kept on several nodes, and its design directly determines which consistency model you get (03-01), what is sacrificed when a partition strikes (03-02) and where consensus is needed (03-03).

We will first look at why we replicate (availability, latency and read scaling) and then at the three topologies that exist. Leader-follower replication, the most common, with its synchronous, asynchronous and semi-synchronous variants, replication lag and the anomalies it produces (which are exactly the session guarantees of 03-01 seen from the other side), and failover with its dangers. Multi-leader replication, for several regions or offline clients, whose central problem is resolving conflicts between concurrent writes. And Dynamo-style leaderless replication, with write and read quorums, read repair and hinted handoff. The practical part is twofold: we will set up a real PostgreSQL streaming replica for km0_orders in Kilometre Zero's docker-compose.yml, observe it with pg_stat_replication and watch it arrive late; and a Python simulation with N=3 will show when a quorum returns stale reads. The Cassandra database, which implements leaderless replication in production, and partitioning (how to spread different data across nodes, rather than copy the same data) are left for Module 4.

Contents

  1. Why replicate: availability, latency and read scaling
  2. Leader-follower replication
  3. Synchronous, asynchronous and semi-synchronous
  4. Replication lag and its anomalies
  5. Failover and its dangers
  6. Multi-leader replication and conflict resolution
  7. Leaderless replication: quorums, read repair and hinted handoff
  8. Table of the three topologies
  9. Hands-on: PostgreSQL streaming replication for km0_orders
  10. Simulation: W/R quorum with N=3
  11. Common mistakes and tips
  12. Exercises
  13. Conclusion

  1. Why replicate: availability, latency and read scaling

Replicating means keeping copies of the same data on several nodes connected by a network. It is done for three reasons, and it is worth knowing which one you are pursuing because each pushes the design in a different direction:

Goal What you are after Design consequence At Kilometre Zero
Availability (fault tolerance) The loss of a node (disk, machine, data centre) must neither lose data nor stop the service The copies must be in different failure domains; a failover procedure is needed km0_orders cannot lose orders if the primary server dies
Latency Data should be close to whoever reads it Geographically spread copies; writes possibly far away The catalogue is read from Valencia without crossing over to Barcelona
Read scaling Spreading the read load over many copies Many read-only replicas; the lag between them becomes visible The pink-tomato page is read thousands of times for every write

Notice that none of the three goals is "scaling writes": replication copies the same writes to every node, so write capacity does not grow. For that you need partitioning (04-01).

If the data never changed, replicating would mean copying it once. All the difficulty comes from the fact that it changes: every write has to reach every copy, and between reaching one and reaching another there is an interval in which the copies differ. The three topologies below are three answers to the question "who accepts writes and how are they propagated?".

  1. Leader-follower replication

Also called single-leader, primary-replica or master-slave (a term that is falling out of use). It is what PostgreSQL, MySQL, MongoDB, Kafka (per partition) and most systems use:

  • One node is the leader (primary). All writes go to it. The first thing it does is write them to its local storage.
  • The others are followers (replicas, standbys). The leader sends them a stream of changes (the replication log), and each follower applies them in the same order, so its state converges to the leader's.
  • Reads can go to the leader (always current) or to any follower (possibly lagging).
flowchart LR
    C1[Clients that write] -->|INSERT / UPDATE| L[(Leader<br/>km0_orders primary)]
    L -->|WAL replication log| S1[(Follower 1<br/>Barcelona replica)]
    L -->|WAL replication log| S2[(Follower 2<br/>Valencia replica)]
    C2[Clients that read] -->|SELECT| S1
    C3[Clients that read] -->|SELECT| S2
    C1 -.->|SELECT that needs<br/>the latest value| L

What travels down the stream can be of three kinds, and the difference matters in practice:

What is replicated Example Advantage Drawback
Statements UPDATE stock SET units = units - 1 WHERE product = 'aged-cheese' Compact Non-deterministic: now(), random(), sequences and triggers give different results on each node; MySQL dropped it as the default
Physical log (WAL) The bytes that change in the disk pages Exact, and it already exists for durability Couples the replicas to the exact engine version and its on-disk format; this is what PostgreSQL does in streaming replication
Logical log (rows) "In the orders table, the row with id P-2026-000123 now has status paid" Independent of engine and version; lets you replicate individual tables and feed other systems (CDC) More costly to generate; it is PostgreSQL's logical replication and the basis of Debezium, which connects with the Outbox pattern of 02-05

To add a new follower without stopping the leader, you take a consistent snapshot of the leader at a point in the log (in PostgreSQL, pg_basebackup), copy it to the follower, and the follower asks the leader for "everything that has happened since that point" (catch-up). Once it has caught up, it follows live. We will do this in section 9.

  1. Synchronous, asynchronous and semi-synchronous

The key question is when the leader acknowledges the write to the client relative to the moment the follower receives it:

sequenceDiagram
    participant C as Client (orders)
    participant L as Leader
    participant S as Follower
    Note over C,S: Synchronous
    C->>L: COMMIT
    L->>S: WAL
    S-->>L: written to disk
    L-->>C: ok (only after the follower's ack)
    Note over C,S: Asynchronous
    C->>L: COMMIT
    L-->>C: ok (immediate)
    L->>S: WAL (arrives when it arrives)
Mode The leader acknowledges when... Guarantee if the leader dies right after acknowledging Write latency Write availability
Synchronous The follower (or every follower) has written the transaction to disk The transaction is on the follower: zero loss + one round trip to the slowest follower, plus its fsync If the synchronous follower goes down or is isolated, writes block until it comes back or the system is reconfigured
Asynchronous It has written to its own disk The transaction may not have reached the follower: loss is possible (the last transactions disappear when the follower is promoted) Minimal The leader keeps writing even if every follower is down
Semi-synchronous At least one (or a quorum) of the followers has acknowledged; the rest are asynchronous At least one copy has the transaction: zero loss as long as the leader and that follower do not fail at the same time + the fastest synchronous follower It blocks only if no follower can acknowledge

Fully synchronous replication with many followers is impractical: the slowest follower sets the latency of the whole system, and any follower that is down paralyses it. That is why "synchronous" in practice nearly always means semi-synchronous: one synchronous follower (which guarantees the copy) and the rest asynchronous (which serve reads and will be at hand if the synchronous one goes down). PostgreSQL configures this with synchronous_standby_names, and in section 9 we will change it on the fly to see the effect. In the terms of 03-02, asynchronous replication is PC/EL (fast, with lag on the replicas) and synchronous replication is PC/EC (every commit pays the coordination latency).

  1. Replication lag and its anomalies

With asynchronous followers, between the leader acknowledging and the follower applying there is a replication lag: milliseconds under normal conditions, seconds or minutes if the follower is overloaded, if a long query is blocking WAL replay, or after a partition. If reads go to the followers for scaling, that lag turns into the anomalies Anna suffered in 03-01, now with their mechanical cause:

Anomaly (03-01) How the lag produces it How to avoid it in leader-follower
Read-your-writes violation Anna writes to the leader and reads from a follower that has not yet applied her transaction Read from the leader whatever the user themselves has just modified (for example, during the minute following a write); or the client remembers the LSN of its commit and the follower waits until it reaches it (pg_last_wal_replay_lsn() >= lsn)
Monotonic reads violation Two consecutive reads land on followers with different lag Always route each user to the same follower (hash of the user id), or a minimum LSN on the client
Causal consistency violation (the reply before the question) Mark's question and the dairy's reply are written to the leader in order, but a follower lagging by seconds still shows only the question while another shows both; a third system reading from both sees the order broken In leader-follower the log preserves the order, so a single follower never shows the reply without the question; the problem appears when combining reads from different followers or when partitioning (04-01)

The practical consequence is that "scaling reads with replicas" is not transparent: you have to decide for each query whether it tolerates lag, and measure the lag continuously (pg_stat_replication in section 9; alerts in 07-01). A reasonable limit is to route to replicas only those reads where a lag of a few seconds is harmless (catalogue, order history from days ago, reports) and keep on the leader the reads that precede a decision (is there stock left? has the order been paid?).

  1. Failover and its dangers

If the leader dies, a follower must become the leader: this is failover. Its three steps (detecting that the leader has died, choosing the new leader, redirecting clients) look simple and each one hides a problem:

  • Detecting: there is no safe way to tell a dead leader from a slow or isolated one (01-02, 03-02). A short timeout causes unnecessary failovers; a long one prolongs the unavailability.
  • Choosing: with asynchronous replication, the most up-to-date follower may not have the last committed transactions: they are lost. Worse: if other systems have already acted on those transactions (an order.created already published by the outbox relay), they are left inconsistent with the database. With semi-synchronous replication, the synchronous follower is the candidate and loses nothing.
  • Redirecting: if the old leader comes back believing it is still the leader, there are two nodes accepting writes: split-brain. This is the situation that Raft's terms prevented in 03-03, and in a database without built-in consensus it has to be prevented from outside: by forcibly shutting down the old leader (STONITH, shoot the other node in the head) or with a fencing token.

Precisely because automatic database failover is so delicate, many teams delegate it to tools that rely on a consensus system (Patroni uses etcd for PostgreSQL; MongoDB and Kafka have their own built-in election) and others do it by hand. How a failover is operated, with which checkpoints and how it is rehearsed is the subject of 07-03; here it is enough to know that the choice between asynchronous and semi-synchronous determines how much is lost in it.

  1. Multi-leader replication and conflict resolution

In leader-follower, every write goes through a single node. If Kilometre Zero has users in Barcelona and in Valencia and the leader is in Barcelona, every write from Valencia travels 350 km; and if the network between the cities goes down, Valencia cannot write. Multi-leader replication (also master-master) puts a leader in each data centre, which accepts writes locally and replicates them asynchronously to the other leaders. Its legitimate use cases are few and specific:

  • Several regions with local writes and tolerance to disconnection between them.
  • Offline clients: the mobile app of the van-3 courier records deliveries with no coverage and synchronises when coverage returns. Each device is, in effect, a leader.
  • Collaborative editing (several users editing the same document), which is multi-leader with very small replicas.

And its central problem is unavoidable: two leaders can accept concurrent writes to the same data, and when they replicate a conflict appears. We already saw it in the AP simulation of 03-02: Anna reserves on inv-bcn and Mark on inv-vlc. The strategies, from the simplest to the most elaborate:

Strategy How it works Loses data When to use it
Avoid the conflict Route all writes to a given piece of data to the same leader (Anna's orders always to Barcelona) No Whenever possible; it breaks down when the leader has to change
Last-write-wins (LWW) Every write carries a timestamp; the highest wins Yes, silently (03-02); it depends on unsynchronised physical clocks (01-05) Data where the last write really is the right one: position of van-3, a profile edited by a single person
Deterministic merge Combine both values with a rule: set union, sum of increments, ordered concatenation No, but it can produce unexpected states Basket (union: never lose an item), wish lists
CRDT (03-01) Data types whose merge is commutative, associative and idempotent No Campaign counters, sets, collaborative text; no good for global invariants
Application-level resolution Both versions (siblings) are kept and the application, or the user, is asked to decide No When the business rule demands it: two reservations of the last cheese, with a compensation (03-05)

One detail that tends to surprise people: the topology in which the leaders send each other changes (all-to-all, ring, star) affects the order in which they arrive, and with physical clocks as the LWW timestamp a write that was causally earlier can "win". Serious multi-leader systems use vector clocks or version vectors (01-05) to detect which writes are truly concurrent and which simply arrived late. Multi-leader is powerful and dangerous in equal measure; PostgreSQL does not offer it out of the box (there are extensions such as BDR/pglogical), and the general recommendation is not to use it unless the three use cases above demand it.

  1. Leaderless replication: quorums, read repair and hinted handoff

The third topology does away with the leader altogether: any replica accepts writes and reads, and coordination is replaced by arithmetic. It was popularised by the Dynamo paper (Amazon, 2007), and it is implemented by Cassandra, Riak, Voldemort and ScyllaDB.

Write and read quorums

With N replicas of each piece of data, the client (or a coordinator node acting on its behalf) sends each write to all N replicas in parallel and considers it successful once W have acknowledged; and it sends each read to the N replicas (or to a subset) and considers it successful with R responses, keeping the value with the highest version. If

W + R > N

then the set of replicas that acknowledged the write and the set that answered the read overlap in at least one replica, which will have the new version; since the read picks the highest version, it always returns the last acknowledged write. With N=3, the typical configurations are:

W R W + R > N Behaviour
1 1 No Maximum speed and availability; frequent stale reads
2 2 Yes The usual balance: tolerates one replica down for writes and for reads
3 1 Yes Fast reads; writes blocked if one replica goes down
1 3 Yes Fast writes; reads blocked if one replica goes down
flowchart LR
    Cl[Client / coordinator] -->|write v2| A[(replica A: v2)]
    Cl -->|write v2| B[(replica B: v2)]
    Cl -.->|write v2: no response| C[(replica C: v1)]
    Cl2[Client / coordinator] -->|read| B
    Cl2 -->|read| C
    B -->|v2| Cl2
    C -->|v1| Cl2
    Cl2 -->|max version = v2<br/>read repair: send v2 to C| C

The condition W + R > N guarantees that the read sees the last acknowledged write, but it does not give linearizability: two concurrent writes with the same W can end up applied on different replicas, a read during a write in progress may see the new version and the next one the old version, and versions depend on timestamps (LWW, with its problems) or on version vectors. It is "tunable" consistency and in practice fairly strong, but a Dynamo-style system is no substitute for a consensus-backed store for the decisions of 03-03.

Keeping the replicas up to date

Without a leader there is no log to bring a replica that has been down back up to date; instead there are two mechanisms:

  • Read repair: when a read receives different versions from several replicas, the coordinator returns the newest and writes it to the replicas that had the old one. Data that is read often repairs itself; data that is not read does not (for that there are background anti-entropy processes that compare replicas using Merkle trees).
  • Hinted handoff: if a replica does not respond during a write, another node accepts the write "on its behalf" with a note (a hint) and hands it over when the replica comes back. With this, the write reaches W acknowledgements even with replicas down: this is the sloppy quorum, which increases availability at the price that W + R > N no longer guarantees overlap (the W acknowledgements may come from nodes that are not the N "real" replicas, and a later read from the N real replicas may not see the write until the hint is delivered).

  1. Table of the three topologies

Aspect Leader-follower Multi-leader Leaderless
Who accepts writes One node One node per region/device Any replica
Write conflicts Impossible (a single order) Yes; they have to be resolved Yes; versions and LWW or version vectors
Achievable consistency Up to linearizable when reading from the leader; eventual on followers Eventual, causal with version vectors Tunable with W and R; eventual with a sloppy quorum
Write latency The leader's (+ synchronous follower) Local That of the W fastest replicas
Availability when a node goes down Failover if the leader goes down (seconds to minutes, risk of loss and split-brain) The other leaders carry on; no failover No failover; the rest carry on as long as W and R are met
Tolerance to partitions between regions The region without the leader cannot write Each region writes; conflicts when they reconnect Depends on the quorum: the side with W replicas writes
Operational complexity Low; very mature High; conflicts and topologies Medium; tuning N, W, R and anti-entropy
Systems PostgreSQL, MySQL, MongoDB, Kafka, Redis CouchDB, BDR, mobile apps with synchronisation Cassandra, Riak, DynamoDB (internally), ScyllaDB
At Kilometre Zero km0_orders, km0_inventory and the rest of PostgreSQL The courier app (deliveries with no coverage) Historical orders and events in Cassandra (04-04)

  1. Hands-on: PostgreSQL streaming replication for km0_orders

We are going to give km0_orders a real follower. PostgreSQL implements leader-follower by sending its WAL (physical log) over a streaming replication connection; the follower, in hot standby mode, replays the WAL and accepts read-only queries. We add two services to Kilometre Zero's docker-compose.yml (the primary replaces the postgres service of 01-06):

# docker-compose.yml (excerpt)
services:
  orders-db-primary:
    image: postgres:16
    environment:
      POSTGRES_DB: km0_orders
      POSTGRES_USER: km0
      POSTGRES_PASSWORD: km0
    volumes:
      - orders_primary_data:/var/lib/postgresql/data
      - ./sql/replication/01-replicator.sh:/docker-entrypoint-initdb.d/01-replicator.sh
    command: >
      postgres -c wal_level=replica
               -c max_wal_senders=5
               -c wal_keep_size=256MB
               -c hot_standby=on
    ports:
      - "5432:5432"

  orders-db-replica:
    image: postgres:16
    depends_on:
      - orders-db-primary
    environment:
      PGPASSWORD: replicator
    volumes:
      - orders_replica_data:/var/lib/postgresql/data
    command: >
      bash -c "
        if [ ! -s /var/lib/postgresql/data/PG_VERSION ]; then
          until pg_isready -h orders-db-primary -U km0; do sleep 1; done;
          pg_basebackup -d 'host=orders-db-primary user=replicator application_name=replica_vlc'
                        -D /var/lib/postgresql/data -R -X stream -C -S slot_replica_vlc;
          chown -R postgres:postgres /var/lib/postgresql/data;
          chmod 700 /var/lib/postgresql/data;
        fi;
        exec docker-entrypoint.sh postgres -c hot_standby=on"
    ports:
      - "5433:5432"

volumes:
  orders_primary_data:
  orders_replica_data:

And the script that creates the replication role and authorises the connection, in km0/sql/replication/01-replicator.sh:

#!/bin/bash
# Runs only once, when the primary is initialised (docker-entrypoint-initdb.d)
set -e
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" <<-SQL
    CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replicator';
SQL
echo "host replication replicator all scram-sha-256" >> "$PGDATA/pg_hba.conf"

What each piece does, line by line:

  • wal_level=replica: the primary writes enough information to the WAL for a follower to replay it (the minimal level is not enough; logical would also include what logical replication needs).
  • max_wal_senders=5: maximum number of walsender processes, one per follower (or per pg_basebackup in progress).
  • wal_keep_size=256MB: how much WAL the primary retains even though it has already applied it itself, in case a follower falls behind. The replication slot (-C -S slot_replica_vlc) is the modern, safer version of this: the primary does not delete WAL that the slot has not yet consumed (with the risk, if the follower disappears for good, of filling the primary's disk).
  • pg_basebackup -R: copies the primary's snapshot to the follower's data directory and writes the standby.signal file (which tells it "start as a follower") and the line primary_conninfo = 'host=orders-db-primary user=replicator application_name=replica_vlc ...' in postgresql.auto.conf, which tells it whom to connect to. -X stream receives the WAL generated during the copy over a second connection, so that the snapshot is consistent without blocking the primary. The application_name is the name by which the primary will identify this follower, and we will use it to make it synchronous.
  • hot_standby=on on the follower: it accepts read queries while it replays the WAL. Without it, it would be a "warm" follower useful only for failover.
  • host replication replicator all scram-sha-256 in pg_hba.conf: authorises the replicator role to open replication connections from any address. In production this is restricted to the replicas' network and encrypted (06-04).

Checking replication

We start up with docker compose up -d orders-db-primary orders-db-replica and, from the primary, query the view that describes each connected follower:

-- On the primary (port 5432)
SELECT application_name, state, sync_state,
       sent_lsn, write_lsn, flush_lsn, replay_lsn,
       write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
 application_name |   state   | sync_state |  sent_lsn  | write_lsn  | flush_lsn  | replay_lsn |    write_lag    |    flush_lag    |   replay_lag
------------------+-----------+------------+------------+------------+------------+------------+-----------------+-----------------+-----------------
 replica_vlc      | streaming | async      | 0/3000148  | 0/3000148  | 0/3000148  | 0/3000148  | 00:00:00.000412 | 00:00:00.000891 | 00:00:00.001203

The columns tell the story of section 3: sent_lsn is how far the primary has sent; write_lsn, how far the follower has received; flush_lsn, how far it has written to disk (what counts for durability); replay_lsn, how far it has replayed (what counts for a query to see it). The *_lag columns are the corresponding times: the measured replication lag. sync_state = async confirms that, for now, the primary waits for nobody. From the follower you can see the same thing from the other side:

-- On the replica (port 5433)
SELECT pg_is_in_recovery() AS is_replica,
       pg_last_wal_receive_lsn() AS received,
       pg_last_wal_replay_lsn() AS replayed,
       now() - pg_last_xact_replay_timestamp() AS approximate_lag;

Watching a read arrive late

With a lag of one millisecond it is hard to observe the anomaly, so we are going to provoke it: the follower can pause WAL replay (it will keep receiving and storing it, but will not apply it), which is exactly what happens when a long query on the replica delays replay or when the replica is saturated.

# km0/simulations/stale_read.py
import time
import psycopg          # pip install "psycopg[binary]"

PRIMARY = "host=localhost port=5432 dbname=km0_orders user=km0 password=km0"
REPLICA = "host=localhost port=5433 dbname=km0_orders user=km0 password=km0"

with psycopg.connect(PRIMARY, autocommit=True) as p, psycopg.connect(REPLICA, autocommit=True) as r:
    r.execute("SELECT pg_wal_replay_pause()")                        # the replica stops replaying WAL
    print("replica paused:", r.execute("SELECT pg_is_wal_replay_paused()").fetchone()[0])

    p.execute("INSERT INTO orders (id, customer, status, total) VALUES (%s, %s, 'created', %s)",
              ("P-2026-000126", "Lucy", 31.50))
    commit_lsn = p.execute("SELECT pg_current_wal_lsn()").fetchone()[0]
    print(f"primary: order P-2026-000126 committed; primary LSN = {commit_lsn}")

    row = r.execute("SELECT id, status FROM orders WHERE id = 'P-2026-000126'").fetchone()
    replica_lsn = r.execute("SELECT pg_last_wal_replay_lsn()").fetchone()[0]
    print(f"replica: immediate read -> {row}; replayed LSN = {replica_lsn}")          # None: Lucy cannot see her order

    r.execute("SELECT pg_wal_replay_resume()")
    start = time.monotonic()
    while r.execute("SELECT pg_last_wal_replay_lsn() >= %s::pg_lsn", (commit_lsn,)).fetchone()[0] is False:
        time.sleep(0.01)                                               # wait for the replica to reach the LSN
    row = r.execute("SELECT id, status FROM orders WHERE id = 'P-2026-000126'").fetchone()
    print(f"replica: after reaching the LSN ({(time.monotonic() - start) * 1000:.1f} ms) -> {row}")

Output:

replica paused: True
primary: order P-2026-000126 committed; primary LSN = 0/30001F8
replica: immediate read -> None; replayed LSN = 0/3000148
replica: after reaching the LSN (12.3 ms) -> ('P-2026-000126', 'created')

The third line is the read-your-writes violation with its cause in plain sight: the primary is at LSN 0/30001F8 and the replica is still at 0/3000148. And the final loop is the "minimum version on the client" technique of 03-01 with PostgreSQL's real version: the client stores the LSN of its commit and refuses to read from a replica that has not reached it. Many data access libraries and proxies (pgpool, some ORMs) implement exactly this.

Making the replica synchronous

With a parameter change on the fly, the primary starts waiting for the acknowledgement from replica_vlc on every commit:

-- On the primary
ALTER SYSTEM SET synchronous_standby_names = 'replica_vlc';
SELECT pg_reload_conf();
SELECT application_name, sync_state FROM pg_stat_replication;   -- now: sync

synchronous_commit (default on) decides what the primary waits for: remote_write (the follower has received it), on (it has written it to disk: zero loss) or remote_apply (it has replayed it: a later read on the replica already sees it, that is, read-your-writes guaranteed in exchange for maximum latency). For a quorum of several followers, the syntax is ANY 1 (replica_vlc, replica_bcn): the semi-synchronous mode of section 3.

Now run the experiment that reveals the price: stop the replica (docker compose stop orders-db-replica) and try an INSERT on the primary. It hangs: the primary cannot acknowledge without the synchronous replica. It is the "synchronous" row of the table in section 3 in the flesh, and the reason why in production you configure ANY 1 of at least two followers, or accept asynchronous replication with its bounded risk of loss (Ctrl+C on the INSERT, go back to synchronous_standby_names = '' and reload to unblock).

  1. Simulation: W/R quorum with N=3

To finish, leaderless replication in Python: three replicas, writes to all of them with W acknowledgements, reads from R with selection of the highest version, and read repair. One replica, node-c, is temporarily down during the write, which is the case in which the choice of W and R matters.

# km0/simulations/quorum_wr.py
from dataclasses import dataclass, field


class NoQuorum(Exception):
    pass


@dataclass
class Replica:
    name: str
    data: dict[str, tuple[int, int]] = field(default_factory=dict)    # key -> (value, version)
    down: bool = False

    def write(self, key: str, value: int, version: int) -> bool:
        if self.down:
            return False
        current = self.data.get(key, (None, 0))
        if version > current[1]:                      # never go back a version
            self.data[key] = (value, version)
        return True

    def read(self, key: str) -> tuple[int, int] | None:
        return None if self.down else self.data.get(key, (None, 0))


class LeaderlessStore:
    def __init__(self, names: list[str], W: int, R: int):
        self.replicas = {n: Replica(n) for n in names}
        self.N, self.W, self.R = len(names), W, R
        self.version = 0
        print(f"\n=== N={self.N}, W={W}, R={R}  ->  W+R{'>' if W + R > self.N else '<='}N ===")

    def write(self, key: str, value: int) -> None:
        self.version += 1
        acks = [r.name for r in self.replicas.values() if r.write(key, value, self.version)]
        if len(acks) < self.W:
            raise NoQuorum(f"write {key}={value}: only {len(acks)} acks, W={self.W}")
        print(f"write {key}={value} (v{self.version}): acknowledged by {acks} ({len(acks)} >= W={self.W})")

    def read(self, key: str, sources: list[str]) -> int | None:
        """Reads from the given replicas (simulates which ones the coordinator reached first)."""
        responses = {n: self.replicas[n].read(key) for n in sources}
        responses = {n: v for n, v in responses.items() if v is not None}
        if len(responses) < self.R:
            raise NoQuorum(f"read {key}: only {len(responses)} responses, R={self.R}")
        best_name, (value, version) = max(responses.items(), key=lambda kv: kv[1][1])
        repaired = []
        for n, (_, v) in responses.items():                          # read repair
            if v < version and self.replicas[n].write(key, value, version):
                repaired.append(n)
        detail = ", ".join(f"{n}=v{v[1]}" for n, v in responses.items())
        print(f"read {key} from {sources}: [{detail}] -> returns v{version} from {best_name}"
              + (f"; read repair on {repaired}" if repaired else ""))
        return value


def scenario(W: int, R: int) -> None:
    store = LeaderlessStore(["node-a", "node-b", "node-c"], W, R)
    store.write("stock:aged-cheese", 3)                         # initial state, all up to date

    store.replicas["node-c"].down = True
    try:
        store.write("stock:aged-cheese", 2)                     # Anna reserves; node-c does not respond
    except NoQuorum as e:
        print("ERROR:", e)
    store.replicas["node-c"].down = False                       # node-c comes back, but it is behind

    for sources in (["node-c"], ["node-b", "node-c"], ["node-a", "node-c"], ["node-c"]):
        try:
            value = store.read("stock:aged-cheese", sources)
            if value == 3:
                print("   !!! STALE READ: Mark sees 3 units when there are 2 left")
        except NoQuorum as e:
            print("ERROR:", e)


if __name__ == "__main__":
    scenario(W=1, R=1)
    scenario(W=2, R=1)
    scenario(W=2, R=2)

Output:

=== N=3, W=1, R=1  ->  W+R<=N ===
write stock:aged-cheese=3 (v1): acknowledged by ['node-a', 'node-b', 'node-c'] (3 >= W=1)
write stock:aged-cheese=2 (v2): acknowledged by ['node-a', 'node-b'] (2 >= W=1)
read stock:aged-cheese from ['node-c']: [node-c=v1] -> returns v1 from node-c
   !!! STALE READ: Mark sees 3 units when there are 2 left
read stock:aged-cheese from ['node-b', 'node-c']: [node-b=v2, node-c=v1] -> returns v2 from node-b; read repair on ['node-c']
read stock:aged-cheese from ['node-a', 'node-c']: [node-a=v2, node-c=v2] -> returns v2 from node-a
read stock:aged-cheese from ['node-c']: [node-c=v2] -> returns v2 from node-c

=== N=3, W=2, R=1  ->  W+R<=N ===
write stock:aged-cheese=3 (v1): acknowledged by ['node-a', 'node-b', 'node-c'] (3 >= W=2)
write stock:aged-cheese=2 (v2): acknowledged by ['node-a', 'node-b'] (2 >= W=2)
read stock:aged-cheese from ['node-c']: [node-c=v1] -> returns v1 from node-c
   !!! STALE READ: Mark sees 3 units when there are 2 left
read stock:aged-cheese from ['node-b', 'node-c']: [node-b=v2, node-c=v1] -> returns v2 from node-b; read repair on ['node-c']
read stock:aged-cheese from ['node-a', 'node-c']: [node-a=v2, node-c=v2] -> returns v2 from node-a
read stock:aged-cheese from ['node-c']: [node-c=v2] -> returns v2 from node-c

=== N=3, W=2, R=2  ->  W+R>N ===
write stock:aged-cheese=3 (v1): acknowledged by ['node-a', 'node-b', 'node-c'] (3 >= W=2)
write stock:aged-cheese=2 (v2): acknowledged by ['node-a', 'node-b'] (2 >= W=2)
ERROR: read stock:aged-cheese: only 1 responses, R=2
read stock:aged-cheese from ['node-b', 'node-c']: [node-b=v2, node-c=v1] -> returns v2 from node-b; read repair on ['node-c']
read stock:aged-cheese from ['node-a', 'node-c']: [node-a=v2, node-c=v2] -> returns v2 from node-a
ERROR: read stock:aged-cheese: only 1 responses, R=2

What it shows:

  • With W=1, R=1 and with W=2, R=1 (both W + R ≤ N), Anna's write is acknowledged without node-c, and the first read, which lands only on node-c, returns version 1: Mark sees three cheeses when there are two left. It is a stale read that is legal for that configuration. The second read, which touches node-b and node-c, obtains both versions, returns version 2 and repairs node-c; from then on node-c is up to date and the following reads are correct. With W=1, moreover, the write would have succeeded even if only one replica had responded, with everything that implies for durability.
  • With W=2, R=2 (W + R > N), a read that only gets a response from node-c does not reach R and fails instead of lying, both the first and the last (a real coordinator would ask another replica instead of failing). Any read with two responses necessarily includes node-a or node-b, which have version 2: there is never a stale read. That is the overlap guaranteed by quorum arithmetic, at the price that every read waits for two replicas and that, if two replicas went down, neither writes nor reads would be possible.

This is the mechanism that Cassandra exposes through the consistency levels ONE, QUORUM and ALL, and with which in 04-04 we will configure Kilometre Zero's historical orders store.

Common Mistakes and Tips

  • Adding read replicas without classifying the queries. The "is there stock left?" query that precedes a reservation must not go to an asynchronous replica. Mark in each service's code which reads go to the leader and which go to the replicas, and why.
  • Believing that synchronous replication is "safer" and just switching it on. With a single synchronous follower, its failure blocks every write in the system. If you need zero loss, configure ANY 1 over at least two followers and monitor that they are alive.
  • Trusting automatic failover with no protection against split-brain. An old leader that comes back and keeps accepting writes is worse than an outage. Use a consensus-backed tool (Patroni + etcd) or a fencing token, and rehearse the failover (07-03, 07-06).
  • Forgotten replication slots. A slot whose follower has disappeared retains WAL for ever and fills the primary's disk. Monitor pg_replication_slots and drop the ones that are not in use.
  • Multi-leader with LWW and physical clocks "because that is the default". It is the combination most certain to lose data (03-02). If you need multi-leader, decide the conflict resolution per data type and use version vectors to detect real concurrency.
  • A sloppy quorum without knowing it. Cassandra and Riak have the sloppy quorum and hinted handoff enabled by default in some configurations; W + R > N stops guaranteeing overlap. Read the documentation for your version before reasoning with the arithmetic of section 7.
  • Tip: measure replication lag as a first-class metric (replay_lag, or the LSN difference converted with pg_wal_lsn_diff) and define a threshold above which the load balancer stops sending reads to that replica.
  • Tip: for read-your-writes on PostgreSQL, the commit LSN is the best "version number" there is: it is monotonic, it is already computed and the replicas know exactly where they stand relative to it.

Exercises

Exercise 1: Choosing the replication mode per database

Kilometre Zero has one PostgreSQL database per service. For km0_orders, km0_inventory, km0_payments and the catalogue database, decide between asynchronous, semi-synchronous (ANY 1 of two followers) and remote_apply, state which reads you would send to the replicas and what maximum data loss you accept in a failover. Justify each choice with the concepts of this lesson and of 03-02.

Exercise 2: Read-your-writes with the LSN in orders

Write a function read_order(order_id, min_lsn) for the orders service that receives the LSN of the user's last commit (stored in their session) and returns the order from the replica if it has reached that LSN, or from the primary if not, recording which of the two it used. Use psycopg and the PostgreSQL functions seen in section 9. How does the service obtain and store the LSN after each of the user's writes?

Exercise 3: Sloppy quorum in the simulation

Add a sloppy quorum mode to LeaderlessStore: if a replica is down during the write, an auxiliary node node-h accepts the write with a hint for it, and delivers it when the replica comes back (deliver_hints()). Reproduce the scenario with W=2, R=2 and show that, between node-c coming back and the hint being delivered, a read from node-b and node-c... can it be stale? Reason about what W + R > N does and does not guarantee with a sloppy quorum, and what is gained in exchange.

Solutions

Solution 1:

  • km0_payments: semi-synchronous ANY 1 with synchronous_commit = on. A charge that is recorded and then lost in a failover is money taken with no order (or vice versa); the acceptable loss is zero. Reads to replicas: only reports and accounting reconciliation (harmless lag); the status of a payment always from the primary. It is the "CP" row of 03-02.
  • km0_orders: semi-synchronous ANY 1. An order confirmed to Anna that disappears in the failover, with its order.created already published by the outbox relay, produces an orphan event that inventory and payments would process for a non-existent order. Acceptable loss: zero. Reads to replicas: "my orders" with a minimum LSN (exercise 2) and history more than a day old.
  • km0_inventory: semi-synchronous ANY 1 for reservations (the last unit, 03-02); stock reads for the catalogue can go to asynchronous replicas (the catalogue is already eventual). Acceptable loss: no confirmed reservation; stock.updated events go through the outbox and follow the same reasoning as orders.
  • Catalogue: asynchronous. A price or description change lost in a failover is re-entered by the producer; there is no irreversible event downstream. Acceptable loss: the last few seconds of changes. All reads to replicas, with no LSN (the effective price is fixed in orders).

remote_apply is not worth it in any of them: it doubles write latency to obtain read-your-writes on the replica, which can be had more cheaply with the LSN on the client.

Solution 2:

# km0/services/orders/reads.py
import psycopg

PRIMARY = "host=orders-db-primary dbname=km0_orders user=km0 password=km0"
REPLICA = "host=orders-db-replica dbname=km0_orders user=km0 password=km0"


def write_and_record_lsn(session: dict, sql: str, params: tuple) -> None:
    """Every write by the user stores in their session the LSN reached after the commit."""
    with psycopg.connect(PRIMARY) as conn:
        with conn.transaction():
            conn.execute(sql, params)
        session["lsn"] = conn.execute("SELECT pg_current_wal_lsn()::text").fetchone()[0]


def read_order(order_id: str, min_lsn: str | None) -> tuple[dict | None, str]:
    if min_lsn is not None:
        with psycopg.connect(REPLICA) as r:
            up_to_date = r.execute("SELECT pg_last_wal_replay_lsn() >= %s::pg_lsn", (min_lsn,)).fetchone()[0]
            if up_to_date:
                row = r.execute("SELECT id, customer, status, total FROM orders WHERE id = %s", (order_id,)).fetchone()
                return row, "replica"
    with psycopg.connect(PRIMARY) as p:                     # no LSN or lagging replica: go to the primary
        row = p.execute("SELECT id, customer, status, total FROM orders WHERE id = %s", (order_id,)).fetchone()
        return row, "primary"

The LSN is obtained with pg_current_wal_lsn() after the commit, on the same connection, and stored in the user's session (signed cookie, session Redis or the session token). A user who has never written (min_lsn is None) goes to the primary to be on the safe side, or to the replica if the read tolerates lag; one improvement is to use pg_last_wal_replay_lsn() compared with the LSN in a single query and perform the read only if the replica is up to date, as here. Note that the session's LSN must be the maximum of the LSNs of all the user's writes (monotonic reads), and that a failover to a replica whose history has forked invalidates the earlier LSNs, another reason for semi-synchronous replication.

Solution 3:

Outline of the implementation: in write, when a replica returns False, (key, value, version) is stored in self.hints[replica_name] and the auxiliary node's ack is counted; deliver_hints() goes through the hints and calls write on the replicas that are available again. With W=2, R=2: Anna's write is acknowledged by node-a, node-b and the hint for node-c (three acks, although only two are real replicas). Once node-c is back and before deliver_hints(), a read from node-b and node-c obtains v2 from node-b and v1 from node-c: not stale, because node-b is a real replica that has the write. But imagine that node-b had also been down: the acks would be node-a plus two hints, W=2 would be met, and a later read from node-b and node-c (both back, with no hints delivered) would return v1 with R=2 satisfied: a stale read with W + R > N. What W + R > N guarantees with a sloppy quorum is that the write is on W nodes of some kind (durability) and that it will be delivered to the real replicas when they come back (convergence); what it no longer guarantees is the overlap between the replicas that acknowledged and those that are read. In exchange, the write succeeds even with most of the real replicas down: maximum availability, the AP choice in its purest form.

Conclusion

Replicating means keeping copies of the same data on several nodes to gain availability, latency and read scaling (never write scaling), and this lesson has gone through the three ways of doing it. In leader-follower, all writes go through one node that sends them in order to the others; the choice between synchronous, asynchronous and semi-synchronous decides how much is lost if the leader dies and how much each commit costs, and the followers' replication lag is the mechanical cause of the session anomalies of 03-01, which are avoided by routing to the leader or waiting for the LSN. Failover turns a follower into the leader with three dangers (detection, loss of transactions, split-brain) that we leave for 07-03. In multi-leader, every region accepts writes and the problem is conflict, with a repertoire of solutions ranging from avoiding it to LWW, merges, CRDTs and application-level resolution. In leaderless replication, the arithmetic W + R > N replaces coordination, with read repair and hinted handoff to keep the replicas up to date and the sloppy quorum as a concession to availability. The hands-on part was real: km0_orders now has a PostgreSQL streaming follower created with pg_basebackup -R, observed with pg_stat_replication, paused to watch Lucy fail to find her order, and waited for until the right LSN; and the simulation with N=3 showed Mark reading three cheeses when there were two left whenever W + R ≤ N.

With this we know how to copy the data of one service. But the most important operation at Kilometre Zero, creating an order, no longer touches a single database: it inserts into km0_orders, deducts in km0_inventory and charges in km0_payments, three databases belonging to three services, each replicated as we have just seen, and no PostgreSQL transaction spans all three. If the charge fails after the stock has been deducted, who puts it back? If orders crashes between the deduction and the charge, what state is Mark's order left in? What the monolith solved with a BEGIN and a COMMIT now needs an atomic commit protocol or, more commonly in microservices, a saga with compensations. That is the subject of the last lesson of the module: distributed transactions and sagas.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved