Lesson 2: What still carries over from classical design
Module 1 · Lesson 2 · About 45 minutes · Needs: Lesson 1, and what a server, a load balancer, a cache and a queue are
The situation
After Lesson 1, Gareth pushes back: "Half of what you will propose is ordinary distributed systems. Before we argue about models, show me Ask survives a replica dying, a provider hanging and a retry."
Follow one employee. Aisha Moreau works in finance at Brennan & Kloss Logistics. At 13:30 UTC on Monday 28 September 2026, the busiest five minutes in the data, she asks Ask six questions about meal expenses, then opens a Cases request for a cracked laptop screen. (Her conversation and request are added in this lesson; traffic and traces are the shared data.)
A real case, first
In a QCon San Francisco talk (InfoQ recording, 24 March 2025), David Cheney, tech lead of GitHub's copilot-proxy, described how Copilot serves code completions. "We serve more than 400 million completion requests", peaking "at about 8,000 requests a second", with "a mean response time of less than 200 milliseconds". About 45% of requests are "typed through" and cancelled; without cancellation, he said, they would make "twice as many requests".
Almost everything he described is classical engineering. The completion path is "essentially stateless". A proxy colocated with the model in each region checks a short-lived token, swaps in the real API key and streams the reply back. HTTP/2 lets a cancelled request reset one stream, not the connection. Unhealthy proxies "vote themselves out of DNS". SLOs are negotiated at the proxy, where GitHub measures for itself.
What you will be able to do
- Explain the seven classical disciplines an AI system still needs, and what changes in their numbers.
- Size Ask's concurrency with Little's law from Northdesk's traffic and traces.
- Show a stateful replica losing a conversation, a queue without backpressure, and a retry that writes twice.
- Detect the 22 September incident from stored spans.
1. Statelessness and horizontal scaling (Essential)
Joel's target means about 46 Ask requests a second at peak, more than one server should carry. Ask must run as many copies, and that only works if any copy can serve any request.
A stateless replica keeps nothing between requests that another replica would need. Send Aisha's six turns round-robin to replicas a, b and c. If each keeps conversations in its own memory, only her first turn sees the right history. With the session in a shared store, all six do.
Horizontal scaling means adding copies rather than a bigger machine, and statelessness makes it safe. When replica b fails after turn three, the balancer skips it and the conversation carries on. The state still exists; it moved to a store built to keep it (Redis, Module 8).
The AI part is arithmetic. Little's law: in flight = arrivals a second × seconds each. At 46.3 requests a second, a 200 ms service holds 46.3 × 0.2 = 9 at once. Ask's simulated mean is 5.5 s, so 46.3 × 5.5 = 255 in flight, each an open, streaming connection. Size replicas by concurrent streams, not CPU.
2. Caching, queueing, and backpressure (Essential)
A model call you skip is the biggest saving there is. When calls pile up, how you queue them decides whether users wait seconds or minutes.
A response cache returns a stored answer to a repeated question, and its key is where AI systems bite. "What is the daily meal limit?" has a different answer at Tallis than at Brennan & Kloss, so the key holds the tenant, the normalised question, the prompt version and the document versions. Leave out the tenant and a speed feature becomes a cross-tenant leak.
By hand: 100,000 users × 10 = 1,000,000 requests a day, at $0.000600 each (2,000 in, 500 out on gpt-oss-120b, course-guide/02, checked 18 October 2026): $600 a day. Each 10 points of hit rate saves $60.
A queue holds work the user need not wait for: Intake's 1,100,000 documents and the weekly Digest, drained by workers at their own pace.
Backpressure is a full system saying "not now" instead of silently falling behind. In Run it, 16 model-call slots serve twice the busiest five minutes. An unlimited queue makes the p95 wait 176.7 s. With at most 16 waiting, it stays at 7.0 s, and 508 requests get a fast "try again".
The course runs RQ on Redis (Module 8). Amazon SQS is the managed comparison, not run here: its documentation (checked 18 October 2026) says a FIFO queue adds no duplicate when you retry a send "within the 5-minute deduplication interval", unlike standard queues.
3. Timeouts, retries, idempotency (Essential)
Any provider call can hang, and retrying a call that writes can write twice. With a model in the loop the timeout is seconds long, and the write is a ticket in a tenant's own system.
A timeout is how long you wait before giving up; Northdesk's gateway allows 8 seconds. Aisha's Cases request calls tickets.create. The first try has not replied after 8,000 ms, so the caller pauses briefly and tries again; the second try replies in 200 ms, 8,269 ms in all. A random pause, jitter, stops clients retrying in step.
The trap: the first call did create the ticket; only its reply was late. A plain retry opens a second one. An idempotency key, one id per user action sent with every attempt, lets the tool return the first result. GoDaddy found "basic retry logic in calls to LLMs mitigates most of the reliability problems", at a cost "compounded by the inherent latency of LLM calls".
Retries add load at the worst moment: on 22 September, 124 sampled Ask requests made 155 model calls. Module 11 caps this with a retry budget and a circuit breaker.
4. Separation of concerns and service boundaries (Essential)
When Ask is slow, Funmi needs to know which part is slow and who owns it. When a provider changes, one component should change, not forty files.
Separation of concerns means each component does one job: authentication, session, retrieval, prompt building, the model gateway, output validation, each with an owner and a latency budget. Lesson 1 could show the model took 3,487 ms of a median request only because each hop was its own span.
A service boundary is the contract at a component's edge. Northdesk fixes its contracts early: retrieval returns list[Hit] already filtered by tenant and groups, and every model call goes through Gateway.complete(request), returning a Completion with text, provider, tokens, cost and latency. GitHub's equivalent is copilot-proxy.
5. Data modelling and storage choice (Essential)
An AI system stores more kinds of data than a classical app, and a wrong store is a migration you pay for later.
Choose the store from the data's shape and how it is read. Tenants, users, document versions and permissions are relational: PostgreSQL. Sessions are small, hot and expire: Redis. Embeddings are searched by similarity: pgvector or Qdrant. Raw uploads are large files: an object store. Traces are written once and read in bulk.
Data modelling is deciding what one row is. A trace is a tree, but it is stored flat: one row per span, with parent_id to rebuild the tree. Run it loads 1,320 simulated Ask traces into SQLite as 15,871 rows.
6. Observability and incident response (Essential)
AI failures are often quiet, so you need your own measurements at your own boundary, and a practised routine for when the provider slows.
Observability means answering new questions about the running system from what it records. Group 22 September's model calls into 10-minute windows: 8 of 42 timed out in the 13:00 window, none before. A rule that fires above 5% alerts in that first window. Cheney said that from his upstream provider's metrics "we barely get averages".
Incident response is the routine: detect, mitigate, resolve, review. On 22 September the mitigation was the fallback to the secondary provider; the slowdown ended at 13:40; the review becomes a failure-mode row and a test (Module 11 replays it).
The course runs a hand-built tracer, then OpenTelemetry with Phoenix or Langfuse, both self-hostable (Module 13). Hosted products such as LangSmith and Datadog are named only. For Leila the deciding question is where prompts and answers, which hold tenant data, are stored.
7. Capacity planning and load testing (Essential)
A capacity plan says how much you need before traffic arrives; a load test proves it. With a model inside, the limit is often the provider's rate or concurrency, not your CPU.
Capacity planning is a chain of multiplications: 1,000,000 requests a day ÷ 86,400 = 11.6 a second; × a peak factor of 4 = 46.3; × 5.5 s = 255 in flight. Every hop, and the provider's limits, must hold 255 open requests. Module 3 builds the estimator.
A load test sends rising traffic and watches where waiting jumps. Run it steps the queue from half the busiest five minutes to twice it. With 16 slots the p95 wait is 2.6 s at the peak and 59.0 s at 1.5 times it: too little headroom for another Tallis.
Run it
No model calls, so no key. Paste shared-data blocks 1, 3 and 4 from course-guide/06, unchanged, in order.
import random
from datetime import date, datetime, timedelta
COURSE_DATE = date(2026, 10, 18) # versions, model names and prices checked on this day
DATA_AS_OF = date(2026, 10, 5) # the snapshot: count days to this, never to the clock
WINDOW = (date(2026, 9, 7), date(2026, 10, 4)) # 28 days of traffic and traces, Monday to Sunday
SEED = 20261005
EVENTS = { # fixed dates the data and the lessons share (times are UTC)
"tallis_onboarded": date(2026, 9, 14), # Tallis Retail Group switches Ask on for all stores
"primary_degraded": (datetime(2026, 9, 22, 13, 0), datetime(2026, 9, 22, 13, 40)),
"prompt_v8": datetime(2026, 9, 29, 10, 0), # Ask prompt v7 -> v8: longer answers, 12 chunks not 8
}
FLEET = { # the whole Northdesk fleet in October 2026 (fixed facts, simulated)
"tenants": 240, "seats": 410_000, "ask_dau": 38_000, "ask_requests_per_dau": 6.2,
"cases_runs_per_day": 9_000, "documents": 1_100_000, "avg_tokens_per_document": 6_000,
"seat_price_usd_month": {"ask": 8.00, "cases": 4.00},
}
TARGET = { # Joel's design target for Q2 2027: the roadmap's worked estimate
"ask_dau": 100_000, "requests_per_user": 10, "input_tokens": 2_000, "output_tokens": 500,
"target_seconds": 3.0, "peak_factor": 4, "cases_runs_per_day": 60_000,
}
TENANTS = [ # the six tenants in the sample data (fictional)
# id, name, country, residency, seats, utc_offset_hours, currency, isolation, features
("t-fairhaven", "Fairhaven Housing", "UK", "uk", 2_400, 1, "GBP", "shared", ("ask",)),
("t-brennan", "Brennan & Kloss Logistics", "DE", "eu", 9_800, 2, "EUR", "shared", ("ask", "cases")),
("t-halcyon", "Halcyon Health Partners", "US", "us", 31_000, -4, "USD", "siloed", ("ask",)),
("t-tallis", "Tallis Retail Group", "UK", "uk", 54_000, 1, "GBP", "shared", ("ask", "cases")),
("t-oriel", "Oriel Software", "US", "us", 1_200, -7, "USD", "shared", ("ask", "cases")),
("t-marlow", "Marlow County Council", "UK", "uk", 6_500, 1, "GBP", "hybrid", ("ask",)),
]
TENANT_IDS = [t[0] for t in TENANTS]
STAFF = ["Helen", "Joel", "Funmi", "Ana", "Leila", "Gareth"]
GROUPS = ["all-staff", "hr", "it", "finance", "facilities", "operations", "managers", "hr-confidential", "execs"]
DEPARTMENTS = ["hr", "it", "finance", "facilities", "operations"]
FIRST = ["Aisha", "Ben", "Chloe", "Dmitri", "Elena", "Femi", "Grace", "Hamza", "Isla", "Jonas", "Keiko", "Liam",
"Maya", "Nikhil", "Olga", "Pedro", "Quinn", "Rania", "Sven", "Tara", "Umar", "Vera", "Wei", "Yara"]
LAST = ["Abbott", "Bakshi", "Costa", "Dunne", "Eriksen", "Fofana", "Gill", "Horvath", "Iqbal", "Jansen", "Kaur",
"Lopes", "Moreau", "Nwosu", "Ortiz", "Park", "Quigley", "Rossi", "Sato", "Tiwari", "Ueda", "Vance", "Weber", "Zielinski"]
def make_users(seed=SEED):
"""60 users per tenant: department, groups and role. Emails use the reserved .example domain."""
rng = random.Random(seed + 1)
users = []
for tid, name, *_ in TENANTS:
domain = tid[2:] + ".example"
for i in range(60):
dept = DEPARTMENTS[i % 5]
groups = ["all-staff", dept]
role = "employee"
if i % 6 == 0:
groups.append("managers"); role = "manager"
if dept == "hr" and i % 3 == 0:
groups.append("hr-confidential")
if i in (0, 1):
groups.append("execs"); role = "executive"
if dept == "it" and i % 4 == 1:
role = "it-admin"
first, last = rng.choice(FIRST), rng.choice(LAST)
users.append({"user_id": f"{tid}-u{i:03d}", "tenant_id": tid, "name": f"{first} {last}",
"email": f"{first.lower()}.{last.lower()}{i}@{domain}", "department": dept,
"role": role, "groups": sorted(set(groups))})
return users
USERS = make_users()
print(len(USERS), "users;", sum("hr-confidential" in u["groups"] for u in USERS), "can read hr-confidential;",
USERS[0]["user_id"], USERS[0]["groups"])Code walkthrough: block 1, unchanged: dates, events, TARGET, tenants, users.
HOUR_WEIGHT = [0.02] * 7 + [0.3, 1.0, 1.6, 1.3, 1.1, 0.8, 1.0, 1.2, 1.0, 0.8, 0.5, 0.25] + [0.08] * 5 # local hour
DAY_WEIGHT = [1.2, 1.0, 1.0, 1.0, 0.8, 0.07, 0.07] # Monday to Sunday
ASK_RATE = FLEET["ask_dau"] / FLEET["seats"] * FLEET["ask_requests_per_dau"] # Ask requests per seat per working day
CASES_RATE = 0.03 # Cases runs per seat per working day
def make_traffic(seed=SEED):
"""Requests per 5-minute bucket (UTC) for each tenant and feature over the 28-day window."""
rng = random.Random(seed + 4)
rows = []
start = datetime(WINDOW[0].year, WINDOW[0].month, WINDOW[0].day)
for b in range(28 * 288):
ts = start + timedelta(minutes=5 * b)
for tid, name, country, res, seats, off, cur, iso, feats in TENANTS:
local = ts + timedelta(hours=off)
share = HOUR_WEIGHT[local.hour] / sum(HOUR_WEIGHT) / 12 * DAY_WEIGHT[local.weekday()]
active = seats
if tid == "t-tallis": # 10% of stores before 14 September, then a three-day ramp to all
ramp = (ts.date() - EVENTS["tallis_onboarded"]).days
active = seats * (0.1 if ramp < 0 else min(1.0, 0.1 + 0.3 * (ramp + 1)))
for feature in feats:
mu = active * (ASK_RATE if feature == "ask" else CASES_RATE) * share
count = max(0, round(rng.gauss(mu, mu ** 0.5))) if mu > 0 else 0
rows.append((ts.strftime("%Y-%m-%dT%H:%M"), tid, feature, count))
return rows
TRAFFIC = make_traffic()
ask_rows = [r for r in TRAFFIC if r[2] == "ask"]
per_bucket = {}
for ts, tid, feature, count in ask_rows:
per_bucket[ts] = per_bucket.get(ts, 0) + count
total = sum(per_bucket.values())
peak_ts = max(per_bucket, key=per_bucket.get)
avg_rps, peak_rps = total / (28 * 86_400), per_bucket[peak_ts] / 300
print(len(TRAFFIC), "rows;", total, "Ask requests in 28 days;", f"average {avg_rps:.2f} RPS,",
f"peak {peak_rps:.2f} RPS at {peak_ts} UTC, peak factor {peak_rps / avg_rps:.1f}x")Code walkthrough: block 3, unchanged: traffic; it leaves peak_rps, the busiest five minutes.
def make_traces(n_ask=1200, n_cases=240, seed=SEED):
"""Sampled request traces: each is a tree of spans with per-hop latency (ms) and token counts."""
rng = random.Random(seed + 5)
weights = {}
for ts, tid, feature, count in TRAFFIC:
weights[(ts, tid, feature)] = count
deg_start, deg_end = EVENTS["primary_degraded"]
def sample(feature, k, inside=None):
keys = sorted(x for x in weights if x[2] == feature and (inside is None or inside(x[0])))
return rng.choices(keys, weights=[weights[x] for x in keys], k=k)
in_incident = lambda ts: deg_start <= datetime.fromisoformat(ts) < deg_end
# the incident window is kept at a higher rate, as a tail-sampling tracer keeps slow and failed requests
ask_keys = sorted(sample("ask", n_ask) + sample("ask", 120, in_incident))
case_keys = sorted(sample("cases", n_cases))
traces = []
def span(spans, name, parent, start, dur, **attrs):
sid = f"s{len(spans) + 1:02d}"
spans.append({"span_id": sid, "parent_id": parent, "name": name, "start_ms": round(start),
"duration_ms": max(1, round(dur)), "attrs": attrs})
return sid, start + dur
def model_call(spans, parent, t, ts, tier, tokens, long_output):
degraded = deg_start <= ts < deg_end
provider = "primary"
if degraded and rng.random() < 0.3: # the primary provider times out at the gateway's 8-second limit
_, t = span(spans, "model.call", parent, t, 8000, provider="primary", model_tier=tier, status="timeout", **tokens)
provider, tier = "secondary", "small"
out = max(40, min(900, round(rng.gauss(*long_output))))
tps = rng.gauss(160, 25) if tier == "small" else rng.gauss(52, 8)
ttft = rng.uniform(120, 250) + tokens["input_tokens"] / 6000 * 1000
queue = rng.uniform(0, 40)
if degraded and provider == "primary":
ttft, queue = ttft * 4, queue + rng.uniform(200, 900)
reasoning = round(rng.uniform(0, 300)) if tier == "large" else 0
decode = (out + reasoning) / tps * 1000
_, t = span(spans, "model.call", parent, t, queue + ttft + decode, provider=provider, model_tier=tier,
status="ok", queue_ms=round(queue), ttft_ms=round(ttft), decode_ms=round(decode),
output_tokens=out, reasoning_tokens=reasoning, **tokens)
return t, provider
for i, (ts_s, tid, _f) in enumerate(ask_keys):
ts = datetime.fromisoformat(ts_s) + timedelta(seconds=rng.randrange(300))
v8 = ts >= EVENTS["prompt_v8"]
spans = []
root, _ = span(spans, "ask.request", None, 0, 0)
_, t = span(spans, "gateway.auth", root, 0, rng.uniform(5, 15))
_, t = span(spans, "session.load", root, t, rng.uniform(3, 10))
ret, _ = span(spans, "retrieve", root, t, 0)
_, t1 = span(spans, "retrieve.embed", ret, t, rng.uniform(15, 40))
_, t2 = span(spans, "retrieve.vector", ret, t1, rng.uniform(20, 60))
_, t3 = span(spans, "retrieve.bm25", ret, t, rng.uniform(8, 25))
k = 12 if v8 else 8
found = rng.random() > 0.03
_, t = span(spans, "retrieve.rerank", ret, max(t2, t3), rng.uniform(60, 180), candidates=40, kept=k if found else 0)
spans[int(ret[1:]) - 1]["duration_ms"] = round(t - spans[int(ret[1:]) - 1]["start_ms"])
_, t = span(spans, "prompt.build", root, t, rng.uniform(2, 6))
turns = rng.choice([0, 0, 1, 1, 2, 3, 4])
system = 700 if v8 else 650
context = k * 160 if found else 0
user = rng.randrange(20, 80)
tokens = {"system_tokens": system, "tool_schema_tokens": 0, "history_tokens": turns * 180,
"context_tokens": context, "user_tokens": user}
tokens["input_tokens"] = sum(tokens.values())
tokens["cached_tokens"] = system if rng.random() < 0.6 else 0
tier = "small" if rng.random() < 0.6 else "large"
mean_out = ((365, 110) if v8 else (260, 90)) if found else (45, 5)
t, provider = model_call(spans, root, t, ts, tier, tokens, mean_out)
_, t = span(spans, "validate.output", root, t, rng.uniform(5, 20))
_, t = span(spans, "session.save", root, t, rng.uniform(3, 8))
spans[0]["duration_ms"] = round(t)
status = "no_context" if not found else ("fallback" if provider == "secondary" else "ok")
traces.append({"trace_id": f"ask-{i + 1:04d}", "ts": ts.isoformat(), "tenant_id": tid, "feature": "ask",
"prompt_version": "v8" if v8 else "v7", "status": status, "spans": spans})
tools = [("tool.hris.get_employee", 80, 300), ("tool.assets.get_device", 100, 250),
("tool.tickets.create", 150, 400), ("tool.hris.get_leave_balance", 80, 250)]
for i, (ts_s, tid, _f) in enumerate(case_keys):
ts = datetime.fromisoformat(ts_s) + timedelta(seconds=rng.randrange(300))
spans = []
root, t = span(spans, "cases.run", None, 0, 0)
steps = rng.choice([2, 3, 3, 4, 4, 5, 6])
outcome = rng.choices(["resolved", "approval_requested", "escalated", "stopped_max_steps"], [70, 18, 10, 2])[0]
history = 0
for s in range(steps):
step, _ = span(spans, "agent.step", root, t, 0, step=s + 1)
step_start = t
tokens = {"system_tokens": 900, "tool_schema_tokens": 1400, "history_tokens": history,
"context_tokens": 0, "user_tokens": rng.randrange(20, 80)}
tokens["input_tokens"] = sum(tokens.values())
tokens["cached_tokens"] = 2300 if rng.random() < 0.7 else 0
t, _p = model_call(spans, step, t, ts, "large", tokens, (120, 40))
name, lo, hi = rng.choice(tools)
_, t = span(spans, name, step, t, rng.uniform(lo, hi))
history += 350
spans[int(step[1:]) - 1]["duration_ms"] = round(t - step_start)
if outcome == "approval_requested":
_, t = span(spans, "approval.wait", root, t, rng.uniform(2, 40) * 60_000)
spans[0]["duration_ms"] = round(t)
traces.append({"trace_id": f"cases-{i + 1:04d}", "ts": ts.isoformat(), "tenant_id": tid, "feature": "cases",
"prompt_version": "c3", "status": outcome, "spans": spans})
return traces
TRACES = make_traces()
ask_tr = [t for t in TRACES if t["feature"] == "ask"]
lat = sorted(t["spans"][0]["duration_ms"] for t in ask_tr)
print(len(TRACES), "traces;", len(ask_tr), "Ask;", f"Ask p50 {lat[len(lat) // 2]} ms, p95 {lat[int(len(lat) * 0.95)]} ms;",
{s: sum(t["status"] == s for t in ask_tr) for s in ("ok", "fallback", "no_context")})Code walkthrough: block 4, unchanged: 1,560 simulated traces.
Folders, then Little's law on the data.
import os
import statistics
os.makedirs("northdesk/basics", exist_ok=True) # %%writefile does not create folders
for path in ("northdesk/__init__.py", "northdesk/basics/__init__.py"):
open(path, "a").close()
ask_traces = [t for t in TRACES if t["feature"] == "ask"]
mean_s = statistics.mean(t["spans"][0]["duration_ms"] for t in ask_traces) / 1000
target_avg = TARGET["ask_dau"] * TARGET["requests_per_user"] / 86_400
target_peak = target_avg * TARGET["peak_factor"]
print(f"Ask mean latency {mean_s:.1f} s (simulated)")
print(f"busiest five minutes: {peak_rps:.2f} RPS x {mean_s:.1f} s = {peak_rps * mean_s:.0f} in flight")
print(f"target: {target_avg:.1f} RPS average, {target_peak:.1f} at peak")
print(f"in flight at the target peak: {target_peak * mean_s:.0f} at {mean_s:.1f} s, {target_peak * 0.2:.0f} at 0.2 s")Code walkthrough
os.makedirsand the empty__init__.pyfiles makenorthdesk/basics/importable: small teaching versions, not the production components later modules build.mean_sis the mean Ask latency;target_avgandtarget_peakturn Joel's target into requests a second; the prints multiply them.
Ask mean latency 5.5 s (simulated)
busiest five minutes: 2.40 RPS x 5.5 s = 13 in flight
target: 11.6 RPS average, 46.3 at peak
in flight at the target peak: 255 at 5.5 s, 9 at 0.2 sThe first component: replicas and a load balancer.
%%writefile northdesk/basics/replicas.py
"""Replicas behind a round-robin load balancer, simulated in one process (no network)."""
from itertools import cycle
class Replica:
def __init__(self, name, store=None):
self.name = name
self.local = {} # in-process memory: lost on restart, invisible to the other replicas
self.store = store # shared session store (a dict standing in for Redis); None = keep state locally
self.up = True
def handle(self, session_id, question):
sessions = self.local if self.store is None else self.store
history = sessions.setdefault(session_id, [])
seen = len(history) # earlier turns this replica can see
history.append(question)
return self.name, seen
class Balancer:
"""Send each request to the next healthy replica in turn."""
def __init__(self, replicas):
self.replicas = replicas
self.order = cycle(replicas)
def route(self, session_id, question):
for _ in range(len(self.replicas)):
replica = next(self.order)
if replica.up:
return replica.handle(session_id, question)
raise RuntimeError("no healthy replica")Code walkthrough
handlecounts the earlier turns it can see (seen) and appends the question.store=Nonekeeps history inself.local, inside one replica; a shared dict stands in for Redis.upmarks a healthy replica.routetakes replicas in turn withcycleand skips any that are down.
from northdesk.basics.replicas import Replica, Balancer
TURNS = ["What is the daily meal limit?", "Does that apply abroad?", "And on a two-day trip?",
"Who approves the claim?", "How long do I have to submit it?", "Can I claim a taxi?"]
SESSION = "t-brennan-u002/s1" # Aisha Moreau's conversation (added in this lesson)
for label, store in (("state in each replica", None), ("shared session store", {})):
replicas = [Replica(name, store) for name in "abc"]
balancer = Balancer(replicas)
results = []
for turn, question in enumerate(TURNS):
if turn == 3:
replicas[1].up = False # replica b fails after the third turn
results.append(balancer.route(SESSION, question))
full = sum(seen == turn for turn, (_, seen) in enumerate(results))
print(f"{label:22} {results} full history on {full} of {len(TURNS)} turns")Code walkthrough: six turns run twice, local state then shared store; b fails before turn four; seen == turn means full history.
state in each replica [('a', 0), ('b', 0), ('c', 0), ('a', 1), ('c', 1), ('a', 2)] full history on 1 of 6 turns
shared session store [('a', 0), ('b', 1), ('c', 2), ('a', 3), ('c', 4), ('a', 5)] full history on 6 of 6 turnsNext, a queue in front of a fixed number of model-call slots.
%%writefile northdesk/basics/queue_sim.py
"""A queue in front of a fixed number of model-call slots, simulated in time (seconds)."""
import heapq
import random
from collections import deque
def simulate(rps, seconds, slots, service_times, bound=None, seed=0):
"""Arrivals at `rps` for `seconds`; each holds one of `slots` for a sampled service time.
bound: most requests allowed to wait; arrivals beyond it are rejected at once (backpressure)."""
rng = random.Random(seed)
free = [0.0] * slots # when each slot is next free
waiting = deque() # start times of accepted requests not yet started
waits, rejected, t = [], 0, 0.0
while True:
t += rng.expovariate(rps) # next arrival (random, at the given average rate)
if t > seconds:
break
while waiting and waiting[0] <= t:
waiting.popleft()
if bound is not None and len(waiting) >= bound:
rejected += 1 # a fast "try again" instead of a long wait
continue
start = max(t, heapq.heappop(free))
heapq.heappush(free, start + rng.choice(service_times))
if start > t:
waiting.append(start)
waits.append(start - t)
waits.sort()
p95 = waits[int(len(waits) * 0.95)] if waits else 0.0
return {"accepted": len(waits), "rejected": rejected, "p95_wait_s": round(p95, 1)}Code walkthrough
freeholds when each slot is next free;heapq.heappoptakes the earliest.rng.expovariate(rps)draws random gaps between arrivals.- With a
bound, an arrival that findswaitingfull is rejected at once. - An accepted request holds a slot for a sampled service time; the function returns the p95 wait.
The load test:
from northdesk.basics.queue_sim import simulate
service = [t["spans"][0]["duration_ms"] / 1000 for t in ask_traces] # seconds each, from the traces
SLOTS, BOUND = 16, 16
for multiple in (0.5, 1, 1.25, 1.5, 2):
rps = peak_rps * multiple
open_queue = simulate(rps, 300, SLOTS, service, bound=None, seed=SEED)
bounded = simulate(rps, 300, SLOTS, service, bound=BOUND, seed=SEED)
print(f"{multiple:>4}x peak ({rps:.2f} RPS) unbounded p95 wait {open_queue['p95_wait_s']:>6} s"
f" | bounded p95 wait {bounded['p95_wait_s']:>4} s, rejected {bounded['rejected']}")Code walkthrough: service holds the traces' durations; SLOTS (16 calls in flight) and BOUND (16 waiting) are simulated limits; each step runs 300 seconds without and with the bound.
0.5x peak (1.20 RPS) unbounded p95 wait 0.0 s | bounded p95 wait 0.0 s, rejected 0
1x peak (2.40 RPS) unbounded p95 wait 2.6 s | bounded p95 wait 2.6 s, rejected 0
1.25x peak (3.00 RPS) unbounded p95 wait 8.8 s | bounded p95 wait 5.4 s, rejected 45
1.5x peak (3.60 RPS) unbounded p95 wait 59.0 s | bounded p95 wait 6.5 s, rejected 172
2x peak (4.81 RPS) unbounded p95 wait 176.7 s | bounded p95 wait 7.0 s, rejected 508Sixteen slots at 5.5 s each serve 16 ÷ 5.5 = 2.9 requests a second, so waits explode just above the peak.
A timeout, a jittered retry, and a scripted stand-in (a fake) for tickets.create.
%%writefile northdesk/basics/retry.py
"""Timeouts, retries with jittered backoff and an idempotency key, on a simulated clock (ms)."""
class SlowTickets:
"""Scripted stand-in for tickets.create: the first call creates the ticket, but replies too late."""
def __init__(self, slow_ms=9_000, fast_ms=200):
self.tickets, self.by_key, self.calls = [], {}, 0
self.slow_ms, self.fast_ms = slow_ms, fast_ms
def create(self, summary, key=None):
self.calls += 1
if key in self.by_key: # seen this key: return the first result, do nothing new
return self.by_key[key], self.fast_ms
ticket = f"TCK-{len(self.tickets) + 1:03d}"
self.tickets.append((ticket, summary)) # the side effect happens whether or not we wait for it
if key is not None:
self.by_key[key] = ticket
return ticket, self.slow_ms if self.calls == 1 else self.fast_ms
def call_with_retry(call, timeout_ms, attempts, rng, base_ms=250):
"""Try up to `attempts` times; give up waiting on each try at timeout_ms. Returns (result, ms, tries)."""
clock = 0.0
for n in range(attempts):
result, took_ms = call()
if took_ms <= timeout_ms:
return result, round(clock + took_ms), n + 1
clock += timeout_ms # we stopped waiting; the work may still have been done
clock += rng.uniform(0, base_ms * 2 ** n) # backoff with full jitter before the next try
return None, round(clock), attemptsCode walkthrough
createmakes a ticket per new call; its first reply takes 9,000 ms, later ones 200 ms.by_keyreturns the earlier ticket for a repeated key.call_with_retryadds simulated time: pasttimeout_msit charges the timeout plus a random pause up tobase_ms * 2 ** n, doubling each try.
Observability: spans as rows, 22 September in 10-minute windows.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE spans (trace_id TEXT, ts TEXT, span_id TEXT, parent_id TEXT,"
" name TEXT, duration_ms INTEGER, status TEXT)")
db.executemany("INSERT INTO spans VALUES (?, ?, ?, ?, ?, ?, ?)",
[(t["trace_id"], t["ts"], s["span_id"], s["parent_id"], s["name"], s["duration_ms"],
s["attrs"].get("status")) for t in ask_traces for s in t["spans"]])
print(db.execute("SELECT COUNT(DISTINCT trace_id), COUNT(*) FROM spans").fetchone(), "traces and span rows")
QUERY = """SELECT substr(ts, 12, 4) || '0' AS window, COUNT(*), SUM(status = 'timeout')
FROM spans WHERE name = 'model.call' AND ts >= '2026-09-22T12:30' AND ts < '2026-09-22T14:10'
GROUP BY window ORDER BY window"""
for window, calls, timeouts in db.execute(QUERY):
print(f"{window} UTC model calls {calls:>2} timeouts {timeouts:>2}", "ALERT" if timeouts / calls > 0.05 else "")Code walkthrough
- One row per span with
parent_id, the data model from section 5. substr(ts, 12, 4) || '0'turns13:07:41into13:00; the query counts calls and timeouts per window; the print flags windows above 5%.- The incident was sampled at a higher rate, so compare rates, not counts.
(1320, 15871) traces and span rows
12:30 UTC model calls 1 timeouts 0
12:50 UTC model calls 1 timeouts 0
13:00 UTC model calls 42 timeouts 8 ALERT
13:10 UTC model calls 47 timeouts 10 ALERT
13:20 UTC model calls 36 timeouts 8 ALERT
13:30 UTC model calls 30 timeouts 5 ALERT
13:40 UTC model calls 1 timeouts 0
13:50 UTC model calls 4 timeouts 0 Break it
Retry Aisha's Cases request the obvious way, then with an idempotency key.
import random
from northdesk.basics.retry import SlowTickets, call_with_retry
for key in (None, "t-brennan-u002/cases-req-1"):
tool = SlowTickets()
result = call_with_retry(lambda: tool.create("cracked laptop screen", key=key),
timeout_ms=8_000, attempts=3, rng=random.Random(SEED))
print(f"key={key}: returned {result[0]} after {result[1]} ms in {result[2]} tries;",
f"tickets in the system: {[t for t, _ in tool.tickets]}")Code walkthrough: the request runs without and with a key; the lambda lets call_with_retry repeat it under the 8,000 ms timeout; the print shows the caller's result and the tickets that exist.
Run this and you will see:
key=None: returned TCK-002 after 8269 ms in 2 tries; tickets in the system: ['TCK-001', 'TCK-002']
key=t-brennan-u002/cases-req-1: returned TCK-001 after 8269 ms in 2 tries; tickets in the system: ['TCK-001']Both callers saw success, but without the key the helpdesk holds two tickets; on assets.request_item it would be two laptops.
In production
In Cheney's talk, proxies whose success rate drops "vote themselves out of DNS" and return once above the SLO, which turns a regional outage into "just reduction in capacity because traffic is routed to other regions". GitHub's own proxy metrics showed requests cancelled about 1 millisecond after sending, which the provider's averages could not; the fix was to check for cancellation just before calling the model. GoDaddy, behind a 30-second upstream timeout, moved to asynchronous responses and recommends streaming. Northdesk builds each piece with numbers: sessions and the job queue (Module 8), caching and admission control (Module 10), retry budgets and circuit breakers (Module 11), tracing (Module 13).
What you added to Northdesk
northdesk/basics/replicas.py, queue_sim.py and retry.py, and six rows for the failure-mode table the Module 1 lab saves:
| Component | Failure | Detection | Fail | Fallback | Blast radius |
|---|---|---|---|---|---|
| Ask replica | dies | health check | open | other replicas | requests in flight |
| Session state | kept in a replica | lost-history test | closed | shared store | one conversation |
| Model-call queue | unbounded backlog | p95 queue wait | closed | reject fast | everyone waiting |
| Response cache | no tenant in key | cross-tenant probe | closed | skip cache | two tenants |
| Write retry | duplicate action | actions per key | closed | idempotency key | one tenant |
| Provider | timeouts rise | timeouts over 5% | open | secondary | all tenants on it |
The question a reviewer will ask
Gareth: "Keeping the conversation in the replica's memory is faster. Why pay for Redis?"
Because then the conversation belongs to one replica: round-robin gave full history on 1 of 6 turns, and a failed replica loses what it held. A shared store gave 6 of 6. A Redis lookup takes milliseconds; the model takes seconds.
Check yourself
- Ask's mean latency doubles at the same traffic. What happens to requests in flight?
- Why does a cache key need the prompt version?
- A retry after a timeout created a duplicate. Was the timeout wrong?
- Why reject requests rather than queue them all?
- They double: at the target peak, 255 becomes 510.
- A new prompt can answer differently; the old answer would hide the change.
- No. The call worked and replied late; an idempotency key makes the repeat return the first result.
- At twice the peak the unbounded queue's p95 wait was 176.7 s; the bounded one kept 7.0 s and told 508 callers at once.
Exercise
Write cache_key(tenant_id, question, prompt_version, doc_versions): the same key for the same question in other spacing or case, a different key when the tenant, prompt version or any document version differs. Then run the check.
def check_cache_key(fn):
q, docs = "What is the daily meal limit?", {"t-brennan-d003": 1}
base = fn("t-brennan", q, "v8", docs)
assert base == fn("t-brennan", " what is the DAILY meal limit? ", "v8", docs), "spacing or case changed the key"
assert base != fn("t-tallis", q, "v8", {"t-brennan-d003": 1}), "two tenants share a key: a cross-tenant leak"
assert base != fn("t-brennan", q, "v7", docs), "the prompt version is missing from the key"
assert base != fn("t-brennan", q, "v8", {"t-brennan-d003": 2}), "a document version is missing from the key"
print("all four checks passed")Code walkthrough: one base key, then four variations; each assertion names the fault it catches.
import hashlib
import json
def cache_key(tenant_id, question, prompt_version, doc_versions):
normalised = " ".join(question.lower().split())
parts = [tenant_id, normalised, prompt_version, sorted(doc_versions.items())]
return hashlib.sha256(json.dumps(parts).encode()).hexdigest()
check_cache_key(cache_key)Code walkthrough: lower().split() and " ".join remove case and extra spaces; sorted fixes the order of document versions; sha256 makes one fixed-length key.
Key terms
| Term | Meaning |
|---|---|
| Stateless replica | A copy that keeps nothing another copy needs |
| Little's law | In flight = arrivals a second × seconds each |
| Response cache | Stored answers, keyed by tenant, question and versions |
| Backpressure | Refusing new work when full instead of queueing without limit |
| Jitter | A random pause before a retry |
| Idempotency key | One id per user action, so a repeat returns the first result |
| Service boundary | The fixed contract at a component's edge |
| Load test | Rising traffic sent to find where waiting jumps |
Sources
- David Cheney (GitHub), "How GitHub Copilot Serves 400 Million Completion Requests a Day", QCon San Francisco, InfoQ recording dated 24 March 2025. https://www.infoq.com/presentations/github-copilot/
- GoDaddy Digital Care team, "LLM From the Trenches: 10 Lessons Learned Operationalizing Models at GoDaddy", 1 February 2024. https://www.godaddy.com/resources/news/llm-from-the-trenches-10-lessons-learned-operationalizing-models-at-godaddy
- Amazon Web Services, "Exactly-once processing in Amazon SQS", checked 18 October 2026. https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html