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 queue behind them until they time out. A bulkhead 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
Expect C's success rate to fall to nearly 0 with the shared pool. With two pools of 5, C stays healthy.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What 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
| Design | C calls succeeding | Idle threads before the hang |
|---|---|---|
| one shared pool of 10 | 0.868% | 72.8% |
| two pools of 5 | 100% | 73% |
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%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.