CourseAI System Design: The Complete Course · Module 1: How AI Systems Differ · part 9 of 107
Part 9 · Module 1: How AI Systems Differ

Lesson 2: What still carries over from classical design

25 min read·
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.)

Seven disciplines, one request Where each sits on Aisha's request Active flow Background or async 1 Processing step number Client Interface Aisha API Layer balancer 1 stateless, scaled out 7 capacity replica 1 stateless, scaled out 4 boundaries Cache cache 2 cache, queue Storage Redis stores session and trace 5 data and storage LLM Generation Layer model 3 timeout, retry gateway 3 timeout, retry 6 observability 1 2 3 4 5
The seven classical disciplines sit on Aisha's request path (Northdesk's own layers) where they always sat; the model (grey, managed, not run by Northdesk) changes their numbers, not their place. Black circles give the request's order; the boxes carry the lesson's discipline numbers.

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".

CASE STUDY Copilot's published scale 400M+ completions a day 8,000 a second at peak <200 ms mean at peak 45% typed through, cancelled
Copilot's published scale: more than 400 million completions a day, about 8,000 a second at peak, a mean under 200 ms, and about 45% typed through and cancelled (amber, wasted work).

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.

One proxy owns the boundary GitHub's copilot-proxy between the IDE and the model Active flow 1 Processing step number Client Interface IDE brief token Boundary GitHub owns copilot-proxy same region as model swaps key streams cancels per stream unhealthy votes itself out of DNS SLO measured here LLM Generation Layer model real API key 1 2
copilot-proxy is a boundary GitHub owns (green, its own layer): it swaps the short-lived token for the real key, streams, cancels one HTTP/2 stream at a time, and is where the SLO is measured (purple).

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.
OLD NAME, NEW NUMBER Same disciplines, new numbers DISCIPLINE NORTHDESK'S NUMBER stateless replicas 255 requests in flight cache, queue $600 a day to save from timeout, retry 8 s timeout on a model call boundaries model behind one gateway storage five stores, five shapes observability alert in 10 minutes capacity knee near 1.25x the peak
Each discipline keeps its name (blue); what is new is the number beside it (amber), which this lesson measures on Northdesk's data.

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.

COMPARE Where the session lives STATE IN EACH REPLICA 1 of 6 right turn 1 replica a sees 0 turn 2 replica b sees 0 turn 3 replica c sees 0 turn 4 replica a sees 1 turn 5 replica c sees 1 turn 6 replica a sees 2 SHARED SESSION STORE 6 of 6 right turn 1 replica a sees 0 turn 2 replica b sees 1 turn 3 replica c sees 2 turn 4 replica a sees 3 turn 5 replica c sees 4 turn 6 replica a sees 5 sees: earlier turns visible to this replica
Aisha's six turns round-robin over three replicas: with state kept in each replica only the first turn sees its full history (red, wrong context); with a shared store all six do (green).

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.

A replica dies mid-conversation The balancer skips replica b Active flow 1 Processing step number Client Interface turn 4 API Layer balancer replica a replica b down replica c skipped Storage Redis store all turns 1 2 3
When replica b fails (pale red), the balancer sends the next turn to a or c, and both read the same store (yellow, storage), so no conversation is lost.

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).

BY THE NUMBERS Seconds each set the concurrency in flight = 46.3 requests a second x seconds each 9 in flight 200 ms service 255 in flight Ask, 5.5 s (simulated)
Little's law at Joel's peak: a 200 ms service (blue) holds 9 requests at once; Ask at its simulated 5.5 s mean (amber) holds 255, so replicas are sized by open streams.

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.

ANATOMY Four parts of a cache key RESPONSE-CACHE KEY tenant t-brennan question normalised prompt v8 documents versions leave it out cross-tenant leak leave these out a stale answer
A response-cache key needs four parts; drop the tenant (red) and Brennan & Kloss can be served Tallis's meal limit, drop the others and answers go stale (amber).

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 THE NUMBERS Every 10 points of hits saves $60 ASK MODEL SPEND, $ A DAY, SNAPSHOT PRICES $600 no cache -$60 10% hits -$120 30% hits $420 left 30% is illustrative
At 1,000,000 requests a day and $0.000600 each on gpt-oss-120b (checked 18 October 2026), Ask spends $600 a day; every 10 points of hit rate saves $60 (green; the 30% is illustrative).

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.

COMPARE Wait now or drain later ANSWERED IN THE REQUEST Aisha Ask model DONE LATER, THROUGH A QUEUE Intake, Digest queue workers model
Ask is answered while the user waits; Intake and the Digest go on a queue (dashed, asynchronous) so workers drain them at their own pace.

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.

BEFORE AND AFTER A bound keeps the wait short simulated, 2x the busiest 5 minutes Unbounded queue 176.7 s p95 wait 0 rejected everyone late At most 16 waiting 7.0 s p95 wait 508 told: try again fast answer
At twice the busiest five minutes (simulated), an unbounded queue serves everyone far too late (red); a bound of 16 keeps the p95 wait at 7.0 s and rejects 508 at once (green).

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".

COMPARE Run the queue or rent it RQ on Redis, open source YOU OPERATE Redis and workers DUPLICATES your idempotency key COURSE RAN fakeredis, Module 8 Amazon SQS, not run here YOU OPERATE AWS runs the queue DUPLICATES FIFO: none in 5 min COURSE RAN nothing
The queue the course runs (blue) against a managed one (grey, not run here): SQS FIFO drops a resent message within 5 minutes, per its docs checked 18 October 2026.

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.

TIMELINE One retry, 8,269 ms in all AISHA'S TICKETS.CREATE, SIMULATED CLOCK try 1: no reply by 8,000 ms 69 ms backoff, try 2: 200 ms 0 ms 4,000 ms 8,000 ms timed out jittered pause success total 8,269 ms
One retry on the simulated clock: the first try hits the 8,000 ms timeout (red), a jittered pause (grey), then a 200 ms success (green): 8,269 ms in all.

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.

GOOD VERSUS BAD A key makes the retry safe Retry, no key TCK-001 TCK-002 two tickets, one screen duplicate Retry with a key TCK-001 second call returns it one ticket
A retried write without a key opens two tickets for one cracked screen (red); with an idempotency key the second call returns TCK-001 (green).

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".

BY THE NUMBERS Retries add load at the worst time 22 SEP, 13:00 TO 13:40 UTC, SAMPLED ASK simulated Ask requests 124 model calls 155 1.25 calls per request
During the simulated 22 September slowdown, 124 Ask requests made 155 model calls (red): retries and fallback add a quarter more load just when the provider struggles.

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.

One owner per component Ask split by concern Active flow 1 Processing step number Ask, Northdesk owns auth session 1 retrieval 2 prompt 3 gateway 4 validate 5 LLM Generation Layer model third party one boundary to the model
Ask split by concern: six components Northdesk owns (green), and only the gateway crosses to the third-party model (grey, managed, not run by Northdesk), so a provider change touches one component.

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.

Contracts at each boundary What crosses each edge Active flow 1 Processing step number Retrieval Pipeline retrieval Orchestration prompt build tenant, groups already applied list[Hit], filtered 1 Orchestration Ask LLM Generation Layer Gateway text, provider, tokens, $, ms complete(request) 2 Completion 3
What crosses a boundary is a fixed contract, a guarantee: retrieval hands on only permitted hits, and every model call returns a Completion with tokens, cost and latency.

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.

DECISION TABLE Each data shape, its own store DATA READ AS STORE tenants, users, ACLs relational PostgreSQL sessions key, expiry Redis chunks, embeddings similarity pgvector, Qdrant raw uploads large files object store traces append, scan log or column store
Each kind of Northdesk data has its own shape and access pattern, so its own store (blue); managed versions of each exist and are named, not run, in this course.

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 MODEL A tree stored as flat rows ONE TRACE ask.request gateway.auth retrieve model.call STORED FLAT: ONE ROW PER SPAN SPAN_ID PARENT_ID NAME s01 none ask.request s02 s01 gateway.auth s04 s01 retrieve s10 s01 model.call 1,320 traces: 15,871 rows
A trace is a tree, stored flat as one row per span with parent_id to rebuild it: the 1,320 simulated Ask traces become 15,871 rows (the model call in amber).

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.

BY THE NUMBERS The first window crosses the line TIMED-OUT SHARE OF MODEL CALLS, 22 SEP simulated 12:30 0 12:50 0 13:00 8/42 13:10 10/47 13:20 8/36 13:30 5/30 13:40 0 13:50 0 0% 10% 20% alert at 5%
22 September in 10-minute windows: the timed-out share jumps above the 5% line (red) from 13:00 to 13:30, so a simple rule alerts in the first window.

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".

HOW IT WORKS The incident routine, applied detect 13:00 window 1 mitigate fallback 2 resolve 13:40 3 review what failed 4 record failure row 5 the next incident starts better prepared
The incident routine applied to 22 September: detect, mitigate with the fallback, resolve, review, and record a failure-mode row and a test (green, the outcomes).

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).

COMPARE Where the traces are kept OpenTelemetry, self-hosted YOU OPERATE Phoenix or Langfuse PROMPTS STORED on your servers COURSE RUNS Module 13 Hosted tracing, named only YOU OPERATE the vendor runs it PROMPTS STORED on the vendor's COURSE RUNS nothing
Tracing the course runs (blue) against hosted products such as LangSmith or Datadog (grey, named only): the deciding row for Leila is where tenants' prompts and answers are stored.

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.

BY THE NUMBERS From a day to open streams 1,000,000 a day 11.6 RPS average ÷ 86,400 46.3 RPS at peak x 4 255 in flight x 5.5 s
The capacity chain for Joel's target: 1,000,000 requests a day becomes 11.6 a second, 46.3 at peak and 255 requests in flight (amber: the model's seconds drive it).

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.

LOAD TEST The knee is near 1.25x P95 WAIT, UNBOUNDED QUEUE, 16 SLOTS simulated 0.5x 0.0 s 1x 2.6 s 1.25x 8.8 s 1.5x 59.0 s 2x 176.7 s load as a multiple of the busiest five minutes
Stepping load up from half the busiest five minutes to twice it: the p95 wait is fine at the peak (green), climbs at 1.25x (amber) and explodes from 1.5x (red), so the knee is near 1.25x.

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.

python
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.

python
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.

python
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.

python
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.makedirs and the empty __init__.py files make northdesk/basics/ importable: small teaching versions, not the production components later modules build.
  • mean_s is the mean Ask latency; target_avg and target_peak turn Joel's target into requests a second; the prints multiply them.
text
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 s

The first component: replicas and a load balancer.

python
%%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

  • handle counts the earlier turns it can see (seen) and appends the question.
  • store=None keeps history in self.local, inside one replica; a shared dict stands in for Redis. up marks a healthy replica.
  • route takes replicas in turn with cycle and skips any that are down.
python
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.

text
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 turns
HOW IT WORKS Inside the queue simulation arrivals random, RPS waiting at most 16 16 slots 5.5 s mean done rejected at once 16 already waiting
The simulation: random arrivals wait for one of 16 model-call slots (amber, seconds each); with a bound, an arrival that finds 16 waiting is rejected at once (red, dashed).

Next, a queue in front of a fixed number of model-call slots.

python
%%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

  • free holds when each slot is next free; heapq.heappop takes the earliest.
  • rng.expovariate(rps) draws random gaps between arrivals.
  • With a bound, an arrival that finds waiting full is rejected at once.
  • An accepted request holds a slot for a sampled service time; the function returns the p95 wait.

The load test:

python
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.

text
 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 508

Sixteen 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.

python
%%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), attempts

Code walkthrough

  • create makes a ticket per new call; its first reply takes 9,000 ms, later ones 200 ms. by_key returns the earlier ticket for a repeated key.
  • call_with_retry adds simulated time: past timeout_ms it charges the timeout plus a random pause up to base_ms * 2 ** n, doubling each try.

Observability: spans as rows, 22 September in 10-minute windows.

python
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' turns 13:07:41 into 13: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.
text
(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.

python
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:

text
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']
BREAK IT A safe-looking retry writes twice Cases retry try 1: TCK-001 try 2: TCK-002 helpdesk: 2 tickets one cracked screen caller saw success
Break it: the retry that looked safe left two tickets in the tenant's helpdesk for one cracked screen (red), and the caller still saw success.

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:

ComponentFailureDetectionFailFallbackBlast radius
Ask replicadieshealth checkopenother replicasrequests in flight
Session statekept in a replicalost-history testclosedshared storeone conversation
Model-call queueunbounded backlogp95 queue waitclosedreject fasteveryone waiting
Response cacheno tenant in keycross-tenant probeclosedskip cachetwo tenants
Write retryduplicate actionactions per keyclosedidempotency keyone tenant
Providertimeouts risetimeouts over 5%opensecondaryall 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

  1. Ask's mean latency doubles at the same traffic. What happens to requests in flight?
  2. Why does a cache key need the prompt version?
  3. A retry after a timeout created a duplicate. Was the timeout wrong?
  4. Why reject requests rather than queue them all?
  1. They double: at the target peak, 255 becomes 510.
  2. A new prompt can answer differently; the old answer would hide the change.
  3. No. The call worked and replied late; an idempotency key makes the repeat return the first result.
  4. 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.

python
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.

python
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

TermMeaning
Stateless replicaA copy that keeps nothing another copy needs
Little's lawIn flight = arrivals a second × seconds each
Response cacheStored answers, keyed by tenant, question and versions
BackpressureRefusing new work when full instead of queueing without limit
JitterA random pause before a retry
Idempotency keyOne id per user action, so a repeat returns the first result
Service boundaryThe fixed contract at a component's edge
Load testRising 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