Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 15 – Distributed and cloud databases

Replication: leaders, followers and quorums

Compare single-leader, multi-leader and leaderless replication, time synchronous commits on PostgreSQL 18.6, and see when quorum reads go stale.

  • Intermediate
  • 35 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 35 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Compare single-leader, multi-leader and leaderless replication
  • Explain synchronous and asynchronous replication and the anomalies that replication lag causes
  • Configure quorum reads and writes (R + W > N) and state what they do not guarantee
  • Distinguish PostgreSQL physical streaming replication from logical replication

Before you start

What you’ll learn · Before you start

What you will learn

  • Compare single-leader, multi-leader and leaderless replication
  • Explain synchronous and asynchronous replication and the anomalies that replication lag causes
  • Configure quorum reads and writes (R + W > N) and state what they do not guarantee
  • Distinguish PostgreSQL physical streaming replication from logical replication

Before you start

On this page

Replication keeps copies of the same data on several servers, called replicas. It does three jobs: the data survives the loss of a server, reads can be spread over more machines, and a copy can sit close to the people who read it. Every replicated database settles two questions. Which replicas accept writes? With a single leader, one server takes every write and streams it to followers; with several leaders, each one accepts writes and they exchange them; a leaderless system sends each write to several replicas and counts the replies. And when is a write done? A synchronous commit waits until a replica has the change, which costs at least one network round trip; an asynchronous commit returns at once and is lost if the leader crashes before sending it. This lesson measures both on a PostgreSQL 18.6 primary and standby whose link takes 10 ms for a round trip, then simulates quorums, where R + W > N decides whether a read can miss a write.

Three ways to organise the copies

Three designs: a leader streaming its log to followers, two leaders swapping changes, a coordinator writing to three replicas.Single leaderMulti-leaderLeaderlessN = 3, W = 2, R = 2All writesLeaderorders every writeFollowerFollowerLeader Aregion 1 writesLeader Bregion 2 writesCoordinatorReplica 1Replica 2Replica 3loglogchanges,both waysackacklate

Single-leader, multi-leader and leaderless replication

Text description of the diagram

The figure has three panels, from top to bottom.

1. Single leader: every write goes to the leader, which puts the writes in one order and streams its log to two followers. Each follower replays the log in the same order. 2. Multi-leader: leader A takes the writes of region 1 and leader B those of region 2. They send each other their changes in both directions, so the same row can be changed in both places at once. 3. Leaderless with N = 3, W = 2 and R = 2: a coordinator, acting for the client, sends the write to all three replicas. Replicas 1 and 2 acknowledge it and the write counts as done; replica 3 receives it later.

With a single leader (PostgreSQL calls it the primary and its copies standbys), every write goes to one server. It puts all changes in one order, writes them to its log and sends the log to the followers, which replay it in that order. Because one server orders everything, followers never disagree about which write came first; a follower can only be behind. The weak point is the leader itself. When it fails, a follower is promoted, and any write the old leader had confirmed but not yet sent is gone. If the old leader comes back still acting as leader, two servers accept writes at once, a split brain, so failover tools first make sure the old leader can no longer accept writes, which is called fencing it.

With multiple leaders, several servers accept writes, typically one per data centre, or a device that works offline and syncs later, and each forwards its changes to the others. Writes are fast everywhere, but two leaders can change the same row at the same moment and both changes are accepted before either leader hears of the other. The system then needs a rule for every such conflict: keep the write with the later timestamp, merge the two values, or hand both versions to the application.

In a leaderless design, which the Dynamo paper described in 2007 (DeCandia et al.), each key is stored on N replicas. The client, or a coordinator node acting for it, sends a write to every one of them and treats the write as complete once W have acknowledged it; a read asks R replicas and keeps the newest version it receives. No replica is special, so nothing has to be promoted when one fails: the others still form a quorum. Replicas that missed a write catch up when a read notices their old version and the coordinator sends them the new one (read repair), or when replicas compare their contents in the background.

Design Writes go to Conflicts
Single leader The leader None
Multi-leader Any leader Need a rule
Leaderless Any replica Need versions

When does a write count as done?

Replication is synchronous when the leader reports a commit only after a replica confirms that it has the change. The write then survives a crash of the leader. The cost is a network round trip to that replica on every commit, and when the replica stops answering, commits stall until it returns or another replica takes over. Replication is asynchronous when the leader reports the commit as soon as its own log is safe and sends the change afterwards. Commits are as fast as without replicas, and a crash of the leader loses every commit it had not sent. A common middle way makes one replica synchronous and the rest asynchronous, so each commit is on two servers before the client hears of it.

PostgreSQL’s streaming replication is asynchronous by default. Naming standbys in synchronous_standby_names makes it synchronous, and synchronous_commit then sets how far each commit waits (PostgreSQL 18 manual, 19.5):

  • off: the commit waits for nothing.
  • local: it waits until its log record is on the primary’s own disk.
  • remote_write: until the synchronous standbys’ operating system has the record.
  • on, the default: until the record is on the synchronous standbys’ disks.
  • remote_apply: until the synchronous standbys have applied it.

With off, the commit record still reaches disk soon after, at most three times wal_writer_delay later, so a crash of the primary itself can lose the last commits. Only remote_apply promises that queries on the standby see the change.

The setting can change for each transaction, so a payment can wait for the standby while a page-view counter does not. FIRST 2 (s1, s2, s3) in synchronous_standby_names waits for the two connected standbys that come first in the list, and ANY 2 (s1, s2, s3) for any two of the three, a quorum among the standbys (PostgreSQL 18 manual, 26.2.8).

Streaming replication, measured on PostgreSQL 18.6

The program below starts a primary, copies it into a standby with pg_basebackup, and connects the standby through a link that delays every byte by 5 ms in each direction. Both servers run on one laptop, listen only on local sockets and have fsync turned off, so the times come from the link, not from a disk. The helper pglab.py speaks PostgreSQL’s wire protocol itself, so each commit can be timed without starting a program for every query.

A primary, a standby 5 ms away, and a crash

postgres/streaming_lab.py

"""Streaming replication on PostgreSQL 18.6, measured: a primary and a standby whose link adds 5 ms in each direction
(a 10 ms round trip). What each synchronous_commit level adds to a commit, how far behind a read on the standby is,
and what a crash of the primary loses with asynchronous and with synchronous commits."""

import statistics

from pglab import Lab, wait_until

DELAY_MS = 5


def pair(lab, tag, sync):
    """A primary with one table and a streaming standby named standby1 behind a 5 ms link."""
    primary = lab.server(f"p{tag}")
    primary.start()
    primary.sql("CREATE TABLE ledger (id int PRIMARY KEY, note text)")
    link = lab.link(f"l{tag}", primary, DELAY_MS)
    standby = lab.standby(f"s{tag}", primary, primary_at=link.dir, app="standby1")
    standby.start()
    wait_until(lambda: primary.sql("SELECT state FROM pg_stat_replication").value == "streaming")
    if sync:
        primary.sql("ALTER SYSTEM SET synchronous_standby_names = 'standby1'")
        primary.sql("SELECT pg_reload_conf()")
        wait_until(lambda: primary.sql("SELECT sync_state FROM pg_stat_replication").value == "sync")
    return primary, standby, link


def visible(standby, row_id):
    return standby.sql(f"SELECT count(*) FROM ledger WHERE id = {row_id}").value == "1"


with Lab() as lab:
    primary, standby, link = pair(lab, 1, sync=True)
    row = primary.sql("SELECT application_name, state, sync_state FROM pg_stat_replication").rows[0]
    print(f"standby: {row[0]}, state {row[1]}, sync_state {row[2]}")
    print(f"the link adds {DELAY_MS} ms each way, so a round trip takes {2 * DELAY_MS} ms")
    print()

    print("1. What a commit costs, median of 30 single-row commits")
    next_id = 1
    with primary.connect() as c:
        for level in ("off", "local", "remote_write", "on", "remote_apply"):
            c.query(f"SET synchronous_commit = {level}")
            times = []
            for _ in range(30):
                times.append(c.timed(f"INSERT INTO ledger VALUES ({next_id}, 'level {level}')"))
                next_id += 1
            print(f"   {level:<13} {statistics.median(times):5.1f} ms")
    print()

    print("2. Is the new row on the standby straight after the commit returns? (10 commits per level)")

    def reads(level):
        global next_id
        found = 0
        with primary.connect() as c, standby.connect() as r:
            c.query(f"SET synchronous_commit = {level}")
            for _ in range(10):
                c.query(f"INSERT INTO ledger VALUES ({next_id}, 'read check')")
                found += r.value(f"SELECT count(*) FROM ledger WHERE id = {next_id}") == "1"
                next_id += 1
        return found

    idle = {level: reads(level) for level in ("local", "on", "remote_apply")}
    standby.configure(recovery_min_apply_delay="50ms")
    standby.restart()
    wait_until(lambda: primary.sql("SELECT sync_state FROM pg_stat_replication").value == "sync")
    late = {level: reads(level) for level in ("local", "on", "remote_apply")}
    print("   level          standby applies at once   standby applies each commit 50 ms late")
    for level in idle:
        print(f"   {level:<13}  {idle[level]:>2} of 10                  {late[level]:>2} of 10")
    with primary.connect() as c:
        c.query("SET synchronous_commit = remote_apply")
        slow = statistics.median(c.timed(f"INSERT INTO ledger VALUES ({next_id + i}, 'slow')") for i in range(5))
        next_id += 5
    print(f"   with the late standby, a remote_apply commit took {slow:.0f} ms (median of 5)")
    standby.configure(recovery_min_apply_delay="0")
    standby.restart()
    wait_until(lambda: primary.sql("SELECT sync_state FROM pg_stat_replication").value == "sync")
    with primary.connect() as c, standby.connect() as r:
        c.query("SET synchronous_commit = local")
        waits = []
        for _ in range(20):
            c.query(f"INSERT INTO ledger VALUES ({next_id}, 'lag check')")
            _, waited = wait_until(lambda: r.value(f"SELECT count(*) FROM ledger WHERE id = {next_id}") == "1")
            waits.append(waited * 1000)
            next_id += 1
    print(f"   an asynchronous commit reached readers on the standby after {statistics.median(waits):.1f} ms"
          " (median of 20)")
    print()

    print("3. The primary crashes while the link is down")
    for sync in (False, True):
        primary, standby, link = pair(lab, 2 if not sync else 3, sync)
        mode = "synchronous (on)" if sync else "asynchronous (local)"
        with primary.connect() as c:
            c.query("SET synchronous_commit = " + ("on" if sync else "local"))
            for i in range(1, 6):
                c.query(f"INSERT INTO ledger VALUES ({i}, 'before the outage')")
            wait_until(lambda: visible(standby, 5))
            link.stall()
            acknowledged = 0
            for i in range(6, 26):
                c.send(f"INSERT INTO ledger VALUES ({i}, 'during the outage')")
                try:
                    c.wait(timeout=1.0)
                    acknowledged += 1
                except TimeoutError:
                    print(f"   {mode}: commit {i} got no answer within 1 s, so the application does not count it")
                    break
            link.cut()
            primary.stop("immediate")
        standby.promote()
        survived = int(standby.sql("SELECT count(*) FROM ledger WHERE id > 5").value)
        print(f"   {mode}: {acknowledged} commits acknowledged during the outage, {survived} of them on the promoted"
              f" standby: {acknowledged - survived} acknowledged commits lost")
        primary.start()
        extra = int(primary.sql("SELECT count(*) FROM ledger WHERE id > 5").value)
        print(f"   the old primary, restarted on its own, holds {extra} row(s) the new primary does not have")
        primary.stop("immediate")

Output

standby: standby1, state streaming, sync_state sync
the link adds 5 ms each way, so a round trip takes 10 ms

1. What a commit costs, median of 30 single-row commits
   off             0.2 ms
   local           0.1 ms
   remote_write   13.0 ms
   on             12.9 ms
   remote_apply   12.9 ms

2. Is the new row on the standby straight after the commit returns? (10 commits per level)
   level          standby applies at once   standby applies each commit 50 ms late
   local           0 of 10                   0 of 10
   on             10 of 10                   0 of 10
   remote_apply   10 of 10                  10 of 10
   with the late standby, a remote_apply commit took 58 ms (median of 5)
   an asynchronous commit reached readers on the standby after 6.8 ms (median of 20)

3. The primary crashes while the link is down
   asynchronous (local): 20 commits acknowledged during the outage, 0 of them on the promoted standby: 20 acknowledged commits lost
   the old primary, restarted on its own, holds 20 row(s) the new primary does not have
   synchronous (on): commit 6 got no answer within 1 s, so the application does not count it
   synchronous (on): 0 commits acknowledged during the outage, 0 of them on the promoted standby: 0 acknowledged commits lost
   the old primary, restarted on its own, holds 1 row(s) the new primary does not have

This output changes from run to run: the times change from run to run and from one computer to another

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 streaming_lab.py

postgres/pglab.py

"""A small PostgreSQL lab for the examples of this lesson: throwaway PostgreSQL 18.6 servers, a client that speaks the
PostgreSQL wire protocol directly (so the examples can time a single commit without starting a program per query),
standbys made with pg_basebackup, and a link between two servers that adds a fixed delay in each direction or stalls.

Every server lives in a new folder under the run's temporary folder (TMPDIR), listens only on a Unix socket in that
folder (never on a network port), and is stopped and removed at the end. The PostgreSQL 18.6 programs are the ones
installed by mise next to the Python that runs this file. fsync is off: these servers never need to survive a power
cut, so the times the examples print come from replication, not from the disk.
"""

import os
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
from collections import deque
from pathlib import Path

PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"


def _run(args, env=None, check=True):
    done = subprocess.run([str(a) for a in args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
                          env=env)
    if check and done.returncode != 0:
        sys.exit(f"{Path(str(args[0])).name} failed:\n{done.stdout}")
    return done


class Lab:
    """A folder for the lab's servers and links, removed when the lab closes."""

    def __init__(self):
        if not (BIN / "postgres").exists():
            sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="pg"))
        self.servers = []
        self.links = []

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        for link in self.links:
            link.close()
        for s in reversed(self.servers):
            s.stop("immediate", quiet=True)
        shutil.rmtree(self.base, ignore_errors=True)

    def server(self, name, settings=None):
        """A new, empty server (initdb), not started yet."""
        s = Server(self.base / name, settings)
        s.initdb()
        self.servers.append(s)
        return s

    def standby(self, name, source, primary_at=None, app=None, settings=None):
        """A standby of `source`, copied with pg_basebackup; it streams from `primary_at` (a folder whose socket leads
        to the primary: the primary's own, or a Link's) under the application_name `app`."""
        s = Server(self.base / name, settings)
        s.dir.mkdir(parents=True)
        _run([BIN / "pg_basebackup", "-h", source.dir, "-U", "learner", "-D", s.data, "-X", "stream", "-c", "fast",
              "--no-sync"])
        conninfo = f"host={primary_at or source.dir} port=5432 user=learner application_name={app or name}"
        with open(s.data / "postgresql.auto.conf", "a") as f:
            f.write(f"primary_conninfo = '{conninfo}'\n")
        (s.data / "standby.signal").write_text("")
        self.servers.append(s)
        return s

    def link(self, name, to, delay_ms):
        """A link to server `to` whose socket is in a folder of its own, delaying each direction by delay_ms."""
        l = Link(self.base / name, to.dir, delay_ms / 1000)
        self.links.append(l)
        return l


class Server:
    def __init__(self, folder, settings=None):
        self.dir = folder
        self.name = folder.name
        self.data = folder / "data"
        self.settings = {"listen_addresses": "''", "unix_socket_directories": f"'{folder}'", "fsync": "off",
                         "full_page_writes": "off", "synchronous_commit": "on", **(settings or {})}

    def initdb(self):
        self.dir.mkdir(parents=True)
        _run([BIN / "initdb", "-D", self.data, "-U", "learner", "--auth=trust", "--no-instructions", "--no-sync",
              "-E", "UTF8", "--locale=C"])

    def configure(self, **settings):
        """Settings for the next start (or a reload)."""
        for k, v in settings.items():
            self.settings[k] = v

    def _options(self):
        return " ".join(f"-c {k}={v}" for k, v in self.settings.items())

    def start(self):
        done = _run([BIN / "pg_ctl", "-D", self.data, "-o", self._options(), "-l", self.dir / "log", "-w", "start"],
                    check=False)
        if done.returncode != 0:
            sys.exit(f"the server did not start:\n{(self.dir / 'log').read_text()}")

    def stop(self, mode="fast", quiet=False):
        if (self.data / "postmaster.pid").exists():
            _run([BIN / "pg_ctl", "-D", self.data, "-m", mode, "-w", "stop"], check=not quiet)

    def restart(self):
        self.stop("fast")
        self.start()

    def promote(self):
        _run([BIN / "pg_ctl", "-D", self.data, "-w", "promote"])

    def connect(self, app="client", db="postgres"):
        return Conn(self.dir, app=app, db=db)

    def sql(self, statement, db="postgres"):
        """Runs one statement on a connection of its own; returns the Result."""
        with self.connect(db=db) as c:
            return c.query(statement)

    def psql(self, statement, db="postgres"):
        """What psql prints for one statement (for output that looks like a terminal session)."""
        env = {**os.environ, "PGHOST": str(self.dir), "PGUSER": "learner", "PGDATABASE": db}
        return _run([BIN / "psql", "-X", "-c", statement], env=env, check=False).stdout.rstrip("\n")


class PgError(Exception):
    def __init__(self, fields):
        self.fields = fields
        self.code = fields.get("C", "")
        super().__init__(f"{fields.get('S', 'ERROR')}:  {fields.get('M', '')}")


class Result:
    def __init__(self):
        self.columns = []
        self.rows = []
        self.tag = None
        self.error = None
        self.notices = []
        self.status = None

    @property
    def value(self):
        """The first column of the first row (or None)."""
        return self.rows[0][0] if self.rows else None


class Conn:
    """One connection, speaking the PostgreSQL frontend/backend protocol (version 3.0) over the Unix socket."""

    def __init__(self, folder, port=5432, app="client", db="postgres"):
        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.sock.connect(str(Path(folder) / f".s.PGSQL.{port}"))
        self.buf = b""
        params = {"user": "learner", "database": db, "application_name": app, "client_encoding": "UTF8"}
        body = struct.pack("!i", 196608) + b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in params.items())
        body += b"\0"
        self.sock.sendall(struct.pack("!i", len(body) + 4) + body)
        self.pending = 0
        result = self._collect()
        if result.error:
            raise PgError(result.error)

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

    def close(self):
        try:
            self.sock.sendall(b"X" + struct.pack("!i", 4))
        except OSError:
            pass
        self.sock.close()

    def _read(self, n):
        while len(self.buf) < n:
            chunk = self.sock.recv(65536)
            if not chunk:
                raise ConnectionError("the server closed the connection")
            self.buf += chunk
        out, self.buf = self.buf[:n], self.buf[n:]
        return out

    def _message(self):
        kind = self._read(1)
        (length,) = struct.unpack("!i", self._read(4))
        return kind, self._read(length - 4)

    @staticmethod
    def _fields(payload):
        out = {}
        for part in payload.split(b"\0"):
            if part:
                out[chr(part[0])] = part[1:].decode()
        return out

    def _collect(self):
        """Reads messages up to ReadyForQuery."""
        r = Result()
        while True:
            kind, payload = self._message()
            if kind == b"T":
                (n,) = struct.unpack("!h", payload[:2])
                pos, cols = 2, []
                for _ in range(n):
                    end = payload.index(b"\0", pos)
                    cols.append(payload[pos:end].decode())
                    pos = end + 1 + 18
                r.columns = cols
            elif kind == b"D":
                (n,) = struct.unpack("!h", payload[:2])
                pos, row = 2, []
                for _ in range(n):
                    (size,) = struct.unpack("!i", payload[pos:pos + 4])
                    pos += 4
                    if size < 0:
                        row.append(None)
                    else:
                        row.append(payload[pos:pos + size].decode())
                        pos += size
                r.rows.append(tuple(row))
            elif kind == b"C":
                r.tag = payload.rstrip(b"\0").decode()
            elif kind == b"E":
                r.error = self._fields(payload)
            elif kind == b"N":
                r.notices.append(self._fields(payload))
            elif kind == b"Z":
                r.status = payload.decode()
                return r

    def send(self, sql):
        """Sends a query without waiting for its result (wait() reads it)."""
        q = sql.encode() + b"\0"
        self.sock.sendall(b"Q" + struct.pack("!i", len(q) + 4) + q)
        self.pending += 1

    def wait(self, timeout=None):
        """The result of the query sent last; raises TimeoutError when nothing complete arrives within timeout."""
        self.sock.settimeout(timeout)
        try:
            result = self._collect()
        except (socket.timeout, TimeoutError):
            raise TimeoutError from None
        finally:
            self.sock.settimeout(None)
        self.pending -= 1
        return result

    def query(self, sql, check=True):
        self.send(sql)
        r = self.wait()
        if check and r.error:
            raise PgError(r.error)
        return r

    def value(self, sql):
        return self.query(sql).value

    def timed(self, sql):
        """Runs a query and returns how long it took, in milliseconds."""
        t = time.perf_counter()
        self.query(sql)
        return (time.perf_counter() - t) * 1000


class Link:
    """A Unix-socket link to a server that delays every byte by `delay` seconds in each direction, or holds it back
    while stalled. A standby that connects through it behaves as if the primary were that far away."""

    def __init__(self, folder, target_dir, delay):
        folder.mkdir(parents=True)
        self.target = str(Path(target_dir) / ".s.PGSQL.5432")
        self.delay = delay
        self.stalled = threading.Event()
        self.closed = threading.Event()
        self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.listener.bind(str(folder / ".s.PGSQL.5432"))
        self.listener.listen(8)
        self.dir = folder
        self.socks = []
        threading.Thread(target=self._accept, daemon=True).start()

    def stall(self):
        """Holds back everything in both directions, as a network that stops delivering would."""
        self.stalled.set()

    def resume(self):
        self.stalled.clear()

    def cut(self):
        """Breaks every connection through the link; what it was holding back is never delivered."""
        self.closed.set()
        for s in self.socks:
            try:
                s.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass
        self.close()

    def close(self):
        self.closed.set()
        for s in [self.listener, *self.socks]:
            try:
                s.close()
            except OSError:
                pass

    def _accept(self):
        while not self.closed.is_set():
            try:
                client, _ = self.listener.accept()
            except OSError:
                return
            server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            server.connect(self.target)
            self.socks += [client, server]
            for a, b in ((client, server), (server, client)):
                q, cv = deque(), threading.Condition()
                threading.Thread(target=self._read, args=(a, q, cv), daemon=True).start()
                threading.Thread(target=self._write, args=(b, q, cv), daemon=True).start()

    def _read(self, src, q, cv):
        while True:
            try:
                data = src.recv(65536)
            except OSError:
                data = b""
            with cv:
                q.append((time.monotonic() + self.delay, data))
                cv.notify()
            if not data:
                return

    def _write(self, dst, q, cv):
        while True:
            with cv:
                while not q:
                    cv.wait()
                due, data = q[0]
            if self.closed.is_set():
                return
            now = time.monotonic()
            if now < due or self.stalled.is_set():
                time.sleep(max(due - now, 0.0005))
                continue
            with cv:
                q.popleft()
            if not data:
                try:
                    dst.shutdown(socket.SHUT_WR)
                except OSError:
                    pass
                return
            try:
                dst.sendall(data)
            except OSError:
                return


def wait_until(check, timeout=10.0, step=0.0005):
    """Polls check() until it returns something true; returns (value, seconds waited)."""
    start = time.perf_counter()
    while True:
        v = check()
        if v:
            return v, time.perf_counter() - start
        if time.perf_counter() - start > timeout:
            sys.exit("timed out waiting")
        time.sleep(step)

What a commit costs. off and local return in a fraction of a millisecond. The three remote levels all wait about one round trip of the link, 12 to 13 ms here: the 10 ms of the link plus the standby’s own work. On a real network the wait grows with the distance between the servers, and the manual states the floor: the round-trip time between primary and standby.

What a reader on the standby sees. After a local commit the new row was never on the standby when read straight away: it was still crossing the link. on made it visible in this run only because the idle standby replayed each change as soon as it had written it. Once the standby was told to apply each commit 50 ms late, with recovery_min_apply_delay, an on commit still returned after one round trip while the row stayed invisible, and only remote_apply made every read find the row, at the price of a commit that waited for the replay. on promises that the standby has the change; remote_apply promises that the standby shows it.

What a crash loses. With the link stalled, the asynchronous primary confirmed 20 more commits. After it crashed and the standby was promoted, all 20 were gone, although each client had been told its commit succeeded. The synchronous primary confirmed nothing during the outage: the first commit waited, so the application never counted it. That commit was not safe either. The old primary had written it to its own log, and restarted on its own, it held a row the new primary lacked; the manual says the same of commits that were waiting when a primary restarts. So a failed primary never rejoins as it is: it is rebuilt from the new primary, with pg_rewind or a fresh base backup, and it must not accept writes in the meantime, or the two copies drift apart for good.

What lag does to readers

An asynchronous standby is always a little behind: about 7 ms in the run above, and far more under heavy load or over long distances. Three anomalies follow from the lag, each with a known remedy.

  • Read-your-writes. A user saves a change on the primary, the next page is read from a standby that has not replayed it, and the change seems lost. Read from the primary for a while after a write, or route by log position: keep the position of the user’s last commit, from pg_current_wal_lsn() on the primary, and read from a standby only once its pg_last_wal_replay_lsn() has reached it (PostgreSQL 18 manual, 9.28). The exercise below writes that check.
  • Monotonic reads. Two reads in a row go to two standbys, the second further behind than the first, and data seems to travel back in time. Keep each user’s session on one standby.
  • Consistent prefix. A reader sees an answer before the question it answers, because the two writes reached it by different paths, as happens when they were made at different leaders or stored in different partitions. Send writes that depend on each other through one log, or make the reader wait for what a write depends on.

For its synchronous standbys, remote_apply removes the first anomaly at the cost measured above; the manual notes that in simple cases it allows load balancing with causal consistency.

Quorums: R + W > N

In a leaderless store each key lives on N replicas, a write waits for W acknowledgements and a read asks R replicas. When R + W > N, every read set shares at least one replica with every write set, because together they name more replicas than exist, so each read reaches a replica that holds the latest acknowledged write, and the newest version wins. N = 3 with W = 2 and R = 2 is the usual choice: reads and writes both keep working with one replica down. The simulation below checks the rule on 20,000 reads per setting, then looks at three things the rule leaves out.

Quorums, a sloppy quorum and concurrent writes, simulated JavaScript · quorum_sim.mjs
// Leaderless quorums, simulated: N replicas, a write that W replicas have acknowledged, and a read that asks R
// replicas and keeps the highest version it sees. The seed is fixed, so every run prints the same numbers.

function rng(seed) {
  // mulberry32: a small, fast generator with a 32-bit state.
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** k distinct replica numbers out of 0..n-1. */
function pick(random, n, k) {
  const all = [...Array(n).keys()];
  for (let i = n - 1; i > 0; i--) {
    const j = Math.floor(random() * (i + 1));
    [all[i], all[j]] = [all[j], all[i]];
  }
  return all.slice(0, k);
}

function choose(n, k) {
  let c = 1;
  for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1);
  return c;
}

const TRIALS = 20000;

/** Fraction of reads that miss an acknowledged write: simulated, and from the formula C(N-W, R) / C(N, R). */
function staleShare(n, w, r, random) {
  let stale = 0;
  for (let t = 0; t < TRIALS; t++) {
    const has = new Set(pick(random, n, w)); // the replicas that acknowledged version 1
    const asked = pick(random, n, r);
    if (!asked.some((x) => has.has(x))) stale++; // every replica asked still holds version 0
  }
  return { simulated: stale / TRIALS, exact: r <= n - w ? choose(n - w, r) / choose(n, r) : 0 };
}

const random = rng(2026);
console.log('1. Reads that miss an acknowledged write (20,000 reads per row)');
console.log('   N  W  R  R+W>N  simulated  formula');
for (const [n, w, r] of [
  [3, 1, 1],
  [3, 2, 1],
  [3, 1, 2],
  [3, 2, 2],
  [3, 3, 1],
  [5, 2, 3],
  [5, 3, 3],
  [5, 1, 1],
]) {
  const s = staleShare(n, w, r, random);
  const pct = (x) => `${(x * 100).toFixed(1)} %`.padStart(7);
  console.log(`   ${n}  ${w}  ${r}  ${String(r + w > n).padEnd(5)}  ${pct(s.simulated)}  ${pct(s.exact)}`);
}

// 2. A sloppy quorum: N = 3, W = 2, R = 2, but during an outage two of the key's three home replicas are
// unreachable, so the write is accepted by the one home replica left and by a stand-in node outside the set.
// Before the stand-in hands the write back, a read of two home replicas misses it whenever it skips the one.
{
  const homes = 3;
  let stale = 0;
  for (let t = 0; t < TRIALS; t++) {
    const holder = Math.floor(random() * homes); // the only home replica that has the write
    const asked = pick(random, homes, 2);
    if (!asked.includes(holder)) stale++;
  }
  console.log();
  console.log('2. A sloppy quorum: W = 2 counted a stand-in node, so only 1 of the 3 home replicas has the write');
  console.log(`   reads of 2 home replicas that miss it: ${((stale / TRIALS) * 100).toFixed(1)} % (formula 33.3 %)`);
}

// 3. Two clients write the same key at the same moment, each through a different coordinator. With last write wins
// the write with the larger timestamp survives; a clock 3 ms fast on one coordinator decides which one that is.
{
  const writes = [
    { client: 'A', value: 'cart = [book]', realTime: 10.0, clockSkew: 0 },
    { client: 'B', value: 'cart = [pen]', realTime: 11.0, clockSkew: -3 },
  ].map((x) => ({ ...x, stamp: x.realTime + x.clockSkew }));
  const kept = writes.reduce((a, b) => (b.stamp > a.stamp ? b : a));
  console.log();
  console.log('3. Concurrent writes with last write wins (timestamps from the coordinators\' clocks)');
  for (const x of writes) console.log(`   ${x.client} wrote "${x.value}" at real time ${x.realTime} ms, stamped ${x.stamp} ms`);
  console.log(`   kept: "${kept.value}"; the later write by real time was dropped without an error`);
}

// 4. N = 3, W = 2, R = 2 and a write still in flight: it has reached replica 0 only. Two reads in a row, each asking
// two replicas, can see the new value and then the old one, although R + W > N.
{
  const version = [1, 0, 0];
  const read = (asked) => Math.max(...asked.map((i) => version[i]));
  console.log();
  console.log('4. One write in flight (on replica 0 only), N = 3, W = 2, R = 2');
  console.log(`   read 1 asks replicas 0 and 1: version ${read([0, 1])}`);
  console.log(`   read 2 asks replicas 1 and 2: version ${read([1, 2])}`);
  console.log('   the second read returned an older version than the first: quorums alone are not linearizable');
}

Output

1. Reads that miss an acknowledged write (20,000 reads per row)
   N  W  R  R+W>N  simulated  formula
   3  1  1  false   66.5 %   66.7 %
   3  2  1  false   33.4 %   33.3 %
   3  1  2  false   33.9 %   33.3 %
   3  2  2  true     0.0 %    0.0 %
   3  3  1  true     0.0 %    0.0 %
   5  2  3  false    9.7 %   10.0 %
   5  3  3  true     0.0 %    0.0 %
   5  1  1  false   80.5 %   80.0 %

2. A sloppy quorum: W = 2 counted a stand-in node, so only 1 of the 3 home replicas has the write
   reads of 2 home replicas that miss it: 34.0 % (formula 33.3 %)

3. Concurrent writes with last write wins (timestamps from the coordinators' clocks)
   A wrote "cart = [book]" at real time 10 ms, stamped 10 ms
   B wrote "cart = [pen]" at real time 11 ms, stamped 8 ms
   kept: "cart = [book]"; the later write by real time was dropped without an error

4. One write in flight (on replica 0 only), N = 3, W = 2, R = 2
   read 1 asks replicas 0 and 1: version 1
   read 2 asks replicas 1 and 2: version 0
   the second read returned an older version than the first: quorums alone are not linearizable

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node quorum_sim.mjs

Whenever R + W ≤ N, some reads miss the write, as often as the formula C(N − W, R) ÷ C(N, R) predicts; whenever R + W > N, none do. The three cases after the table are what the rule does not cover:

  • Sloppy quorums. To stay writable during an outage, a Dynamo-style store lets a write count acknowledgements from stand-in nodes outside the key’s home replicas, which keep the write and hand it back later (hinted handoff). Until they do, only one home replica holds the write, and reads of two home replicas missed it a third of the time.
  • Concurrent writes. Two clients writing one key at the same moment both reach a quorum. Last write wins keeps the version with the larger timestamp, so a coordinator clock running 3 ms slow decided which write survived, and the other vanished without an error. Dynamo instead tags versions with vector clocks, keeps versions that neither clock orders, and hands them to the application to merge.
  • Writes in flight. While a write has reached only one replica, one read can see it and the next read miss it, even with R + W > N. A quorum returns the newest acknowledged write; it does not put all reads into one order, so on its own it does not make the store behave like a single up-to-date copy.

A write that failed, because fewer than W replicas acknowledged it, is not undone on the replicas that did take it, so a later read may still return it.

Physical or logical replication in PostgreSQL

Physical streaming replication sends the write-ahead log itself, the record of changes to data pages, so a standby is an exact copy of the whole server: every database, table and index. Both sides need the same major version and the same hardware architecture, and a standby answers read-only queries (hot standby, on by default). It is the tool for failover and for read replicas. Logical replication sends changes as rows of named tables, from a publication on the publisher to a subscription on the subscriber (PostgreSQL 18 manual, chapter 29). The subscriber is an ordinary database that can hold other tables and indexes and accept its own writes, and the two sides may run different major versions, which makes logical replication a way to move to a new version or to copy only some tables. The manual lists what it does not carry: schema changes, sequence values and large objects. Updates and deletes need a replica identity, normally the primary key, so the subscriber can find the row they change.

A publication and a subscription on PostgreSQL 18.6

postgres/logical_lab.py

"""Logical replication on PostgreSQL 18.6: a publisher and a subscriber that is an ordinary, writable database.
Rows travel as changes to named tables; a table without a key cannot publish updates, and a column added on the
publisher alone stops replication until the subscriber has it too."""

from pglab import Lab, wait_until


def show(server, statement, shown=None):
    """Prints a statement and what psql printed for it, as a terminal session would."""
    print(f"{server.name}=> {shown or statement}")
    print(server.psql(statement))
    print()


with Lab() as lab:
    pub = lab.server("pub", {"wal_level": "logical"})
    sub = lab.server("sub", {"wal_retrieve_retry_interval": "200ms"})
    pub.name, sub.name = "publisher", "subscriber"
    pub.start()
    sub.start()
    for s in (pub, sub):
        s.sql("CREATE TABLE accounts (id int PRIMARY KEY, holder text, balance int)")
        s.sql("CREATE TABLE visits (page text, seen int)")
    pub.sql("INSERT INTO accounts VALUES (1, 'Asha', 500), (2, 'Ravi', 300)")

    show(pub, "CREATE PUBLICATION shop FOR TABLE accounts, visits;")
    subscribe = "CREATE SUBSCRIPTION shop_sub CONNECTION 'host={} user=learner dbname=postgres' PUBLICATION shop;"
    # The publisher's socket folder is a temporary path of this computer: the session shows a placeholder instead.
    show(sub, subscribe.format(pub.dir), shown=subscribe.format("<publisher socket folder>"))
    wait_until(lambda: sub.sql("SELECT count(*) FROM accounts").value == "2")
    show(sub, "SELECT * FROM accounts ORDER BY id;")

    show(pub, "UPDATE accounts SET balance = balance - 100 WHERE id = 1;")
    wait_until(lambda: sub.sql("SELECT balance FROM accounts WHERE id = 1").value == "400")
    show(sub, "SELECT * FROM accounts ORDER BY id;")

    show(pub, "INSERT INTO visits VALUES ('/home', 1);")
    show(pub, "UPDATE visits SET seen = 2;")

    show(sub, "CREATE TABLE notes (account_id int, note text);")
    show(sub, "CREATE INDEX accounts_holder ON accounts (holder);")

    show(pub, "ALTER TABLE accounts ADD COLUMN branch text;")
    show(pub, "INSERT INTO accounts VALUES (3, 'Meena', 900, 'Pune');")
    wait_until(lambda: sub.sql("SELECT apply_error_count > 0 FROM pg_stat_subscription_stats").value == "t")
    log = (sub.dir / "log").read_text().splitlines()
    error = next(line for line in log if "missing replicated column" in line)
    print("the subscriber's log says:")
    print("  " + error[error.index("ERROR:"):])
    print()
    show(sub, "SELECT count(*) FROM accounts;")
    show(sub, "ALTER TABLE accounts ADD COLUMN branch text;")
    wait_until(lambda: sub.sql("SELECT count(*) FROM accounts").value == "3")
    show(sub, "SELECT * FROM accounts ORDER BY id;")

Output

publisher=> CREATE PUBLICATION shop FOR TABLE accounts, visits;
CREATE PUBLICATION

subscriber=> CREATE SUBSCRIPTION shop_sub CONNECTION 'host=<publisher socket folder> user=learner dbname=postgres' PUBLICATION shop;
NOTICE:  created replication slot "shop_sub" on publisher
CREATE SUBSCRIPTION

subscriber=> SELECT * FROM accounts ORDER BY id;
 id | holder | balance 
----+--------+---------
  1 | Asha   |     500
  2 | Ravi   |     300
(2 rows)

publisher=> UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE 1

subscriber=> SELECT * FROM accounts ORDER BY id;
 id | holder | balance 
----+--------+---------
  1 | Asha   |     400
  2 | Ravi   |     300
(2 rows)

publisher=> INSERT INTO visits VALUES ('/home', 1);
INSERT 0 1

publisher=> UPDATE visits SET seen = 2;
ERROR:  cannot update table "visits" because it does not have a replica identity and publishes updates
HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.

subscriber=> CREATE TABLE notes (account_id int, note text);
CREATE TABLE

subscriber=> CREATE INDEX accounts_holder ON accounts (holder);
CREATE INDEX

publisher=> ALTER TABLE accounts ADD COLUMN branch text;
ALTER TABLE

publisher=> INSERT INTO accounts VALUES (3, 'Meena', 900, 'Pune');
INSERT 0 1

the subscriber's log says:
  ERROR:  logical replication target relation "public.accounts" is missing replicated column: "branch"

subscriber=> SELECT count(*) FROM accounts;
 count 
-------
     2
(1 row)

subscriber=> ALTER TABLE accounts ADD COLUMN branch text;
ALTER TABLE

subscriber=> SELECT * FROM accounts ORDER BY id;
 id | holder | balance | branch 
----+--------+---------+--------
  1 | Asha   |     400 | 
  2 | Ravi   |     300 | 
  3 | Meena  |     900 | Pune
(3 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 logical_lab.py

postgres/pglab.py

"""A small PostgreSQL lab for the examples of this lesson: throwaway PostgreSQL 18.6 servers, a client that speaks the
PostgreSQL wire protocol directly (so the examples can time a single commit without starting a program per query),
standbys made with pg_basebackup, and a link between two servers that adds a fixed delay in each direction or stalls.

Every server lives in a new folder under the run's temporary folder (TMPDIR), listens only on a Unix socket in that
folder (never on a network port), and is stopped and removed at the end. The PostgreSQL 18.6 programs are the ones
installed by mise next to the Python that runs this file. fsync is off: these servers never need to survive a power
cut, so the times the examples print come from replication, not from the disk.
"""

import os
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
from collections import deque
from pathlib import Path

PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"


def _run(args, env=None, check=True):
    done = subprocess.run([str(a) for a in args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
                          env=env)
    if check and done.returncode != 0:
        sys.exit(f"{Path(str(args[0])).name} failed:\n{done.stdout}")
    return done


class Lab:
    """A folder for the lab's servers and links, removed when the lab closes."""

    def __init__(self):
        if not (BIN / "postgres").exists():
            sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="pg"))
        self.servers = []
        self.links = []

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        for link in self.links:
            link.close()
        for s in reversed(self.servers):
            s.stop("immediate", quiet=True)
        shutil.rmtree(self.base, ignore_errors=True)

    def server(self, name, settings=None):
        """A new, empty server (initdb), not started yet."""
        s = Server(self.base / name, settings)
        s.initdb()
        self.servers.append(s)
        return s

    def standby(self, name, source, primary_at=None, app=None, settings=None):
        """A standby of `source`, copied with pg_basebackup; it streams from `primary_at` (a folder whose socket leads
        to the primary: the primary's own, or a Link's) under the application_name `app`."""
        s = Server(self.base / name, settings)
        s.dir.mkdir(parents=True)
        _run([BIN / "pg_basebackup", "-h", source.dir, "-U", "learner", "-D", s.data, "-X", "stream", "-c", "fast",
              "--no-sync"])
        conninfo = f"host={primary_at or source.dir} port=5432 user=learner application_name={app or name}"
        with open(s.data / "postgresql.auto.conf", "a") as f:
            f.write(f"primary_conninfo = '{conninfo}'\n")
        (s.data / "standby.signal").write_text("")
        self.servers.append(s)
        return s

    def link(self, name, to, delay_ms):
        """A link to server `to` whose socket is in a folder of its own, delaying each direction by delay_ms."""
        l = Link(self.base / name, to.dir, delay_ms / 1000)
        self.links.append(l)
        return l


class Server:
    def __init__(self, folder, settings=None):
        self.dir = folder
        self.name = folder.name
        self.data = folder / "data"
        self.settings = {"listen_addresses": "''", "unix_socket_directories": f"'{folder}'", "fsync": "off",
                         "full_page_writes": "off", "synchronous_commit": "on", **(settings or {})}

    def initdb(self):
        self.dir.mkdir(parents=True)
        _run([BIN / "initdb", "-D", self.data, "-U", "learner", "--auth=trust", "--no-instructions", "--no-sync",
              "-E", "UTF8", "--locale=C"])

    def configure(self, **settings):
        """Settings for the next start (or a reload)."""
        for k, v in settings.items():
            self.settings[k] = v

    def _options(self):
        return " ".join(f"-c {k}={v}" for k, v in self.settings.items())

    def start(self):
        done = _run([BIN / "pg_ctl", "-D", self.data, "-o", self._options(), "-l", self.dir / "log", "-w", "start"],
                    check=False)
        if done.returncode != 0:
            sys.exit(f"the server did not start:\n{(self.dir / 'log').read_text()}")

    def stop(self, mode="fast", quiet=False):
        if (self.data / "postmaster.pid").exists():
            _run([BIN / "pg_ctl", "-D", self.data, "-m", mode, "-w", "stop"], check=not quiet)

    def restart(self):
        self.stop("fast")
        self.start()

    def promote(self):
        _run([BIN / "pg_ctl", "-D", self.data, "-w", "promote"])

    def connect(self, app="client", db="postgres"):
        return Conn(self.dir, app=app, db=db)

    def sql(self, statement, db="postgres"):
        """Runs one statement on a connection of its own; returns the Result."""
        with self.connect(db=db) as c:
            return c.query(statement)

    def psql(self, statement, db="postgres"):
        """What psql prints for one statement (for output that looks like a terminal session)."""
        env = {**os.environ, "PGHOST": str(self.dir), "PGUSER": "learner", "PGDATABASE": db}
        return _run([BIN / "psql", "-X", "-c", statement], env=env, check=False).stdout.rstrip("\n")


class PgError(Exception):
    def __init__(self, fields):
        self.fields = fields
        self.code = fields.get("C", "")
        super().__init__(f"{fields.get('S', 'ERROR')}:  {fields.get('M', '')}")


class Result:
    def __init__(self):
        self.columns = []
        self.rows = []
        self.tag = None
        self.error = None
        self.notices = []
        self.status = None

    @property
    def value(self):
        """The first column of the first row (or None)."""
        return self.rows[0][0] if self.rows else None


class Conn:
    """One connection, speaking the PostgreSQL frontend/backend protocol (version 3.0) over the Unix socket."""

    def __init__(self, folder, port=5432, app="client", db="postgres"):
        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.sock.connect(str(Path(folder) / f".s.PGSQL.{port}"))
        self.buf = b""
        params = {"user": "learner", "database": db, "application_name": app, "client_encoding": "UTF8"}
        body = struct.pack("!i", 196608) + b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in params.items())
        body += b"\0"
        self.sock.sendall(struct.pack("!i", len(body) + 4) + body)
        self.pending = 0
        result = self._collect()
        if result.error:
            raise PgError(result.error)

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

    def close(self):
        try:
            self.sock.sendall(b"X" + struct.pack("!i", 4))
        except OSError:
            pass
        self.sock.close()

    def _read(self, n):
        while len(self.buf) < n:
            chunk = self.sock.recv(65536)
            if not chunk:
                raise ConnectionError("the server closed the connection")
            self.buf += chunk
        out, self.buf = self.buf[:n], self.buf[n:]
        return out

    def _message(self):
        kind = self._read(1)
        (length,) = struct.unpack("!i", self._read(4))
        return kind, self._read(length - 4)

    @staticmethod
    def _fields(payload):
        out = {}
        for part in payload.split(b"\0"):
            if part:
                out[chr(part[0])] = part[1:].decode()
        return out

    def _collect(self):
        """Reads messages up to ReadyForQuery."""
        r = Result()
        while True:
            kind, payload = self._message()
            if kind == b"T":
                (n,) = struct.unpack("!h", payload[:2])
                pos, cols = 2, []
                for _ in range(n):
                    end = payload.index(b"\0", pos)
                    cols.append(payload[pos:end].decode())
                    pos = end + 1 + 18
                r.columns = cols
            elif kind == b"D":
                (n,) = struct.unpack("!h", payload[:2])
                pos, row = 2, []
                for _ in range(n):
                    (size,) = struct.unpack("!i", payload[pos:pos + 4])
                    pos += 4
                    if size < 0:
                        row.append(None)
                    else:
                        row.append(payload[pos:pos + size].decode())
                        pos += size
                r.rows.append(tuple(row))
            elif kind == b"C":
                r.tag = payload.rstrip(b"\0").decode()
            elif kind == b"E":
                r.error = self._fields(payload)
            elif kind == b"N":
                r.notices.append(self._fields(payload))
            elif kind == b"Z":
                r.status = payload.decode()
                return r

    def send(self, sql):
        """Sends a query without waiting for its result (wait() reads it)."""
        q = sql.encode() + b"\0"
        self.sock.sendall(b"Q" + struct.pack("!i", len(q) + 4) + q)
        self.pending += 1

    def wait(self, timeout=None):
        """The result of the query sent last; raises TimeoutError when nothing complete arrives within timeout."""
        self.sock.settimeout(timeout)
        try:
            result = self._collect()
        except (socket.timeout, TimeoutError):
            raise TimeoutError from None
        finally:
            self.sock.settimeout(None)
        self.pending -= 1
        return result

    def query(self, sql, check=True):
        self.send(sql)
        r = self.wait()
        if check and r.error:
            raise PgError(r.error)
        return r

    def value(self, sql):
        return self.query(sql).value

    def timed(self, sql):
        """Runs a query and returns how long it took, in milliseconds."""
        t = time.perf_counter()
        self.query(sql)
        return (time.perf_counter() - t) * 1000


class Link:
    """A Unix-socket link to a server that delays every byte by `delay` seconds in each direction, or holds it back
    while stalled. A standby that connects through it behaves as if the primary were that far away."""

    def __init__(self, folder, target_dir, delay):
        folder.mkdir(parents=True)
        self.target = str(Path(target_dir) / ".s.PGSQL.5432")
        self.delay = delay
        self.stalled = threading.Event()
        self.closed = threading.Event()
        self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.listener.bind(str(folder / ".s.PGSQL.5432"))
        self.listener.listen(8)
        self.dir = folder
        self.socks = []
        threading.Thread(target=self._accept, daemon=True).start()

    def stall(self):
        """Holds back everything in both directions, as a network that stops delivering would."""
        self.stalled.set()

    def resume(self):
        self.stalled.clear()

    def cut(self):
        """Breaks every connection through the link; what it was holding back is never delivered."""
        self.closed.set()
        for s in self.socks:
            try:
                s.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass
        self.close()

    def close(self):
        self.closed.set()
        for s in [self.listener, *self.socks]:
            try:
                s.close()
            except OSError:
                pass

    def _accept(self):
        while not self.closed.is_set():
            try:
                client, _ = self.listener.accept()
            except OSError:
                return
            server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            server.connect(self.target)
            self.socks += [client, server]
            for a, b in ((client, server), (server, client)):
                q, cv = deque(), threading.Condition()
                threading.Thread(target=self._read, args=(a, q, cv), daemon=True).start()
                threading.Thread(target=self._write, args=(b, q, cv), daemon=True).start()

    def _read(self, src, q, cv):
        while True:
            try:
                data = src.recv(65536)
            except OSError:
                data = b""
            with cv:
                q.append((time.monotonic() + self.delay, data))
                cv.notify()
            if not data:
                return

    def _write(self, dst, q, cv):
        while True:
            with cv:
                while not q:
                    cv.wait()
                due, data = q[0]
            if self.closed.is_set():
                return
            now = time.monotonic()
            if now < due or self.stalled.is_set():
                time.sleep(max(due - now, 0.0005))
                continue
            with cv:
                q.popleft()
            if not data:
                try:
                    dst.shutdown(socket.SHUT_WR)
                except OSError:
                    pass
                return
            try:
                dst.sendall(data)
            except OSError:
                return


def wait_until(check, timeout=10.0, step=0.0005):
    """Polls check() until it returns something true; returns (value, seconds waited)."""
    start = time.perf_counter()
    while True:
        v = check()
        if v:
            return v, time.perf_counter() - start
        if time.perf_counter() - start > timeout:
            sys.exit("timed out waiting")
        time.sleep(step)

The table without a primary key took the insert but refused the update on the publisher itself, with the hint to set a replica identity. The subscriber accepted a table and an index of its own. The column added on the publisher alone stopped replication: the subscriber’s apply worker failed with the error in its log, and the third account arrived only after the subscriber had the column too. The manual’s advice follows: make additive schema changes on the subscriber first.

Physical streaming replication or logical replication
Criterion PhysicalLogical
Ships Log records of pagesRow changes
Copies The whole serverChosen tables
Versions Same major versionMay differ
The copy Read-onlyWritable
Schema changes IncludedNot replicated
Used for Failover, read replicasUpgrades, partial copies

Exercise

Exercise · Medium · Python

Check quorums and route reads by replay position

Write five functions in quorum_rules.py.

  • read_meets_write(n, w, r) returns True when every set of r replicas shares at least one replica with every set of w replicas out of n.
  • tolerated(n, w, r) returns a pair: how many of the n replicas may be unreachable while writes still collect w acknowledgements, and how many while reads still reach r replicas.
  • lsn_to_int(lsn) turns a PostgreSQL log position such as '16/B374D848' into a byte number: the part before the slash is the high 32 bits and the part after it the low 32 bits, both in hexadecimal.
  • lag_bytes(primary_lsn, replica_lsn) returns how many bytes of log the replica has still to replay.
  • fresh_replicas(token, replayed) returns, sorted by name, the replicas whose replayed position is at least token. replayed maps a replica's name to its position, for example {'s1': '0/3000148'}. A read routed to one of them sees every commit up to the token.

The sample tests use three replica sets and positions on both sides of a 4 GB boundary of the log.

Starter code · quorum_rules.py

"""Quorum rules and read routing by replay position.

A log position is PostgreSQL's text form of a WAL location, such as '16/B374D848': the high 32 bits, a slash, and
the low 32 bits, both in hexadecimal.
"""


def read_meets_write(n, w, r):
    """True when every set of r replicas out of n shares a replica with every set of w."""
    # Replace this line with your code.
    return False


def tolerated(n, w, r):
    """(replicas that may be down for writes, replicas that may be down for reads)."""
    # Replace this line with your code.
    return (0, 0)


def lsn_to_int(lsn):
    """The byte number of a log position such as '16/B374D848'."""
    # Replace this line with your code.
    return 0


def lag_bytes(primary_lsn, replica_lsn):
    """Bytes of log the replica has not replayed yet."""
    # Replace this line with your code.
    return 0


def fresh_replicas(token, replayed):
    """Names of the replicas whose replayed position is at least token, sorted."""
    # Replace this line with your code.
    return []
The sample tests · test_quorum_rules.py
from quorum_rules import fresh_replicas, lag_bytes, lsn_to_int, read_meets_write, tolerated


def test_overlap_needs_more_than_n():
    """two sets drawn from n replicas must share one exactly when their sizes add up to more than n"""
    assert read_meets_write(3, 2, 2) is True
    assert read_meets_write(3, 1, 2) is False
    assert read_meets_write(5, 3, 3) is True
    assert read_meets_write(5, 2, 3) is False
    assert read_meets_write(3, 3, 1) is True


def test_failures_each_side_tolerates():
    """writes wait for w replicas and reads ask r, so n - w and n - r may be unreachable"""
    assert tolerated(3, 2, 2) == (1, 1)
    assert tolerated(5, 3, 3) == (2, 2)
    assert tolerated(3, 3, 1) == (0, 2)


def test_lsn_text_to_bytes():
    """the high half counts units of 2**32 bytes"""
    assert lsn_to_int("0/0") == 0
    assert lsn_to_int("0/3000148") == 0x3000148
    assert lsn_to_int("1/0") == 2**32
    assert lsn_to_int("16/B374D848") == 0x16 * 2**32 + 0xB374D848


def test_lag_across_a_4_gb_boundary():
    """positions on both sides of the high half's boundary still subtract correctly"""
    assert lag_bytes("1/10", "0/FFFFFFF0") == 0x20
    assert lag_bytes("0/3000148", "0/3000148") == 0
    assert lag_bytes("0/5000000", "0/4FFF000") == 4096


def test_routing_by_token():
    """a replica qualifies when it has replayed at least up to the token"""
    replayed = {"s3": "0/3000060", "s1": "0/3000148", "s2": "0/2FFFFF0"}
    assert fresh_replicas("0/3000060", replayed) == ["s1", "s3"]
    assert fresh_replicas("0/3000149", replayed) == []
    assert fresh_replicas("0/0", replayed) == ["s1", "s2", "s3"]
A hint

Two sets drawn from the same n replicas must overlap when their sizes add up to more than n: there are not enough replicas to keep them apart. For the positions, split the text at the slash and read each half with int(part, 16); the high half counts units of 2 to the power 32 bytes.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A key is stored on N = 5 replicas and a write waits for W = 3 acknowledgements. What is the smallest R for which every read reaches at least one replica that has the latest acknowledged write?

    Type a number.

    Show the answer to question 1

    Answer: 3 replicas

    The read and write sets must overlap, so R + W must be greater than N: R + 3 > 5 gives R = 3. With R = 2, a read could ask the two replicas that the write has not reached yet.

  2. Question 2 of 5 Which synchronous_commit level makes a commit visible to queries on the synchronous standby before the client is told the commit succeeded?

    Choose one answer.

    Show the answer to question 2

    Answer: remote_apply

    remote_apply waits until the standby has replayed the commit, so queries there see it. on waits only until the standby has flushed the commit to disk: in the lesson's run the row stayed invisible for 50 ms after an on commit once the standby applied commits late.

  3. Question 3 of 5 An asynchronous primary confirms 20 commits while its link to the standby is down, then crashes, and the standby is promoted. What happened to the 20 commits?

    Choose one answer.

    Show the answer to question 3

    Answer: They are lost, although every client was told its commit succeeded

    An asynchronous commit is confirmed once the primary's own log is safe. Changes that never left it are not on the standby, so promoting the standby loses them. The old primary still holds them, which is why it must be rebuilt before it can follow the new primary.

  4. Question 4 of 5 Which of these does PostgreSQL 18's logical replication not carry from the publisher to the subscriber?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • ALTER TABLE … ADD COLUMN
    • The current value of a sequence

    The manual lists schema changes (DDL), sequence data and large objects among the restrictions; rows and TRUNCATE are replicated. A column added only on the publisher stops replication until the subscriber has it too.

  5. Question 5 of 5 The primary's log is at position 0/3001000 and a standby has replayed up to 0/3000000. How many bytes of log does the standby still have to replay?

    Type a number.

    Show the answer to question 5

    Answer: 4096 bytes

    Both positions have the same high half (0), so the difference is 0x3001000 − 0x3000000 = 0x1000, which is 4,096 bytes. PostgreSQL computes the same with pg_wal_lsn_diff().

Interview questions

Fresher · What is the difference between synchronous and asynchronous replication?

With synchronous replication the leader reports a commit only after at least one replica confirms that it has the change, so the write survives a crash of the leader. The price is time and availability: every commit waits at least one round trip to that replica, and if it stops answering, commits wait too, unless another synchronous replica can take its place. With asynchronous replication the leader confirms as soon as its own log is safe and ships the change afterwards, so commits are fast, but a crash of the leader loses whatever it had not sent, although the clients were told it committed. PostgreSQL is asynchronous by default; naming standbys in synchronous_standby_names makes it synchronous, and synchronous_commit chooses, per transaction, whether to wait for the standby to write, flush or apply the change.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A PostgreSQL primary crashed and its asynchronous standby was promoted. Customers report orders confirmed in the last few seconds that are now missing, and the old primary's disk still holds them. What happened, how do you get the orders back, and what do you change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A key-value store runs with N = 3, W = 2 and R = 2. A user refreshes a page twice and sees a new value, then an older one. How can that happen when R + W > N, and what would you change?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A single leader orders every write; multiple leaders and leaderless replicas accept writes in several places and must resolve conflicting versions.
  • A synchronous commit costs at least one round trip to its replica and survives a crash of the leader; an asynchronous commit is fast and loses whatever the leader had not sent when it crashed.
  • In PostgreSQL, on waits until the synchronous standby has flushed the commit, remote_apply until queries there see it.
  • A failed primary is rebuilt before it rejoins; two servers that both accept writes drift apart.
  • R + W > N makes every read meet the latest acknowledged write; sloppy quorums, concurrent writes and writes in flight are outside that promise.
  • Physical replication copies the whole server byte for byte; logical replication copies rows of chosen tables and leaves schema changes to you.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.