Skip to content
About
Main content

Anomaly rate equals window times rate

Run a real systemNeeds Docker, Python

Problem

The first lab counted operations inside a ; this one measures the anomalies themselves on a real . 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 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

At 2 s of replica lag, where does the share of stale reads sit against λ × W?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    anomaly-rate-equals-window-times-rate.py74 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    anomaly-rate-equals-window-times-rate.compose.yml33 linesDownload
    View 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: 60
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

Share of stale reads against λ × WApple M2 · 8 cores · 8 GB
Stale share of reads (measured)λ × W
Data table
Share of stale reads against λ × W
λ × WStale share of reads (measured)λ × W
0.0040.0030.004
0.040.020.04
0.40.2760.4
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20
Output · uv run anomaly-rate-equals-window-times-rate.py
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 4196

What 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.

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.