1"""Chapter 6 practice: an autoscaler simulation (Level 3 Build) and the 'flapping'
2autoscaler bug (Level 4 Debug), plus Amdahl's law. Standard library only.
3Run: python3 ch06_scaling.py"""
4import math
5
6
7def amdahl_speedup(serial_fraction, n):
8 """Best possible speedup with n servers when part of the work can't be split."""
9 return 1 / (serial_fraction + (1 - serial_fraction) / n)
10
11
12def servers_needed(rps, per_server_rps, target_util):
13 return max(1, math.ceil(rps / (per_server_rps * target_util)))
14
15
16def simulate(traffic, per_server_rps=500, target_util=0.6, min_servers=2, max_servers=100,
17 warmup=3, schedule=None, start=None):
18 """traffic: requests per second for each minute.
19 New servers take `warmup` minutes before they can serve.
20 schedule: optional {minute: servers}: from that minute on, keep at least that many
21 servers (scaling ahead of a known event, like a sale that starts at a fixed time).
22 Returns (ready servers per minute, total requests that couldn't be served)."""
23 ready = start if start is not None else min_servers
24 pending = [] # minutes at which ordered servers become ready
25 history, dropped = [], 0
26 for minute, rps in enumerate(traffic):
27 ready += sum(1 for t in pending if t == minute)
28 pending = [t for t in pending if t > minute]
29 want = servers_needed(rps, per_server_rps, target_util)
30 if schedule:
31 floors = [n for m, n in schedule.items() if m <= minute]
32 if floors:
33 want = max(want, max(floors))
34 want = min(max(want, min_servers), max_servers)
35 coming = ready + len(pending)
36 if want > coming: # scale out: order more servers
37 pending += [minute + warmup] * (want - coming)
38 elif want < ready and not pending: # scale in: remove extra servers now
39 ready = want
40 capacity = ready * per_server_rps
41 dropped += max(0, rps - capacity) * 60
42 history.append(ready)
43 return history, dropped
44
45
46# ---- Level 4: the flapping autoscaler ------------------------------------------------
47
48def buggy_autoscaler(cpu_by_minute_fn, minutes=30, start=4):
49 """Adds a server above 60% CPU, removes one below 60%, every minute."""
50 servers, changes = start, 0
51 for _ in range(minutes):
52 cpu = cpu_by_minute_fn(servers)
53 if cpu > 0.60:
54 servers += 1; changes += 1
55 elif cpu < 0.60: # BUG: same threshold both ways, no cooldown
56 servers -= 1; changes += 1
57 return servers, changes
58
59
60def fixed_autoscaler(cpu_by_minute_fn, minutes=30, start=4, cooldown=5):
61 """Out above 70%, in below 40% (a gap = hysteresis), and wait after each change."""
62 servers, changes, wait = start, 0, 0
63 for _ in range(minutes):
64 if wait:
65 wait -= 1
66 continue
67 cpu = cpu_by_minute_fn(servers)
68 if cpu > 0.70:
69 servers += 1; changes += 1; wait = cooldown
70 elif cpu < 0.40:
71 servers -= 1; changes += 1; wait = cooldown
72 return servers, changes
73
74
75def steady_load(servers, work=2.6):
76 """A steady load worth 2.6 'servers' of CPU, spread across however many there are."""
77 return work / servers
78
79
80def run_checks():
81 # Amdahl's law
82 assert abs(amdahl_speedup(0.10, 10) - 5.26) < 0.01
83 assert abs(amdahl_speedup(0.10, 100) - 9.17) < 0.01
84 assert amdahl_speedup(0.10, 10**9) < 10 # never more than 1 / serial fraction
85
86 # Sizing for the sale
87 assert servers_needed(20_000, 500, 0.6) == 67
88 assert servers_needed(2_000, 500, 0.6) == 7
89
90 # Steady traffic: the fleet settles and nothing is dropped.
91 hist, dropped = simulate([2000] * 30, start=7)
92 assert dropped == 0 and hist[-1] == 7
93
94 # A sudden 10x spike with 3-minute warm-up: requests are dropped while servers start.
95 spike = [2000] * 10 + [20_000] * 20
96 hist, dropped_reactive = simulate(spike, start=7)
97 assert dropped_reactive > 0 and hist[-1] == 67
98
99 # Scale ahead of the known sale (scheduled at minute 5): nothing dropped.
100 hist, dropped_scheduled = simulate(spike, schedule={5: 67}, start=7)
101 assert dropped_scheduled == 0
102
103 # Level 4: flapping
104 end, changes_bad = buggy_autoscaler(steady_load)
105 assert changes_bad == 30 # changes every single minute
106 end, changes_good = fixed_autoscaler(steady_load)
107 assert changes_good <= 1
108
109 print(f"All checks pass. Spike: {dropped_reactive:,} requests dropped while scaling up, "
110 f"0 with scheduled scaling. Flapping: {changes_bad} changes in 30 min vs {changes_good}.")
111
112
113if __name__ == "__main__":
114 run_checks()