1"""Chapter 27: Monolith vs microservices. Tested with Python 3, standard library only.
2
3Running example: the bookstore's backend (catalogue, cart, orders, payments, search,
4recommendations), as its team grows from 5 to 60 engineers.
5"""
6import random
7
8
9# ---------- 27.5 the real cost: latency and availability add up ----------
10
11def chain_latency_ms(hops, per_hop_ms):
12 return hops * per_hop_ms
13
14
15def chain_availability(per_service, services):
16 return per_service ** services
17
18
19def p_any_slow(p_slow_each, fanout):
20 """Calling N services in parallel: chance that at least one is slow (tail at scale)."""
21 return 1 - (1 - p_slow_each) ** fanout
22
23
24# ---------- 27.3 modular monolith: enforce boundaries ----------
25
26ALLOWED = {
27 "orders": {"catalog", "payments", "users"},
28 "cart": {"catalog", "users"},
29 "catalog": set(),
30 "payments": {"users"},
31 "users": set(),
32 "recommendations": {"catalog", "orders"},
33}
34
35
36def boundary_violations(imports):
37 """imports: list of (from_module, to_module). Returns the ones not declared."""
38 return sorted((a, b) for a, b in imports if a != b and b not in ALLOWED.get(a, set()))
39
40
41# ---------- 27.6 drawing boundaries: count calls that cross ----------
42
43CALLS = { # calls per order placed, between components (made-up but plausible shape)
44 ("cart", "catalog"): 5, ("orders", "cart"): 1, ("orders", "payments"): 2,
45 ("orders", "inventory"): 5, ("inventory", "catalog"): 5, ("payments", "users"): 1,
46 ("orders", "users"): 1, ("recommendations", "orders"): 1,
47}
48
49
50def cross_calls(split):
51 """split: component -> service name. Calls between different services cost network hops."""
52 return sum(n for (a, b), n in CALLS.items() if split[a] != split[b])
53
54
55# ---------- 27.7 service discovery ----------
56
57class Registry:
58 def __init__(self, ttl):
59 self.ttl, self.instances = ttl, {} # (service, addr) -> last heartbeat
60 self.rr = {}
61
62 def heartbeat(self, service, addr, now):
63 self.instances[(service, addr)] = now
64
65 def healthy(self, service, now):
66 return sorted(a for (s, a), t in self.instances.items() if s == service and now - t <= self.ttl)
67
68 def pick(self, service, now):
69 live = self.healthy(service, now)
70 if not live:
71 raise LookupError(f"no healthy {service}")
72 i = self.rr.get(service, 0)
73 self.rr[service] = i + 1
74 return live[i % len(live)]
75
76
77# ---------- 27.8 sync vs async ----------
78
79def place_order_sync(payment_up, email_up):
80 if not payment_up:
81 return "failed: payment down"
82 if not email_up:
83 return "failed: email down" # an email outage blocks orders
84 return "placed"
85
86
87def place_order_async(payment_up, email_up, queue):
88 if not payment_up:
89 return "failed: payment down" # still needed now
90 queue.append("send confirmation email") # email happens later
91 return "placed"
92
93
94# ---------- 27.9 database per service: N+1 calls vs batch ----------
95
96def order_history_n_plus_1(order_book_ids, call_ms):
97 calls = len(order_book_ids) # one catalogue call per order
98 return calls, calls * call_ms
99
100
101def order_history_batched(order_book_ids, call_ms):
102 return 1, call_ms # one call: GET /books?ids=...
103
104
105# ---------- Level 4: a call with no timeout ----------
106
107def request_with_slow_dependency(timeout_ms, dep_latency_ms, threads=200, rps=100):
108 """How long each request holds a thread, and whether the pool runs out."""
109 held = dep_latency_ms if timeout_ms is None else min(timeout_ms, dep_latency_ms)
110 busy = rps * held / 1000 # Little's law: threads in use
111 return held, busy, busy > threads
112
113
114# ---------- checks ----------
115
116def run_checks():
117 # latency and availability
118 assert chain_latency_ms(6, 2) == 12 # vs microseconds in-process
119 a = chain_availability(0.999, 6)
120 assert round(a, 4) == 0.994 # 99.9% each -> 99.4% together
121 assert round((1 - a) * 30 * 24 * 60) == 259 # ~4.3 h down a month vs 43 min
122 assert round(p_any_slow(0.01, 10), 3) == 0.096 # 1% slow each, fan-out 10 -> ~10%
123 assert round(p_any_slow(0.01, 100), 2) == 0.63
124
125 # modular monolith boundaries
126 imports = [("orders", "catalog"), ("cart", "payments"), ("catalog", "orders"), ("orders", "orders")]
127 assert boundary_violations(imports) == [("cart", "payments"), ("catalog", "orders")]
128
129 # boundaries: keep chatty parts together
130 bad = {"cart": "A", "catalog": "B", "orders": "A", "payments": "C",
131 "inventory": "C", "users": "B", "recommendations": "D"}
132 good = {"cart": "shop", "catalog": "shop", "inventory": "shop", "orders": "orders",
133 "payments": "payments", "users": "users", "recommendations": "reco"}
134 assert cross_calls(bad) == 20 and cross_calls(good) == 11 # of 21 calls per order
135
136 # service discovery
137 reg = Registry(ttl=30)
138 reg.heartbeat("catalog", "10.0.0.5", 0)
139 reg.heartbeat("catalog", "10.0.0.6", 0)
140 reg.heartbeat("catalog", "10.0.0.6", 40)
141 assert reg.healthy("catalog", 50) == ["10.0.0.6"] # .5 stopped heartbeating
142 assert reg.pick("catalog", 50) == "10.0.0.6"
143 try:
144 reg.pick("search", 50)
145 assert False
146 except LookupError:
147 pass
148
149 # sync vs async
150 q = []
151 assert place_order_sync(True, False) == "failed: email down"
152 assert place_order_async(True, False, q) == "placed" and q
153
154 # database per service: N+1
155 ids = list(range(50))
156 assert order_history_n_plus_1(ids, 3) == (50, 150)
157 assert order_history_batched(ids, 3) == (1, 3)
158
159 # Level 4: a slow catalogue with no timeout exhausts the order service's threads
160 held, busy, exhausted = request_with_slow_dependency(None, 5_000)
161 assert (held, busy, exhausted) == (5_000, 500.0, True)
162 held, busy, exhausted = request_with_slow_dependency(300, 5_000)
163 assert (held, busy, exhausted) == (300, 30.0, False)
164
165 return {"availability": round(a, 4), "fanout10": round(p_any_slow(0.01, 10), 3),
166 "cross_calls": (20, 11)}
167
168
169if __name__ == "__main__":
170 print("All checks pass.", run_checks())