Skip to content
About
Main content

Measure the Dekker pattern and the price of r + w ≥ d

Simulate a modelBreak it on purposeNeeds Python

Problem

means all clients see operations in one order consistent with each client's own program order. Between two regions a message takes d to arrive, and that sets a floor: a read's latency r plus a write's latency w must be at least d, or a client can act on information the other region has not received. The Dekker test shows it. A writes x = 1 then reads y; B writes y = 1 then reads x. In any single order, at least one of them must see the other's 1. With local reads and local writes, both can read 0.

Simulate two "regions" as two threads or two processes with a message delay d between them (for example time.sleep(0.08) per message). Each holds a copy of x and y, both 0. Run the Dekker test: client A writes x = 1 then reads y; client B writes y = 1 then reads x. First use local reads and local writes: count how often both reads return 0 (it should be nearly every run). Then make writes wait for an acknowledgement from the other copy and count again (it should drop to 0). Record the latency of read and write in each variant and check that r + w is at least d in every variant that never shows the forbidden outcome.

The script also tries a write that sleeps d (plus 5 ms) instead of waiting for the acknowledgement.

The question. How often do both clients read 0 with local operations, with a write that sleeps d, and with a write that waits for the other copy's acknowledgement, and what is r + w in each?


Predict

With the write sleeping d before it returns, in how many of 100 runs do both clients still read 0?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    measure-the-dekker-pattern-and-the.py65 linesDownload
    View the code
    measure-the-dekker-pattern-and-the.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Measure the Dekker pattern and the latency floor.
    
    Two "regions" with a one-way message delay d. Client A writes x = 1 then
    reads y; client B writes y = 1 then reads x. With local reads and writes,
    both can read 0, which no single copy could produce; making the write wait
    for the other copy removes it, and the latency it costs is at least d.
    """
    import argparse, json, threading, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--delay", type=float, default=0.08, help="d: one-way message delay between the regions, seconds.")
    p.add_argument("--runs", type=int, default=100, help="Runs per variant.")
    p.add_argument("--margin", type=float, default=0.005, help="Seconds a waiting write adds to d, so it does not race the message arriving at the same instant.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    D = args.delay
    
    
    def trial(mode):
        rep = {"A": {"x": 0, "y": 0}, "B": {"x": 0, "y": 0}}
        acked = {"A": threading.Event(), "B": threading.Event()}
        def send(to, frm, k, v):                           # asynchronous replication with an ack back after d
            def deliver():
                rep[to][k] = v
                threading.Timer(D, acked[frm].set).start()
            threading.Timer(D, deliver).start()
        out, lat = {}, {}
        def client(me, other, mine, theirs):
            t0 = time.perf_counter()
            rep[me][mine] = 1; send(other, me, mine, 1)
            if mode == "sync write": acked[me].wait()      # the write returns once the other copy has it (2d round trip)
            if mode == "write waits d": time.sleep(D + args.margin)   # the write returns after one delay
            t1 = time.perf_counter()
            out[me] = rep[me][theirs]
            lat[me] = (t1 - t0, time.perf_counter() - t1)
        a = threading.Thread(target=client, args=("A", "B", "x", "y"))
        b = threading.Thread(target=client, args=("B", "A", "y", "x"))
        a.start(); b.start(); a.join(); b.join()
        time.sleep(2.2 * D)                                # let in-flight messages land before the next trial
        return (out["A"], out["B"]), lat
    
    
    rows = []
    for mode in ("local", "write waits d", "sync write"):
        both_zero, w, r = 0, [], []
        for _ in range(args.runs):
            res, lat = trial(mode)
            both_zero += res == (0, 0)
            for wl, rl in lat.values(): w.append(wl); r.append(rl)
        rows.append({"variant": mode, "both_zero": both_zero, "both_zero_pct": both_zero / args.runs * 100,
                     "write_ms": sum(w) / len(w) * 1000, "read_ms": sum(r) / len(r) * 1000,
                     "r_plus_w_ms": (sum(w) + sum(r)) / len(w) * 1000})
        x = rows[-1]
        print(f"{mode:<14} (0,0) in {both_zero:>3} of {args.runs} runs   write {x['write_ms']:6.1f} ms  read {x['read_ms']:5.2f} ms  "
              f"r+w {x['r_plus_w_ms']:6.1f} ms  (d = {D * 1000:.0f} ms)")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"variants": rows, "summary": [{"d_ms": D * 1000}]}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run measure-the-dekker-pattern-and-the.py
    uv run measure-the-dekker-pattern-and-the.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--delay0.08 d: one-way message delay between the regions, seconds.
--runs100 Runs per variant.
--margin0.005 Seconds a waiting write adds to d, so it does not race the message arriving at the same instant.

Our run

The Dekker test, 100 runs each, d = 80 ms

The Dekker test, 100 runs each, d = 80 ms
WriteRuns with (0, 0)w (ms)r (ms)r + w (ms)
local1000.1980.0020.199
write waits d591.70.00391.7
sync write01740.003174
Reference runApple M2 · 8 cores · 8 GB
Output · uv run measure-the-dekker-pattern-and-the.py
local          (0,0) in 100 of 100 runs   write    0.2 ms  read  0.00 ms  r+w    0.2 ms  (d = 80 ms)
write waits d  (0,0) in   5 of 100 runs   write   91.7 ms  read  0.00 ms  r+w   91.7 ms  (d = 80 ms)
sync write     (0,0) in   0 of 100 runs   write  173.9 ms  read  0.00 ms  r+w  173.9 ms  (d = 80 ms)
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

With local reads and writes, both clients read 0 in 100 of 100 runs, with r + w of 0.199 ms, as expected. Waiting for the other copy's acknowledgement removed it completely (0 runs), at r + w = 174 ms: a full , comfortably above d.

Sleeping d inside the write did not quite remove it: 5 runs still showed (0, 0), even with r + w = 91.7 ms. A sleep is not an acknowledgement. Under load, a message can take longer than d to be applied, and a write that only waits for the expected delay returns before the other copy has the value. r + w ≥ d is necessary, not sufficient: the write has to know the other side has it.


Read it

r + w ≥ d: local reads and local writes together are impossible for sequential consistency; the network delay is a conserved total. A design chooses where to pay it. Reads in Singapore can be local only if writes pay the cross-region delay, or the other way round.

The failed variant is the general lesson about timing: a protocol that assumes a delay bound is correct only while the bound holds. Waiting for an acknowledgement replaces an assumption about time with a fact about order, and that is the version that survives a slow network.