Anomaly rate equals window times rate
Run a real systemNeeds Docker, Python
Problem
The first lab counted operations inside a window; this one measures the anomalies themselves on a real replica. A user writes a row at rate λ and keeps reading it from a replica that lags by W. A read is stale whenever a write happened within the last W, so the share of stale reads should be about λ × W while that is small, and level off as it approaches 1. Shrinking W is the lever: route the user's reads to the primary for a short time after each of their writes, and the window that matters to that user is zero.
Using the same setup, vary the artificial lag W (20 ms, 200 ms, 2 s) while sending a fixed rate λ of write-then-read pairs. Count stale reads per second. Plot against λ x W. Then shrink W by routing reads to the primary for 2 s after a write and watch the count fall to zero for that user.
The script writes at λ = 0.2 per second (Poisson) for 60 seconds per lag and reads every 10 ms.
The question. How does the share of stale reads grow with the replica lag W, and what happens when the user's reads go to the primary for 2 s after each write?
Predict
Expected: a straight line through the origin, because the number of operations inside the window is λ x W (Little's rule).
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
anomaly-rate-equals-window-times-rate.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Anomaly rate equals window times rate. A user writes a row at a steady rate lambda on the primary and keeps reading it from a replica that lags by W. The share of stale reads should be about lambda x W, as long as that is well below 1. Then the user's reads go to the primary for 2 s after each write. Needs the primary and replica from the compose file (ports 55472, 55473). """ import argparse, json, random, threading, time import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--primary", default="postgresql://postgres:lab@localhost:55472/postgres", help="Primary connection string.") p.add_argument("--replica", default="postgresql://postgres:lab@localhost:55473/postgres", help="Replica connection string.") p.add_argument("--rate", type=float, default=0.2, help="lambda: writes per second (Poisson).") p.add_argument("--lags-ms", type=lambda s: [int(v) for v in s.split(",")], default=[20, 200, 2000], help="Replica lags W to try (recovery_min_apply_delay), comma-separated.") p.add_argument("--seconds", type=float, default=60, help="Seconds of traffic per lag.") p.add_argument("--read-every", type=float, default=0.01, help="Seconds between the user's reads.") p.add_argument("--sticky", type=float, default=2.0, help="Seconds the user's reads go to the primary after a write, in the fix.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) pri = psycopg.connect(args.primary, autocommit=True) rep = psycopg.connect(args.replica, autocommit=True) pri.execute("DROP TABLE IF EXISTS cart"); pri.execute("CREATE TABLE cart (id int PRIMARY KEY, n int)") pri.execute("INSERT INTO cart VALUES (1, 0)") def phase(lag_ms, sticky): rep.execute(f"ALTER SYSTEM SET recovery_min_apply_delay = '{lag_ms}ms'"); rep.execute("SELECT pg_reload_conf()") time.sleep(lag_ms / 1000 + 1) w = psycopg.connect(args.primary, autocommit=True) state = {"n": pri.execute("SELECT n FROM cart").fetchone()[0], "last_write": 0.0} stop = threading.Event() def writer(): # Poisson writes at rate lambda while not stop.wait(random.expovariate(args.rate)): state["n"] = w.execute("UPDATE cart SET n = n + 1 WHERE id = 1 RETURNING n").fetchone()[0] state["last_write"] = time.time() th = threading.Thread(target=writer); th.start() reads = stale = 0; t0 = time.time() while time.time() - t0 < args.seconds: expected = state["n"] use_primary = sticky and time.time() - state["last_write"] < args.sticky got = (pri if use_primary else rep).execute("SELECT n FROM cart").fetchone()[0] reads += 1; stale += got < expected time.sleep(args.read_every) stop.set(); th.join(); w.close() return {"lag_ms": lag_ms, "lambda_W": args.rate * lag_ms / 1000, "reads": reads, "stale": stale, "stale_share": stale / reads, "stale_per_s": stale / args.seconds, "sticky": "yes" if sticky else "no"} rows = [] for lag in args.lags_ms: r = phase(lag, False); rows.append(r) print(f"W = {lag:>5} ms lambda*W = {r['lambda_W']:.3f} stale reads {r['stale']:>5} of {r['reads']} " f"= {r['stale_share']:.3f} of reads ({r['stale_per_s']:.2f}/s)") fix = phase(args.lags_ms[-1], True) print(f"W = {args.lags_ms[-1]} ms, reads to the primary for {args.sticky:g} s after each write: " f"{fix['stale']} stale of {fix['reads']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"lags": rows, "summary": [{"sticky_stale": fix["stale"], "sticky_reads": fix["reads"], "rate": args.rate, "max_lag_ms": args.lags_ms[-1]}]}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
anomaly-rate-equals-window-times-rate.compose.yml # A PostgreSQL primary and one streaming replica, cloned with pg_basebackup when it starts. configs: hba: content: | local all all trust host all all all trust host replication all all trust services: primary: image: postgres:17.6 environment: POSTGRES_PASSWORD: lab POSTGRES_HOST_AUTH_METHOD: trust command: ["postgres", "-c", "wal_level=replica", "-c", "hba_file=/etc/pg_hba.conf"] configs: [{ source: hba, target: /etc/pg_hba.conf }] ports: ["55472:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 30 replica: image: postgres:17.6 user: postgres depends_on: primary: { condition: service_healthy } entrypoint: ["bash", "-c", "pg_basebackup -h primary -U postgres -D /tmp/replica -R -X stream && chmod 700 /tmp/replica && exec postgres -D /tmp/replica -c hot_standby=on -c hba_file=/etc/pg_hba.conf"] configs: [{ source: hba, target: /etc/pg_hba.conf }] ports: ["55473:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 604Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f anomaly-rate-equals-window-times-rate.compose.yml up -d --wait uv run anomaly-rate-equals-window-times-rate.py uv run anomaly-rate-equals-window-times-rate.py --help # every knob docker compose -f anomaly-rate-equals-window-times-rate.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --primarypostgresql://postgres:lab@localhost:55472/postgres | Primary connection string. |
| --replicapostgresql://postgres:lab@localhost:55473/postgres | Replica connection string. |
| --rate0.2 | lambda: writes per second (Poisson). |
| --lags-ms20, 200, 2000 | Replica lags W to try (recovery_min_apply_delay), comma-separated. |
| --seconds60 | Seconds of traffic per lag. |
| --read-every0.01 | Seconds between the user's reads. |
| --sticky2 | Seconds the user's reads go to the primary after a write, in the fix. |
| --seed0 | Random seed. |
Our run
Data table
| λ × W | Stale share of reads (measured) | λ × W |
|---|---|---|
| 0.004 | 0.003 | 0.004 |
| 0.04 | 0.02 | 0.04 |
| 0.4 | 0.276 | 0.4 |
W = 20 ms lambda*W = 0.004 stale reads 14 of 4170 = 0.003 of reads (0.23/s)
W = 200 ms lambda*W = 0.040 stale reads 86 of 4217 = 0.020 of reads (1.43/s)
W = 2000 ms lambda*W = 0.400 stale reads 1160 of 4198 = 0.276 of reads (19.33/s)
W = 2000 ms, reads to the primary for 2 s after each write: 0 stale of 4196What we found
The stale share grew with λ × W across two orders of magnitude, from 0.003 at 20 ms to 0.276 at 2 s, but it sat below λ × W rather than on it: about half to three quarters of it at each point. Part of that is the curve bending at the top, since the stale share can never exceed 1 and follows 1 − e^(−λW). Part is that a write is not visible to the reader's expected value until its statement returns, which shortens the window the reader can observe. It is a line through the origin to within a factor, not exactly. Routing the user's reads to the primary for 2 s after each write left 0 stale reads out of 4,200, as expected.
Read it
Anomalies in flight ≈ conflicting rate × window. The replica lag is the window here, and it is the term you can attack: keep lag low and alert on it, or take the user out of the window entirely by sending their reads to the primary right after they write.
Sticky routing needs no token in the request, so it survives the infrastructure changes that break tokens, but it costs primary capacity in proportion to how much your users write and then read.