Implement a thread-safe connection pool and measure how pool size shapes p50, p99, and throughput under concurrent load.
instructions
your task — Python
#!/usr/bin/env python3
"""
Lab 02: Connection Pool Tuning — YOUR TURN.
Everything except the pool's acquire/release logic is given. Implement:
ConnectionPool.acquire -> block on the semaphore (with timeout), then hand
out an idle connection or create a new one.
ConnectionPool.release -> return a healthy connection to the pool and free
one semaphore permit.
Goal: a pool of size 10 should serve 100 concurrent requests far faster than
a pool of size 1, while never creating more than max_size connections.
When done:
python -m unittest test_lab.py # should pass
python solution.py # compare against the reference
"""
import threading
import time
import statistics
from contextlib import contextmanager
from dataclasses import dataclass, field
class SimulatedConnection:
"""Expensive to create (15ms), cheap to query (10ms). (given)"""
CONNECTION_CREATION_COST_MS = 15
QUERY_COST_MS = 10
_id_counter = 0
_lock = threading.Lock()
def __init__(self):
with SimulatedConnection._lock:
SimulatedConnection._id_counter += 1
self.id = SimulatedConnection._id_counter
time.sleep(self.CONNECTION_CREATION_COST_MS / 1000)
self.queries_executed = 0
self._closed = False
def execute(self, query: str = "SELECT 1") -> dict:
if self._closed:
raise RuntimeError(f"Connection {self.id} is closed")
time.sleep(self.QUERY_COST_MS / 1000)
self.queries_executed += 1
return {"rows": 1, "connection_id": self.id}
def ping(self) -> bool:
return not self._closed
def close(self):
self._closed = True
@dataclass
class PoolStats:
total_created: int = 0
total_acquired: int = 0
total_released: int = 0
total_timeouts: int = 0
total_wait_time_ms: float = 0.0
wait_times_ms: list = field(default_factory=list)
def record_wait(self, wait_ms: float):
self.wait_times_ms.append(wait_ms)
self.total_wait_time_ms += wait_ms
@property
def avg_wait_ms(self) -> float:
return self.total_wait_time_ms / max(1, len(self.wait_times_ms))
class ConnectionPool:
"""Thread-safe pool. You implement acquire() and release()."""
def __init__(self, min_size: int = 2, max_size: int = 10, timeout: float = 30.0):
self.min_size = min_size
self.max_size = max_size
self.timeout = timeout
self._idle: list[SimulatedConnection] = []
self._total_created = 0
self._lock = threading.Lock()
self._semaphore = threading.Semaphore(max_size)
self.stats = PoolStats()
for _ in range(min_size):
conn = SimulatedConnection()
self._idle.append(conn)
self._total_created += 1
self.stats.total_created += 1
def acquire(self) -> SimulatedConnection:
"""
TODO:
1. Try to acquire the semaphore with self.timeout. If it fails,
record a timeout in stats and raise TimeoutError.
2. Record the wait time in self.stats.
3. Under self._lock: return an idle connection if one exists
(replace it if it fails ping()), otherwise create a new
SimulatedConnection and bump the created counters.
4. Increment stats.total_acquired and return the connection.
"""
raise NotImplementedError("Implement ConnectionPool.acquire")
def release(self, conn: SimulatedConnection):
"""
TODO:
1. Under self._lock: if conn.ping(), push it back onto self._idle;
otherwise decrement self._total_created (the connection is lost).
2. Release one semaphore permit.
3. Increment stats.total_released.
"""
raise NotImplementedError("Implement ConnectionPool.release")
@contextmanager
def connection(self):
conn = self.acquire()
try:
yield conn
finally:
self.release(conn)
@property
def active_count(self) -> int:
with self._lock:
return self._total_created - len(self._idle)
class NoPool:
"""New connection per request — the baseline. (given)"""
def __init__(self):
self.stats = PoolStats()
@contextmanager
def connection(self):
conn = SimulatedConnection()
self.stats.total_created += 1
self.stats.total_acquired += 1
try:
yield conn
finally:
conn.close()
self.stats.total_released += 1
def run_request(pool, results, errors, idx):
start = time.monotonic()
try:
with pool.connection() as conn:
conn.execute("SELECT * FROM users WHERE id = ?")
results.append((time.monotonic() - start) * 1000)
except Exception as e: # noqa: BLE001
errors.append({"idx": idx, "error": str(e)})
def run_concurrent_workload(pool, num_requests: int = 100) -> dict:
results, errors, threads = [], [], []
barrier = threading.Barrier(num_requests + 1)
def request_with_barrier(idx):
barrier.wait()
run_request(pool, results, errors, idx)
for i in range(num_requests):
t = threading.Thread(target=request_with_barrier, args=(i,), daemon=True)
threads.append(t)
t.start()
workload_start = time.monotonic()
barrier.wait()
for t in threads:
t.join(timeout=60)
total_elapsed = time.monotonic() - workload_start
if not results:
return {"error": "All requests failed", "errors": errors}
sorted_results = sorted(results)
n = len(sorted_results)
return {
"successful": len(results),
"p50_ms": statistics.median(sorted_results),
"p99_ms": sorted_results[int(n * 0.99)] if n > 1 else sorted_results[-1],
"throughput_rps": len(results) / total_elapsed,
"connections_created": pool.stats.total_created,
}
def main():
SimulatedConnection._id_counter = 0
try:
pool = ConnectionPool(min_size=5, max_size=10, timeout=60.0)
print(run_concurrent_workload(pool, 100))
except NotImplementedError as e:
print(f"Not implemented yet: {e}")
if __name__ == "__main__":
main()
Understand how connection pool size affects throughput and latency under concurrent load. You will benchmark four configurations: no pool, pool too small, pool optimal, and pool too large.
Available in 8 languages. Each
<lang>/folder has astubyou implementand a
solutionfor reference: Python, Go, JavaScript, TypeScript, Rust, Ruby,C++, and C. Implement
acquire/release; the embedded checks verify reuse, atimeout when exhausted, and that the pool never exceeds
maxSize. Pick the tabfor your language in the lab console, or run the
testCmdfromlab.json.
Expected outcomes:
| Configuration | p50 | p99 | Throughput |
|---|---|---|---|
| No pool | ~25ms | ~90ms | ~120 req/s |
| Pool size=1 | ~55ms | ~400ms | ~50 req/s |
| Pool size=10 | ~12ms | ~25ms | ~500 req/s |
| Pool size=100 | ~15ms | ~40ms | ~400 req/s |
Pool size=10 is optimal because it matches the concurrency level and minimizes overhead.
We simulate a database connection with:
The pool manages a bounded set of pre-created connections. Requests that cannot get a connection immediately must wait.
Save as lab02.py and run with python lab02.py:
python#!/usr/bin/env python3"""Lab 02: Connection Pool TuningDemonstrates impact of pool size on latency and throughput under concurrent load.No external dependencies — uses threading and sleep to simulate DB connection cost."""import threadingimport timeimport statisticsimport queuefrom contextlib import contextmanagerfrom dataclasses import dataclass, fieldfrom typing import Optional# ──────────────────────────────────────────────# SIMULATED CONNECTION# ──────────────────────────────────────────────class SimulatedConnection:"""Simulates a database connection.Creation is expensive (simulated TCP+TLS+auth overhead).Query execution is cheaper (simulated DB query)."""CONNECTION_CREATION_COST_MS = 15 # TCP 3-way + TLS + DB authQUERY_COST_MS = 10 # average query execution time_id_counter = 0_lock = threading.Lock()def __init__(self):with SimulatedConnection._lock:SimulatedConnection._id_counter += 1self.id = SimulatedConnection._id_counter# Simulate connection creation overheadtime.sleep(self.CONNECTION_CREATION_COST_MS / 1000)self.created_at = time.monotonic()self.queries_executed = 0self._closed = Falsedef execute(self, query: str = "SELECT 1") -> dict:"""Execute a query. Simulates network RTT + DB processing."""if self._closed:raise RuntimeError(f"Connection {self.id} is closed")time.sleep(self.QUERY_COST_MS / 1000)self.queries_executed += 1return {"rows": 1, "connection_id": self.id}def ping(self) -> bool:"""Health check — lightweight."""return not self._closeddef close(self):self._closed = Truedef __repr__(self):return f"Connection(id={self.id}, queries={self.queries_executed})"# ──────────────────────────────────────────────# CONNECTION POOL IMPLEMENTATION# ──────────────────────────────────────────────@dataclassclass PoolStats:"""Accumulated statistics for the pool."""total_created: int = 0total_acquired: int = 0total_released: int = 0total_timeouts: int = 0total_wait_time_ms: float = 0.0wait_times_ms: list = field(default_factory=list)def record_wait(self, wait_ms: float):self.wait_times_ms.append(wait_ms)self.total_wait_time_ms += wait_ms@propertydef avg_wait_ms(self) -> float:return self.total_wait_time_ms / max(1, len(self.wait_times_ms))@propertydef p99_wait_ms(self) -> float:if not self.wait_times_ms:return 0.0idx = int(len(self.wait_times_ms) * 0.99)return sorted(self.wait_times_ms)[idx]class ConnectionPool:"""Thread-safe connection pool with:- Configurable min/max size- Blocking acquire with timeout- Statistics collection- Health checking"""def __init__(self, min_size: int = 2, max_size: int = 10, timeout: float = 30.0):self.min_size = min_sizeself.max_size = max_sizeself.timeout = timeoutself._idle: list[SimulatedConnection] = []self._total_created = 0self._lock = threading.Lock()self._semaphore = threading.Semaphore(max_size)self.stats = PoolStats()# Pre-warm the poolfor _ in range(min_size):conn = SimulatedConnection()self._idle.append(conn)self._total_created += 1self.stats.total_created += 1def acquire(self) -> SimulatedConnection:"""Get a connection from the pool.Blocks if pool is exhausted until one is returned or timeout expires."""wait_start = time.monotonic()if not self._semaphore.acquire(timeout=self.timeout):self.stats.total_timeouts += 1raise TimeoutError(f"Pool exhausted (max={self.max_size}): "f"waited {self.timeout}s")wait_ms = (time.monotonic() - wait_start) * 1000self.stats.record_wait(wait_ms)with self._lock:if self._idle:conn = self._idle.pop() # LIFO: take most recently usedif not conn.ping():# Unhealthy — create replacementconn = SimulatedConnection()self._total_created += 1self.stats.total_created += 1else:# No idle connections but semaphore acquired — create newconn = SimulatedConnection()self._total_created += 1self.stats.total_created += 1self.stats.total_acquired += 1return conndef release(self, conn: SimulatedConnection):"""Return connection to pool."""with self._lock:if conn.ping():self._idle.append(conn)else:self._total_created -= 1 # lost this connectionself._semaphore.release()self.stats.total_released += 1@contextmanagerdef connection(self):"""Context manager for safe connection usage."""conn = self.acquire()try:yield connfinally:self.release(conn)@propertydef idle_count(self) -> int:with self._lock:return len(self._idle)@propertydef active_count(self) -> int:with self._lock:return self._total_created - len(self._idle)# ──────────────────────────────────────────────# NO-POOL IMPLEMENTATION (for comparison)# ──────────────────────────────────────────────class NoPool:"""Creates a new connection for every request — no pooling."""def __init__(self):self.stats = PoolStats()@contextmanagerdef connection(self):wait_start = time.monotonic()conn = SimulatedConnection() # 15ms creation costwait_ms = (time.monotonic() - wait_start) * 1000self.stats.record_wait(wait_ms)self.stats.total_created += 1self.stats.total_acquired += 1try:yield connfinally:conn.close()self.stats.total_released += 1# ──────────────────────────────────────────────# WORKLOAD SIMULATION# ──────────────────────────────────────────────def run_request(pool_or_nopool, results: list, errors: list, idx: int):"""Simulate one HTTP request that needs one DB query.Measures total time: wait for connection + query execution."""start = time.monotonic()try:with pool_or_nopool.connection() as conn:conn.execute("SELECT * FROM users WHERE id = ?")elapsed_ms = (time.monotonic() - start) * 1000results.append(elapsed_ms)except TimeoutError as e:elapsed_ms = (time.monotonic() - start) * 1000errors.append({"idx": idx, "elapsed_ms": elapsed_ms, "error": str(e)})except Exception as e:errors.append({"idx": idx, "elapsed_ms": -1, "error": str(e)})def run_concurrent_workload(pool_or_nopool, num_requests: int = 100) -> dict:"""Launch num_requests concurrent threads simultaneously.Returns timing statistics."""results = []errors = []threads = []# Create all threads first (they won't start until we signal)barrier = threading.Barrier(num_requests + 1) # +1 for main threaddef request_with_barrier(idx: int):barrier.wait() # synchronize: all threads start at exactly the same timerun_request(pool_or_nopool, results, errors, idx)for i in range(num_requests):t = threading.Thread(target=request_with_barrier, args=(i,))t.daemon = Truethreads.append(t)t.start()# Signal all threads to start simultaneouslyworkload_start = time.monotonic()barrier.wait()# Wait for all to completefor t in threads:t.join(timeout=60)total_elapsed = time.monotonic() - workload_startif not results:return {"error": "All requests failed", "errors": errors}sorted_results = sorted(results)n = len(sorted_results)return {"total_requests": num_requests,"successful": len(results),"errors": len(errors),"p50_ms": statistics.median(sorted_results),"p90_ms": sorted_results[int(n * 0.90)],"p99_ms": sorted_results[int(n * 0.99)] if n > 1 else sorted_results[-1],"min_ms": sorted_results[0],"max_ms": sorted_results[-1],"total_time_s": total_elapsed,"throughput_rps": len(results) / total_elapsed,"avg_wait_ms": pool_or_nopool.stats.avg_wait_ms,"connections_created": pool_or_nopool.stats.total_created,}# ──────────────────────────────────────────────# FOUR SCENARIOS# ──────────────────────────────────────────────def scenario_no_pool(num_requests: int) -> dict:"""Scenario 1: No pool — new connection per request."""pool = NoPool()return run_concurrent_workload(pool, num_requests)def scenario_pool_too_small(num_requests: int) -> dict:"""Scenario 2: Pool size=1 — massive bottleneck, high queuing."""SimulatedConnection._id_counter = 0pool = ConnectionPool(min_size=1, max_size=1, timeout=60.0)return run_concurrent_workload(pool, num_requests)def scenario_pool_optimal(num_requests: int) -> dict:"""Scenario 3: Pool size=10 — good balance for 100 concurrent."""SimulatedConnection._id_counter = 0pool = ConnectionPool(min_size=5, max_size=10, timeout=30.0)return run_concurrent_workload(pool, num_requests)def scenario_pool_too_large(num_requests: int) -> dict:"""Scenario 4: Pool size=100 — all requests get immediate connection.BUT: 100 simultaneous connections overwhelm the DB server's scheduler."""SimulatedConnection._id_counter = 0# Simulate DB degradation at high connection count: increase query costoriginal_cost = SimulatedConnection.QUERY_COST_MSSimulatedConnection.QUERY_COST_MS = 15 # DB slower due to process schedulingpool = ConnectionPool(min_size=20, max_size=100, timeout=30.0)result = run_concurrent_workload(pool, num_requests)SimulatedConnection.QUERY_COST_MS = original_costreturn result# ──────────────────────────────────────────────# ANALYSIS AND OUTPUT# ──────────────────────────────────────────────def print_scenario_results(name: str, stats: dict, description: str):print(f"\n{'─' * 65}")print(f" {name}")print(f" {description}")print(f"{'─' * 65}")if "error" in stats:print(f" ✗ FAILED: {stats['error']}")returnsuccess_rate = stats["successful"] / stats["total_requests"] * 100print(f" Requests: {stats['total_requests']} total, "f"{stats['successful']} succeeded ({success_rate:.0f}%)")print(f" Latency: p50={stats['p50_ms']:.1f}ms "f"p90={stats['p90_ms']:.1f}ms "f"p99={stats['p99_ms']:.1f}ms")print(f" Range: min={stats['min_ms']:.1f}ms max={stats['max_ms']:.1f}ms")print(f" Throughput: {stats['throughput_rps']:.1f} req/s")print(f" DB Connections created: {stats['connections_created']}")print(f" Avg conn wait: {stats['avg_wait_ms']:.1f}ms")if stats["errors"] > 0:print(f" ⚠ Errors: {stats['errors']}")def print_comparison_table(all_results: dict):print("\n" + "=" * 80)print("COMPARISON TABLE")print("=" * 80)print(f"{'Config':<22} {'p50':>8} {'p99':>8} {'Throughput':>12} {'Conn Created':>14}")print("-" * 80)for name, stats in all_results.items():if "error" in stats:print(f"{name:<22} {'ERROR':>8}")continueprint(f"{name:<22} "f"{stats['p50_ms']:>7.1f}ms "f"{stats['p99_ms']:>7.1f}ms "f"{stats['throughput_rps']:>11.1f}/s "f"{stats['connections_created']:>14}")print("=" * 80)def print_analysis():print("""ANALYSIS:─────────────────────────────────────────────────────────────────────────No Pool: Each request pays 15ms connection creation cost.100 connections created = 100 × TCP+TLS+auth overhead.High throughput variability.Pool size=1: All 100 requests queue behind a single connection.Requests execute serially: total_time ≈ 100 × 25ms = 2,500ms.p99 is terrible because requests at the back of the queue wait longest.Pool size=10: 10 connections serve 100 requests in 10 batches of 10.Total time ≈ 10 × 25ms = 250ms (10× faster than pool=1).p99 is much better: worst case waits for ~9 others ahead.Pool size=100: All 100 requests get a connection immediately.BUT: 100 simultaneous DB queries thrash the DB server's CPU.DB scheduler overhead increases query time from 10ms to 15ms.Creates 100 connections = 100 OS processes on the DB server.KEY INSIGHT: Pool size is not "more = better".Optimal size = f(CPU cores on DB server, query duration, concurrency level)Formula: pool_size ≈ DB_CPU_cores × 2 (HikariCP / PgBouncer recommendation)─────────────────────────────────────────────────────────────────────────""")# ──────────────────────────────────────────────# LITTLE'S LAW DEMONSTRATION# ──────────────────────────────────────────────def print_littles_law_analysis():print("""LITTLE'S LAW ANALYSIS:L = λ × W (average queue length = arrival rate × average wait time)For pool_size=10, query_time=10ms:Maximum throughput λ_max = pool_size / W = 10 / 0.010s = 1,000 req/s(At 100 concurrent requests in 250ms total: 100/0.250 = 400 req/s — matches)For pool_size=1, query_time=10ms:Maximum throughput = 1 / 0.010s = 100 req/s(At 100 concurrent: heavily queued, p99 >> p50)SIZING FORMULA for your system:pool_size = ceil(target_rps × avg_query_duration_seconds × safety_factor)Example: 500 req/s target, 10ms avg query, 1.3 safety factor:pool_size = ceil(500 × 0.010 × 1.3) = ceil(6.5) = 7Round up to nearest sensible number: 10""")# ──────────────────────────────────────────────# MAIN# ──────────────────────────────────────────────def main():NUM_REQUESTS = 100print("\n" + "=" * 65)print("LAB 02: Connection Pool Tuning")print("=" * 65)print(f"""Configuration:Simulated connection creation cost: {SimulatedConnection.CONNECTION_CREATION_COST_MS}msSimulated query execution time: {SimulatedConnection.QUERY_COST_MS}msConcurrent requests: {NUM_REQUESTS}Each request simulates:1. Wait for available connection (pool contention)2. Create new connection if pool not full (15ms overhead)3. Execute query (10ms)4. Release connection back to pool""")all_results = {}print("Running Scenario 1: No Pool (new connection per request)...")all_results["No Pool"] = scenario_no_pool(NUM_REQUESTS)print_scenario_results("SCENARIO 1: No Pool",all_results["No Pool"],"Each request creates a new TCP connection (15ms overhead per request)")print("\nRunning Scenario 2: Pool size=1 (extreme bottleneck)...")all_results["Pool size=1"] = scenario_pool_too_small(NUM_REQUESTS)print_scenario_results("SCENARIO 2: Pool size=1",all_results["Pool size=1"],"All 100 concurrent requests share 1 connection — severe queuing")print("\nRunning Scenario 3: Pool size=10 (optimal for this workload)...")all_results["Pool size=10"] = scenario_pool_optimal(NUM_REQUESTS)print_scenario_results("SCENARIO 3: Pool size=10 (OPTIMAL)",all_results["Pool size=10"],"10 connections serve 100 requests in 10 parallel batches")print("\nRunning Scenario 4: Pool size=100 (too large)...")all_results["Pool size=100"] = scenario_pool_too_large(NUM_REQUESTS)print_scenario_results("SCENARIO 4: Pool size=100 (too large)",all_results["Pool size=100"],"100 connections created — overwhelms DB scheduler, each query takes longer")print_comparison_table(all_results)print_analysis()print_littles_law_analysis()print("=" * 65)print("CONCLUSION:")print(" Pool size=10 achieves the best balance of latency and throughput.")print(" The optimal pool size is NOT the maximum — it's the minimum")print(" needed to saturate your bottleneck (usually: DB CPU cores × 2).")print("=" * 65 + "\n")if __name__ == "__main__":main()
bashpython lab02.py
Expected output structure:
===================================================================LAB 02: Connection Pool Tuning===================================================================Configuration:Simulated connection creation cost: 15msSimulated query execution time: 10msConcurrent requests: 100─────────────────────────────────────────────────────────────────SCENARIO 1: No PoolEach request creates a new TCP connection (15ms overhead per request)─────────────────────────────────────────────────────────────────Requests: 100 total, 100 succeeded (100%)Latency: p50=25.3ms p90=31.2ms p99=38.4msThroughput: 74.1 req/sDB Connections created: 100─────────────────────────────────────────────────────────────────SCENARIO 2: Pool size=1All 100 concurrent requests share 1 connection — severe queuing─────────────────────────────────────────────────────────────────Requests: 100 total, 100 succeeded (100%)Latency: p50=511ms p90=942ms p99=1,001msThroughput: 9.9 req/sDB Connections created: 1─────────────────────────────────────────────────────────────────SCENARIO 3: Pool size=10 (OPTIMAL)10 connections serve 100 requests in 10 parallel batches─────────────────────────────────────────────────────────────────Requests: 100 total, 100 succeeded (100%)Latency: p50=115ms p90=203ms p99=212msThroughput: 48.0 req/sDB Connections created: 10================================================================================COMPARISON TABLE================================================================================Config p50 p99 Throughput Conn Created--------------------------------------------------------------------------------No Pool 25.3ms 38.4ms 74.1/s 100Pool size=1 511.0ms 1001.0ms 9.9/s 1Pool size=10 115.0ms 212.0ms 48.0/s 10Pool size=100 21.0ms 36.0ms 64.3/s 100================================================================================
Modify the code to test pool sizes from 1 to 50:
pythondef sweep_pool_sizes(num_requests: int = 100):print(f"\n{'Pool Size':>10} {'p50 (ms)':>10} {'p99 (ms)':>10} {'Throughput':>12}")print("-" * 46)for pool_size in [1, 2, 5, 10, 15, 20, 30, 50]:SimulatedConnection._id_counter = 0pool = ConnectionPool(min_size=min(2, pool_size), max_size=pool_size, timeout=60.0)stats = run_concurrent_workload(pool, num_requests)print(f"{pool_size:>10} "f"{stats['p50_ms']:>10.1f} "f"{stats['p99_ms']:>10.1f} "f"{stats['throughput_rps']:>11.1f}/s")
Expected: p99 improves as pool size increases, then plateaus (or degrades) past the optimal point.
pythondef simulate_connection_leak(pool: ConnectionPool, num_requests: int = 20):"""Simulate requests that forget to release connections (connection leak).The pool will exhaust and subsequent requests will time out."""leaked_connections = []for i in range(num_requests):try:conn = pool.acquire()conn.execute()# BUG: forgot to call pool.release(conn) ← connection leakleaked_connections.append(conn) # prevent GCprint(f"Request {i+1}: success, pool active={pool.active_count}")except TimeoutError:print(f"Request {i+1}: TIMEOUT — pool exhausted by leak!")
../../bsps/07-core-backend-engineering/02-connection-pooling.md — full theory../../bsps/01-mathematics-for-systems/04-queueing-theory.md — Little's Law derivation../../bsps/03-operating-systems/01-processes-and-threads.md — why each DB connection is an OS process