Skip to content
About
Main content

Bulkheads: one slow dependency, one shared pool

Simulate a modelNeeds Python

Problem

When two dependencies share one thread pool, the slow one can take every thread: each hung call holds its thread, and calls to the healthy dependency behind them until they time out. A gives each dependency its own pool, so a hang can only exhaust its own threads. The cost is capacity: two pools of 5 cannot lend each other idle threads the way one pool of 10 can.

Build a service with a thread pool of 10 that calls dependency B (hangs after 5 seconds into the run) and dependency C (always fast). Measure the success rate of C calls before and after B hangs. Then split into two pools of 5 and repeat. Finally compare the idle capacity of the two designs under normal load to see the cost of isolation.

The script is a discrete-event simulation; a call that waits more than 0.5 s for a thread fails.

The question. After B hangs, what share of C's calls succeed with one shared pool and with two pools, and what does the isolation cost under normal load?


Predict

After B hangs, what share of C's calls succeed with one shared pool of 10 threads?

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.

    bulkheads-one-slow-dependency-one-shared.py73 linesDownload
    View the code
    bulkheads-one-slow-dependency-one-shared.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Bulkheads: one slow dependency, one shared pool.
    
    A service with 10 threads calls dependency B and dependency C (always fast).
    B hangs from second 5. With one shared pool, B's hung calls take every
    thread and C's calls fail too; with two pools of 5, C stays healthy.
    A discrete-event simulation.
    """
    import argparse, heapq, json, random
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--threads", type=int, default=10, help="Threads in total.")
    p.add_argument("--rate", type=float, default=40, help="Calls per second to each dependency.")
    p.add_argument("--hang-at", type=float, default=5, help="Second at which B starts hanging.")
    p.add_argument("--seconds", type=float, default=20, help="Length of the run.")
    p.add_argument("--queue-timeout", type=float, default=0.5, help="A call waiting longer than this for a thread fails.")
    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)
    
    
    def run(split):
        pools = {"B": args.threads // 2, "C": args.threads // 2} if split else {"all": args.threads}
        pool_of = (lambda d: d) if split else (lambda d: "all")
        free = dict(pools); waiting = {k: [] for k in pools}
        events, res = [], {"B": [0, 0], "C": [0, 0]}                  # [ok, failed] after the hang
        busy_samples = []
        for dep in ("B", "C"):
            t = 0.0
            while t < args.seconds:
                t += random.expovariate(args.rate); heapq.heappush(events, (t, "arrive", dep, t))
        def start(t, dep, born):
            k = pool_of(dep); free[k] -= 1
            hang = dep == "B" and t >= args.hang_at
            heapq.heappush(events, (t + (1e9 if hang else random.uniform(0.01, 0.05)), "done", dep, born))
        while events:
            t, kind, dep, born = heapq.heappop(events)
            if t > args.seconds: break
            k = pool_of(dep)
            if kind == "arrive":
                if free[k] > 0: start(t, dep, born)
                else: waiting[k].append((dep, born))
            else:
                if born >= args.hang_at: res[dep][0] += 1
                free[k] += 1
                while waiting[k]:
                    d2, b2 = waiting[k].pop(0)
                    if t - b2 > args.queue_timeout:
                        if b2 >= args.hang_at: res[d2][1] += 1
                        continue
                    start(t, d2, b2); break
            if t < args.hang_at: busy_samples.append(sum(pools[x] - free[x] for x in pools) / args.threads)
        for k in waiting:                                              # whatever is still queued at the end failed
            for d2, b2 in waiting[k]:
                if b2 >= args.hang_at: res[d2][1] += 1
        c_ok, c_fail = res["C"]
        return {"design": "two pools of 5" if split else "one shared pool of 10",
                "c_success_pct": c_ok / max(1, c_ok + c_fail) * 100, "idle_before_pct": (1 - sum(busy_samples) / len(busy_samples)) * 100}
    
    
    rows = [run(False), run(True)]
    for r in rows:
        print(f"{r['design']:<24} C calls succeeding after B hangs: {r['c_success_pct']:5.1f}%   idle threads before: {r['idle_before_pct']:4.1f}%")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"designs": rows}}, 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 bulkheads-one-slow-dependency-one-shared.py
    uv run bulkheads-one-slow-dependency-one-shared.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--threads10 Threads in total.
--rate40 Calls per second to each dependency.
--hang-at5 Second at which B starts hanging.
--seconds20 Length of the run.
--queue-timeout0.5 A call waiting longer than this for a thread fails.
--seed0 Random seed.

Our run

After dependency B hangs

After dependency B hangs
DesignC calls succeedingIdle threads before the hang
one shared pool of 100.868%72.8%
two pools of 5100%73%
Reference runApple M2 · 8 cores · 8 GB
Output · uv run bulkheads-one-slow-dependency-one-shared.py
one shared pool of 10    C calls succeeding after B hangs:   0.9%   idle threads before: 72.8%
two pools of 5           C calls succeeding after B hangs: 100.0%   idle threads before: 73.0%
macOS 26.6.2 · Python 3.13.3 · run 2026-10-25

What we found

With one shared pool, only 0.868% of C's calls succeeded after B hung: B's hung calls took every thread, as expected. With two pools of 5, 100% did. At this load the isolation cost nothing visible: both designs left about 73% of their threads idle before the hang. The cost appears when one dependency's load spikes and its five threads are not enough while the other five sit idle.


Read it

A bulkhead converts "one dependency is down" from a total outage into a partial one. Size each pool for its own dependency's peak, and accept that the sum of the pools is larger than one shared pool would need: that slack is the price of the isolation.