Skip to content
About
Main content

Closed-loop vs. open-loop, and coordinated omission (45 minutes)

Measure your machineNeeds Python

Problem

A generator has a fixed number of users, each waiting for its reply before sending again; when the server slows, the users send less, so the offered load falls with the service and the it reports stays mild (: rate = users ÷ response time). Real traffic is open-loop: requests keep arriving whether or not the server keeps up, and a slowdown becomes a . A related bias, , hides stalls: a client that measures each request from when it was actually sent, rather than when it was due, records one slow sample for a long freeze instead of every request the freeze delayed.

Start a small server (its own process, 20 workers) that answers in 40 ms and switches to 400 ms halfway through. Test it with a closed-loop client (20 users) and an open-loop client at 250 requests per second. Compute with Little's Law what rate the closed-loop test achieves at each service time and compare. Then add a one-second freeze to a fast server and compare the measured from the actual send time with the p99 measured from the intended send time, at 1,000 requests per second.

The question. What rate and latency does each client report when the server slows, and how many slow samples does each measurement of the freeze record?


Predict

During a one-second freeze at 1,000 requests a second, how many slow samples does the tool record when it times from the actual send?

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.

    closed-loop-vs-open-loop-and.py135 linesDownload
    View the code
    closed-loop-vs-open-loop-and.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Closed loop against open loop, and coordinated omission.
    
    A small server (its own process) handles each request in 40 ms with a fixed
    number of workers, and slows to 400 ms halfway through. A closed-loop client
    (a fixed number of users, each waiting for its reply) and an open-loop client
    (a fixed arrival rate) test it. Then the server freezes once for one second
    while a rate-limited client measures latency two ways: from the actual send
    (no correction, like wrk) and from the intended send time (like wrk2).
    """
    import argparse, asyncio, json, os, subprocess, sys, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--users", type=int, default=20, help="Closed-loop users (and server workers).")
    p.add_argument("--fast-ms", type=float, default=40, help="Service time before the slowdown.")
    p.add_argument("--slow-ms", type=float, default=400, help="Service time after the slowdown.")
    p.add_argument("--rate", type=float, default=250, help="Open-loop arrival rate (requests/s).")
    p.add_argument("--phase", type=float, default=6, help="Seconds before and after the slowdown.")
    p.add_argument("--freeze-rate", type=float, default=1000, help="Request rate during the freeze test.")
    p.add_argument("--port", type=int, default=58123, help="Local port for the server.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    SERVER = r"""
    import asyncio, sys, time
    port, workers, fast, slow, switch_after, freeze_at = int(sys.argv[1]), int(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5]), float(sys.argv[6])
    t0 = None; sem = asyncio.Semaphore(workers); froze = False
    async def handle(rid, w):
        global froze
        async with sem:                                   # a fixed pool of workers
            el = time.monotonic() - t0
            if freeze_at >= 0 and el >= freeze_at and not froze:
                froze = True; time.sleep(1.0)             # one blocking one-second freeze
            await asyncio.sleep((fast if switch_after < 0 or el < switch_after else slow) / 1000)
        w.write(rid + b"\n")
    async def conn(r, w):
        global t0
        while line := await r.readline():
            if t0 is None: t0 = time.monotonic()
            asyncio.ensure_future(handle(line.strip(), w))
    async def main():
        s = await asyncio.start_server(conn, "127.0.0.1", port); print("ready", flush=True)
        async with s: await s.serve_forever()
    asyncio.run(main())
    """
    
    def server(workers, fast, slow, switch_after, freeze_at=-1):
        pr = subprocess.Popen([sys.executable, "-c", SERVER, str(args.port), str(workers), str(fast), str(slow), str(switch_after), str(freeze_at)],
                              stdout=subprocess.PIPE, text=True)
        pr.stdout.readline(); return pr
    
    class Client:
        async def open(self):
            self.r, self.w = await asyncio.open_connection("127.0.0.1", args.port); self.waiting, self.n = {}, 0
            self.task = asyncio.ensure_future(self.read())
        async def read(self):
            while line := await self.r.readline():
                self.waiting.pop(line.strip()).set_result(time.monotonic())
        def send(self):
            self.n += 1; rid = str(self.n).encode(); fut = asyncio.get_running_loop().create_future()
            self.waiting[rid] = fut; self.w.write(rid + b"\n"); return fut
    
    def pct(xs, q):
        xs = sorted(xs); return xs[min(len(xs) - 1, int(q * len(xs)))] if xs else None
    
    async def closed():
        c = Client(); await c.open(); t0 = time.monotonic(); end = t0 + 2 * args.phase; done = []
        async def user():
            while time.monotonic() < end:
                s = time.monotonic(); r = await c.send(); done.append((s - t0, r - s))
        await asyncio.gather(*(user() for _ in range(args.users))); return done
    
    async def opened():
        c = Client(); await c.open(); t0 = time.monotonic(); done = []; futs = []
        n = int(2 * args.phase * args.rate)
        async def one(intended, fut): r = await fut; done.append((intended - t0, r - intended))
        for i in range(n):
            intended = t0 + i / args.rate
            await asyncio.sleep(max(0, intended - time.monotonic()))
            futs.append(asyncio.ensure_future(one(intended, c.send())))
        deadline = time.monotonic() + 30
        while len(done) < n and time.monotonic() < deadline: await asyncio.sleep(0.05)   # let the queue drain (bounded)
        return done
    
    async def freeze():
        c = Client(); await c.open(); t0 = time.monotonic(); raw, corrected = [], []
        for i in range(int(2 * args.phase * args.freeze_rate)):
            intended = t0 + i / args.freeze_rate
            await asyncio.sleep(max(0, intended - time.monotonic()))
            s = time.monotonic(); r = await c.send()            # one request in flight, like a single wrk connection
            raw.append(r - s); corrected.append(r - intended)
        return raw, corrected
    
    def per_second(done, value):
        out = {}
        for t, v in done: out.setdefault(int(t), []).append(v)
        return out
    
    rows = []
    srv = server(args.users, args.fast_ms, args.slow_ms, args.phase)
    try: cl = asyncio.run(closed())
    finally: srv.kill(); srv.wait()
    srv = server(args.users, args.fast_ms, args.slow_ms, args.phase)
    try: op = asyncio.run(opened())
    finally: srv.kill(); srv.wait()
    cps, ops = per_second(cl, 0), per_second(op, 0)
    for sec in range(int(2 * args.phase)):
        rows.append({"second": sec, "closed_rate": len(cps.get(sec, [])),
                     "closed_p99_ms": (pct([v for _, v in cl if int(_) == sec], .99) or 0) * 1000,
                     "open_p99_ms": (pct([v for _, v in op if int(_) == sec], .99) or 0) * 1000})
    little = [{"phase": f"service time {ms:g} ms", "predicted_rate": args.users / (ms / 1000),
               "measured_rate": sum(len(cps.get(s, [])) for s in rng) / len(rng)}
              for ms, rng in ((args.fast_ms, range(1, int(args.phase))), (args.slow_ms, range(int(args.phase) + 1, int(2 * args.phase))))]
    summary = {"closed_p99_ms": pct([v for _, v in cl], .99) * 1000, "open_p99_ms": pct([v for _, v in op], .99) * 1000,
               "open_max_ms": max(v for _, v in op) * 1000, "open_completed": len(op), "open_sent": int(2 * args.phase * args.rate)}
    
    srv = server(1000, 0, 0, -1, freeze_at=args.phase)                 # no service time: only the freeze
    try: raw, corr = asyncio.run(freeze())
    finally: srv.kill(); srv.wait()
    fz = [{"measurement": m, "samples": len(xs), "slow_samples": sum(x > 0.1 for x in xs),
           "p99_ms": pct(xs, .99) * 1000, "p999_ms": pct(xs, .999) * 1000, "max_ms": max(xs) * 1000}
          for m, xs in (("from actual send (uncorrected)", raw), ("from intended send (corrected)", corr))]
    
    for r in little: print(f"closed loop, {r['phase']}: Little's Law {r['predicted_rate']:.0f}/s, measured {r['measured_rate']:.0f}/s")
    print(f"p99 over the whole run: closed loop {summary['closed_p99_ms']:.0f} ms, open loop {summary['open_p99_ms']:.0f} ms (max {summary['open_max_ms']:.0f} ms)")
    for r in fz: print(f"freeze, {r['measurement']:<31} {r['slow_samples']:>5} samples over 100 ms, p99 {r['p99_ms']:.1f} ms, max {r['max_ms']:.0f} ms")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"timeline": rows, "little": little, "freeze": fz}}, 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 closed-loop-vs-open-loop-and.py
    uv run closed-loop-vs-open-loop-and.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--users20 Closed-loop users (and server workers).
--fast-ms40 Service time before the slowdown.
--slow-ms400 Service time after the slowdown.
--rate250 Open-loop arrival rate (requests/s).
--phase6 Seconds before and after the slowdown.
--freeze-rate1000 Request rate during the freeze test.
--port58,123 Local port for the server.

Our run

p99 latency per second (service time rises at second 6)Apple M2 · 8 cores · 8 GB
Closed loopOpen loop
Data table
p99 latency per second (service time rises at second 6)
SecondClosed loopOpen loop
043.441.5
142.941.4
24341.3
343.141.3
443.141.4
543.141.6
64044,250
74048,110
840312.3K
940316.2K
1040420.4K
1140324.2K
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Closed loop against Little's Law

Closed loop against Little's Law
PhasePredicted rate (/s)Measured (/s)
service time 40 ms500468
service time 400 ms5048

A one-second freeze

A one-second freeze
Latency measuredSamples over 100 msp99 (ms)
from actual send (uncorrected)10.483
from intended send (corrected)1,040912
Output · uv run closed-loop-vs-open-loop-and.py
closed loop, service time 40 ms: Little's Law 500/s, measured 468/s
closed loop, service time 400 ms: Little's Law 50/s, measured 48/s
p99 over the whole run: closed loop 403 ms, open loop 23889 ms (max 24228 ms)
freeze, from actual send (uncorrected)      1 samples over 100 ms, p99 0.5 ms, max 1004 ms
freeze, from intended send (corrected)   1037 samples over 100 ms, p99 912.1 ms, max 1004 ms

What we found

The closed-loop rate followed Little's Law, from 468 to 48 requests per second, and its p99 over the whole run was 403 ms: the slowdown reads as a mild latency increase. The open-loop client saw a p99 of 23.9K ms, with requests waiting up to 24.2K ms: the queue grew for as long as the slowdown lasted. For the freeze, measuring from the actual send recorded 1 slow sample and a p99 of 0.483 ms; measuring from the intended send recorded 1.04K and a p99 of 912 ms.


Read it

A closed-loop test measures a system with a polite load that backs off when it struggles. That is the right model for a fixed pool of workers and the wrong one for users on the internet. Measure latency from when each request should have been sent, or a freeze shows up as one bad sample instead of a thousand.