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.
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
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.
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 itspg_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.
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
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.
| Criterion | Physical | Logical |
|---|---|---|
| Ships | Log records of pages | Row changes |
| Copies | The whole server | Chosen tables |
| Versions | Same major version | May differ |
| The copy | Read-only | Writable |
| Schema changes | Included | Not replicated |
| Used for | Failover, read replicas | Upgrades, 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)returnsTruewhen every set ofrreplicas shares at least one replica with every set ofwreplicas out ofn.tolerated(n, w, r)returns a pair: how many of thenreplicas may be unreachable while writes still collectwacknowledgements, and how many while reads still reachrreplicas.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 leasttoken.replayedmaps 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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?
-
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?
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,
onwaits until the synchronous standby has flushed the commit,remote_applyuntil 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
- PostgreSQL 18 Documentation: 26.2 Log-Shipping Standby Servers (streaming and synchronous replication) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: 19.5 Write Ahead Log (synchronous_commit) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: 19.6 Replication (synchronous_standby_names, recovery_min_apply_delay) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Chapter 29 Logical Replication (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: 29.1 Publication (replica identity) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: 29.8 Restrictions of logical replication (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: pg_basebackup (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: pg_rewind (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: 9.28 System Administration Functions (WAL positions) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Chapter 54 Frontend/Backend Protocol (The PostgreSQL Global Development Group)
- G. DeCandia et al.: Dynamo: Amazon's Highly Available Key-value Store (SOSP 2007) (ACM)
Report a problem with this lesson
Kept only in this browser. Your Learn progress