Skip to content
About
Main content

Throughput versus goodput past the knee

Simulate a modelNeeds Python

Problem

Past full load, a fills, and every request waits longer than the one before. Once the wait exceeds the client's timeout, the server is still busy, so looks fine, but it is answering requests nobody is waiting for: falls to zero. A bounded queue caps the wait so that served requests are still wanted; a deadline check at dequeue discards requests that have already expired instead of spending capacity on them.

Simulate a service that completes 100 requests per second, receives 150 per second, and whose clients give up after 2 seconds. Compare (a) an unbounded queue, (b) a bounded queue (200), (c) a bounded queue plus a deadline check at dequeue.

The question. What goodput does each queue deliver over a minute of overload?


Predict

Over a minute at 150 requests a second against a capacity of 100, what goodput does the unbounded queue deliver?

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.

    throughput-versus-goodput-past-the-knee.py50 linesDownload
    View the code
    throughput-versus-goodput-past-the-knee.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Throughput against goodput past the knee.
    
    A service completes 100 requests per second and receives 150; clients give up
    after 2 seconds. (a) an unbounded queue, (b) a queue bounded at 200, (c) the
    bounded queue plus a deadline check at dequeue. Goodput (answers delivered to
    clients still waiting) per second over a minute.
    """
    import argparse, json
    from collections import deque
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--arrival", type=int, default=150, help="Requests per second.")
    p.add_argument("--capacity", type=int, default=100, help="Requests served per second.")
    p.add_argument("--timeout", type=int, default=2, help="Client timeout (s).")
    p.add_argument("--max-queue", type=int, default=200, help="Bound for the bounded queue.")
    p.add_argument("--seconds", type=int, default=60, help="Seconds simulated.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    def sim(max_queue=None, check=False):
        q, good, per_sec = deque(), 0, []
        for sec in range(args.seconds):
            g0 = good
            for _ in range(args.arrival):
                if max_queue is None or len(q) < max_queue: q.append(sec)      # remember the arrival second
            for _ in range(args.capacity):
                while q:
                    arr = q.popleft()
                    if sec - arr > args.timeout:
                        if check: continue                                     # discard for free, try the next one
                        break                                                  # capacity spent on a dead request
                    good += 1; break
            per_sec.append(good - g0)
        return good / args.seconds, per_sec
    rows, timeline = [], []
    for label, kw in (("unbounded", {}), (f"bounded ({args.max_queue})", {"max_queue": args.max_queue}),
                      (f"bounded ({args.max_queue}) + dequeue check", {"max_queue": args.max_queue, "check": True})):
        g, ps = sim(**kw); rows.append({"queue": label, "goodput": g, "last_10s": sum(ps[-10:]) / 10})
        timeline += [{"queue": label, "second": i, "goodput": v} for i, v in enumerate(ps)]
    for r in rows: print(f"{r['queue']:<34} goodput {r['goodput']:6.1f}/s over the minute, {r['last_10s']:6.1f}/s in the last 10 s")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"queues": rows, "timeline": timeline}}, 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 throughput-versus-goodput-past-the-knee.py
    uv run throughput-versus-goodput-past-the-knee.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--arrival150 Requests per second.
--capacity100 Requests served per second.
--timeout2 Client timeout (s).
--max-queue200 Bound for the bounded queue.
--seconds60 Seconds simulated.

Our run

Goodput per secondApple M2 · 8 cores · 8 GB
unboundedbounded (200)bounded (200) + dequeue check
Data table
Goodput per second
SecondseriesAnswers delivered in time (/s)
0unbounded100
1unbounded100
2unbounded100
3unbounded100
4unbounded100
5unbounded100
6unbounded100
7unbounded50
8unbounded0
9unbounded0
10unbounded0
11unbounded0
12unbounded0
13unbounded0
14unbounded0
15unbounded0
16unbounded0
17unbounded0
18unbounded0
19unbounded0
20unbounded0
21unbounded0
22unbounded0
23unbounded0
24unbounded0
25unbounded0
26unbounded0
27unbounded0
28unbounded0
29unbounded0
30unbounded0
31unbounded0
32unbounded0
33unbounded0
34unbounded0
35unbounded0
36unbounded0
37unbounded0
38unbounded0
39unbounded0
40unbounded0
41unbounded0
42unbounded0
43unbounded0
44unbounded0
45unbounded0
46unbounded0
47unbounded0
48unbounded0
49unbounded0
50unbounded0
51unbounded0
52unbounded0
53unbounded0
54unbounded0
55unbounded0
56unbounded0
57unbounded0
58unbounded0
59unbounded0
0bounded (200)100
1bounded (200)100
2bounded (200)100
3bounded (200)100
4bounded (200)100
5bounded (200)100
6bounded (200)100
7bounded (200)100
8bounded (200)100
9bounded (200)100
10bounded (200)100
11bounded (200)100
12bounded (200)100
13bounded (200)100
14bounded (200)100
15bounded (200)100
16bounded (200)100
17bounded (200)100
18bounded (200)100
19bounded (200)100
20bounded (200)100
21bounded (200)100
22bounded (200)100
23bounded (200)100
24bounded (200)100
25bounded (200)100
26bounded (200)100
27bounded (200)100
28bounded (200)100
29bounded (200)100
30bounded (200)100
31bounded (200)100
32bounded (200)100
33bounded (200)100
34bounded (200)100
35bounded (200)100
36bounded (200)100
37bounded (200)100
38bounded (200)100
39bounded (200)100
40bounded (200)100
41bounded (200)100
42bounded (200)100
43bounded (200)100
44bounded (200)100
45bounded (200)100
46bounded (200)100
47bounded (200)100
48bounded (200)100
49bounded (200)100
50bounded (200)100
51bounded (200)100
52bounded (200)100
53bounded (200)100
54bounded (200)100
55bounded (200)100
56bounded (200)100
57bounded (200)100
58bounded (200)100
59bounded (200)100
0bounded (200) + dequeue check100
1bounded (200) + dequeue check100
2bounded (200) + dequeue check100
3bounded (200) + dequeue check100
4bounded (200) + dequeue check100
5bounded (200) + dequeue check100
6bounded (200) + dequeue check100
7bounded (200) + dequeue check100
8bounded (200) + dequeue check100
9bounded (200) + dequeue check100
10bounded (200) + dequeue check100
11bounded (200) + dequeue check100
12bounded (200) + dequeue check100
13bounded (200) + dequeue check100
14bounded (200) + dequeue check100
15bounded (200) + dequeue check100
16bounded (200) + dequeue check100
17bounded (200) + dequeue check100
18bounded (200) + dequeue check100
19bounded (200) + dequeue check100
20bounded (200) + dequeue check100
21bounded (200) + dequeue check100
22bounded (200) + dequeue check100
23bounded (200) + dequeue check100
24bounded (200) + dequeue check100
25bounded (200) + dequeue check100
26bounded (200) + dequeue check100
27bounded (200) + dequeue check100
28bounded (200) + dequeue check100
29bounded (200) + dequeue check100
30bounded (200) + dequeue check100
31bounded (200) + dequeue check100
32bounded (200) + dequeue check100
33bounded (200) + dequeue check100
34bounded (200) + dequeue check100
35bounded (200) + dequeue check100
36bounded (200) + dequeue check100
37bounded (200) + dequeue check100
38bounded (200) + dequeue check100
39bounded (200) + dequeue check100
40bounded (200) + dequeue check100
41bounded (200) + dequeue check100
42bounded (200) + dequeue check100
43bounded (200) + dequeue check100
44bounded (200) + dequeue check100
45bounded (200) + dequeue check100
46bounded (200) + dequeue check100
47bounded (200) + dequeue check100
48bounded (200) + dequeue check100
49bounded (200) + dequeue check100
50bounded (200) + dequeue check100
51bounded (200) + dequeue check100
52bounded (200) + dequeue check100
53bounded (200) + dequeue check100
54bounded (200) + dequeue check100
55bounded (200) + dequeue check100
56bounded (200) + dequeue check100
57bounded (200) + dequeue check100
58bounded (200) + dequeue check100
59bounded (200) + dequeue check100
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Goodput

Goodput
QueueOver the minute (/s)Last 10 s (/s)
unbounded12.50
bounded (200)100100
bounded (200) + dequeue check100100
Output · uv run throughput-versus-goodput-past-the-knee.py
unbounded                          goodput   12.5/s over the minute,    0.0/s in the last 10 s
bounded (200)                      goodput  100.0/s over the minute,  100.0/s in the last 10 s
bounded (200) + dequeue check      goodput  100.0/s over the minute,  100.0/s in the last 10 s

What we found

The unbounded queue delivered 12.5 answers per second over the minute and 0 in the last ten seconds: after the first few seconds every request had expired by the time it was served. The bounded queue delivered 100 per second, and so did the bounded queue with the deadline check: a bound of 200 at 100 per second means no request waits more than 2 seconds, so with this bound the check never had anything to discard.


Read it

The bound is what turns overload into rejected requests instead of expired ones; it should be set from the timeout (capacity × timeout), not from memory. The deadline check is the safety net for when the bound is larger than that, or when clients' timeouts vary.