Design an Ad Click Event Aggregation System
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A small ad network shows advertisers click counts per hour | A large ad platform: real-time dashboards and exact billing | A global platform: budgets that stop ads, privacy rules, conversions |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Volume | 10M clicks/day: 116/s average, 500/s peak | 1B clicks/day: 11,574/s average, 100K/s peak; 10K dashboard queries/s | 5B clicks/day in 3 regions; 100M conversions/day |
| Data | 5 GB/day raw → 1.25 GB/day Parquet | 500 GB/day raw → 125 GB/day Parquet (45.6 TB/year) | 625 GB/day Parquet; lake levels off near 156 TB |
| Footprint | 1 region, 3 AZs | 1 region, 3 AZs | 3 regions, each with 3 AZs |
| Targets | Redirect 99.9%; an hour's counts ready within 20 min of the hour's end | Dashboards < 3 s fresh (P99); zero double-billing; redirect 99.95% | Redirect 99.99%; overspend bounded; billing reconciled daily |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is Click Aggregation?
You Already Know One: a Turnstile Counter
A subway turnstile clicks once for every person who walks through, and at the end of the day the operator reads the counter and knows how many fares to collect. Now picture a turnstile on every ad on the internet. Every time someone clicks an ad, a counter ticks, and the advertiser pays for each tick. The same counter tells the advertiser, minute by minute, how the campaign is doing.
That's the whole system: count clicks per ad, per time period, and turn the counts into a bill and a dashboard.
A few words we'll use all the way through:
| Term | One-line meaning |
|---|---|
| Impression | One showing of an ad to one person. |
| Click | That person clicking the ad they were shown. |
| CTR (click-through rate) | Clicks ÷ impressions. 2% means 2 clicks per 100 showings. |
| CPC (cost per click) | What the advertiser pays for one valid click. We store money in micros (millionths of a dollar) as integers, so $0.45 is 450000. |
| Window | A time bucket we count into, for example "10:00 to 11:00" or "10:01 to 10:02". |
| Event time | When the click actually happened. |
| Processing time | When our system got around to counting it. |
| Watermark | The stream's running estimate of "all clicks up to time X have now arrived". Round 2 defines it properly. |
What Makes It Hard
- Volume. By Round 2 it's a billion clicks a day, with spikes during big live events.
- Clicks arrive late, and twice. A phone clicks in a subway tunnel and uploads 40 minutes later. A user double-clicks. A network retry sends the same click again.
- Some clicks are fake. Bots and click farms click ads to drain a competitor's budget or to inflate a publisher's earnings.
- Every count is money. Counting one click twice overcharges a customer. Losing one underpays us. Both end up in a dispute.
The Question the Whole Loop Answers
How do we count a firehose of clicks fast enough to act on, and exactly enough to bill?
The answer gets sharper every round:
- Round 1: count through a queue instead of the database, once an hour, exactly.
- Round 2: count in a stream within seconds, and keep a slower, exact recount for billing.
- Round 3: feed the fast count back into ad serving so budgets stop on time, across regions, under privacy rules.
Round 1 · Mid-level · "Click Reports for a Small Ad Network"
~35 min · SDE II (L5) · 1 region, 3 AZs · 10M clicks/day, 500/s peak · 5,000 active ads · redirect 99.9%
R1.1 Establish Design Scope
The interviewer says: "Design a system that counts ad clicks for a small ad network." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| What exactly is counted? | Clicks per ad, per hour. Advertisers see a table of hours. | The output is small: one row per ad per hour. We never need to show a single click to anyone. |
| How fresh must the counts be? | Within the hour is fine. | We don't need a real-time system. A job that runs every hour is allowed. |
| How is billing done? | Monthly, from these counts. | Counts must be exact, not approximate. A monthly bill can wait for late data to settle. |
| Can the same click be recorded twice? | Yes. People double-click, and browsers retry. | Every click needs an ID we can deduplicate on (step 1.3). |
| Do we filter fraud? | Not yet. | We only stop the obvious forgery: links edited to point at a different ad (step 1.4). Bot detection waits for Round 2. |
| Where do clicks happen? | The ad's link points at our server, which sends the user on to the advertiser. | We own a redirect on the click path. It must be fast and always up, because a broken redirect is a broken ad. |
| How much traffic? | About 10 million clicks a day, on about 5,000 active ads. | Small enough for one region and simple building blocks (R1.7). |
Out of scope for this round:
- Real-time dashboards. Hourly is what was asked.
- Fraud detection. Beyond forged links, not yet.
- Budgets. Ads keep running whatever they've spent; the sales team handles limits by hand.
Write the out-of-scope list where you can see it. In a multi-round loop, all three items come back.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Operation |
|---|---|
| "The link points at our server" | GET /c/{token}: record the click, answer with a redirect to the advertiser |
| "Clicks per ad, per hour" | Aggregate valid clicks and spend per (ad, hour) |
| "Advertisers see a table of hours" | GET /v1/ads/{id}/metrics?from=&to=&granularity=hour |
| "Billing is monthly, from these counts" | Keep the raw clicks so any hour can be recounted |
Not yet: minute-level counts, fraud scoring, and a separate "final for billing" flag.
R1.3 Non-Functional Requirements: the Questions
We name each quality in words first. The numbers come in R1.7.
- A fast redirect. The user is waiting to see the advertiser's page. Our hop must add only a few milliseconds of server time, and recording the click must never make the user wait on a slow database.
- No lost clicks. A lost click is money we can't bill. Once we've redirected someone, the click must be recorded somewhere durable, even if a downstream part is broken.
- Correct counts. Each real click counted exactly once, in the hour it happened.
- Cheap storage of raw events. We keep every click for recounts and disputes, so raw storage must cost almost nothing per click.
How they pull against each other. The fastest redirect records nothing. The most careful one writes to a database and waits for it. The design below finds the middle: hand the click to a durable log that is built to take writes, then redirect.
R1.4 The API
The click link
The ad server, at the moment it shows an ad, builds a link that carries a signed token:
httpGET /c/v1.k7.Y2xrXzJiOWU0MWQwN2E2ZjRjMTE.Jx9fQ2mB0s1PzK4dRw7aNg HTTP/1.1 Host: c.adnet.example
httpHTTP/1.1 302 Found Location: https://shoes.example/spring-sale?utm_source=adnet Cache-Control: private, no-store
We answer 302 with no-store, so every click reaches us and nothing is cached by the browser. The URL shortener loop explains that choice in detail: URL shortener, step 1.4.
The token's parts (the payload is base64url-encoded; step 1.4 explains the signature):
| Part | Example | Why |
|---|---|---|
| Version | v1 | Lets us change the format later |
| Key ID | k7 | Which signing key was used, so keys can rotate |
click_id | c-2b9e41d07a6f4c1188d3e0a5b7f21c64 | A random 128-bit ID minted once per impression. One impression can produce at most one billable click, so this is our dedup key (step 1.3) |
ad_id, campaign_id, publisher_id | ad_1042, cmp_88, pub_311 | Who gets charged and who gets paid |
cpc_micros | 450000 | The price, decided by the ad auction when the ad was shown |
imp_ts | 2026-09-28T10:14:03.120Z | When the ad was shown; the click must follow within 1 hour |
| MAC | 16 bytes | HMAC-SHA256 over everything above, truncated to 128 bits |
The advertiser's destination URL is not in the token. The redirect service looks it up by ad_id in an in-memory copy of the ad catalog. Taking the target from the request would make us an open redirect: anyone could craft our trusted domain into a link to a phishing site.
The click event the redirect service records (about 500 bytes with the user agent):
json{ "request_id": "r-7f3a9c2e51d0", "click_id": "c-2b9e41d07a6f4c1188d3e0a5b7f21c64", "ad_id": "ad_1042", "campaign_id": "cmp_88", "publisher_id": "pub_311", "cpc_micros": 450000, "impression_time": "2026-09-28T10:14:03.120Z", "event_time": "2026-09-28T10:14:09.874Z", "ip": "203.0.113.24", "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) ...", "redirect_host": "redir-b-2" }
request_id is unique per HTTP request; click_id is unique per impression. A double-click produces two request_ids with one click_id. event_time is stamped by the redirect server when the request arrives; for a web click, that is the moment of the click.
The report
httpGET /v1/ads/ad_1042/metrics?from=2026-09-28T08:00Z&to=2026-09-28T11:00Z&granularity=hour HTTP/1.1 Authorization: Bearer <advertiser token>
json{ "ad_id": "ad_1042", "granularity": "hour", "rows": [ { "hour": "2026-09-28T08:00Z", "clicks": 81, "spend_micros": 36450000 }, { "hour": "2026-09-28T09:00Z", "clicks": 94, "spend_micros": 42300000 }, { "hour": "2026-09-28T10:00Z", "clicks": 88, "spend_micros": 39600000 } ] }
Status codes
| Code | Where | Meaning |
|---|---|---|
302 Found | Click | Redirected; the click is recorded |
404 Not Found | Click | Bad token or unknown ad. We send the user to the publisher's fallback page, and count the attempt as invalid |
400 Bad Request | Report | Range too long (more than 93 days at hourly granularity) |
403 Forbidden | Report | The caller doesn't own this ad |
Recap
- One click path (
GET /c/{token}→ 302) and one report path. - 10M clicks a day, 500/s at peak, 5,000 active ads, one region.
- Counts per ad per hour, exact, with duplicates removed, bucketed by when the click happened.
- Never block the redirect on anything slow.
R1.5 Design Evolution: From a Table of Clicks to Hourly Counts
Every step follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is usually the next problem.
Step 1.0: The Baseline
The redirect handler inserts a row per click into a relational table, then redirects. Reports run SELECT ad_id, COUNT(*) FROM clicks WHERE ts BETWEEN ... GROUP BY ad_id.
Synthesizing vector architecture diagram...
One table does two jobs: it takes every click on the redirect path and answers every report.
It works on day one. Everything below exists because this table is on the user's path and is being asked to be both a write log and an analytics engine.
Step 1.1: Recording Clicks Slows the Redirect
The problem: at month's end, advertisers pull reports. Each report scans millions of rows, the database's disk and CPU saturate, and the redirect's P99 jumps from 15 ms to 800 ms. During a database patch window, every redirect fails. What would you do?
Why a stream and not a direct call to a counting service? A direct call couples the redirect to that service's health and speed, the same problem as the database. The log absorbs bursts, keeps records while consumers are down, and lets us add consumers later without touching the redirect.
Primitive: Message Queues vs Event Streams · Drill: Message queue order pipeline
Step 1.2: Reports Scan Millions of Rows
The problem: clicks now sit in a stream. Reports still need "clicks per ad per hour" for ranges up to three months, and the stream only keeps 24 hours. What would you do?
Synthesizing vector architecture diagram...
Raw clicks flow left to right into cheap files; one small table holds the only numbers reports ever read.
Step 1.3: A Double-Click Is Counted Twice
The problem: a user double-clicks an ad. Both requests reach us, both are logged, and the advertiser is charged twice. A Kinesis write that timed out after it actually succeeded, then was retried, does the same thing. What would you do?
Step 1.4: Tampered Links Fake Clicks for Other Ads
The problem: our click links used to be /c?ad=1042&pub=311. A publisher who is paid per click edits its links to point at expensive ads, or writes a script that requests the link a thousand times with made-up parameters.
What would you do?
Step 1.5: Which Hour Does a Click Belong To?
The problem: Firehose names files by the hour they arrived. A click at 10:59:58 lands in a file for 11:00. After a 40-minute Kinesis delivery problem, a whole batch of 10:30 clicks arrives at 11:10. Which hour do we put them in, and why does the advertiser care? What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | One table for writes and reports | Everything below |
| 1.1 | Recording slows the redirect | Append to Kinesis, then redirect; local spool, fail open | Asynchronous counting |
| 1.2 | Reports scan millions of rows | Firehose to Parquet in S3; hourly Athena job; one DynamoDB item per ad-hour | Up to an hour of lag |
| 1.3 | Double-clicks counted twice | Dedup on the signed click_id, with a lookback hour | A bounded dedup window |
| 1.4 | Forged and edited links | HMAC-signed tokens, target from the catalog | Key management |
| 1.5 | Which hour? | Event time; recompute the last 3 hours; overwrite | Numbers settle after 3 hours (daily recount beyond) |
R1.6 Architecture v1
Synthesizing vector architecture diagram...
The click path touches only the load balancer, the redirect service and Kinesis. Everything after Kinesis can fail or run late without the user noticing.
Why these pieces:
- Application Load Balancer terminates TLS and spreads clicks over the redirect tasks.
- Redirect service on ECS (Fargate), one task per AZ at minimum. Each task holds the ad catalog (5,000 ads, a few MB) and the signing keys in memory, so the click path has no lookups.
- Kinesis Data Streams, one provisioned shard. The partition key is
click_id, which spreads records evenly (there is only one shard now, but Round 2 will add more). - Firehose reads the stream, converts to Parquet using a schema in the AWS Glue Data Catalog, and writes to
s3://clicks-lake/arrival_hour=2026-09-28-11/...every 300 seconds. Records that fail conversion go to anerrors/prefix instead of disappearing. - The hourly job: EventBridge Scheduler starts a Step Functions workflow at 10 minutes past each hour. It runs one Athena query, then a Lambda function writes the result rows to DynamoDB. A second schedule at 02:10 recounts the whole previous day.
- DynamoDB holds the only numbers reports read.
The data model
click_hourly (DynamoDB):
| Attribute | Example | Notes |
|---|---|---|
ad_id (partition key) | ad_1042 | One item collection per ad |
hour (sort key, String) | 2026-09-28T10 | ISO format, zero-padded, so string order is time order |
clicks | 88 | Valid, deduplicated clicks in this event hour |
spend_micros | 39600000 | Sum of cpc_micros of those clicks |
computed_by | hourly-2026-09-28T13:10 | Which run wrote it, for debugging |
The raw lake table (Glue Data Catalog, read by Athena):
| Column | Type | Notes |
|---|---|---|
request_id | string | Unique per HTTP request |
click_id | string | Unique per impression; the dedup key |
ad_id, campaign_id, publisher_id | string | |
cpc_micros | bigint | |
impression_time, event_time | timestamp | |
ip, user_agent, redirect_host | string | Kept for disputes and future fraud work |
arrival_hour | string (partition) | Set from Firehose's arrival timestamp in the S3 prefix |
The hourly query. At 11:10, the job recounts event hours 08:00, 09:00 and 10:00. It reads arrival hours 07:00 through 11:00: 07:00 is the lookback hour, and 11:00 holds clicks from 10:xx that arrived after 11:00.
sqlWITH first_copy AS ( SELECT click_id, min(event_time) AS event_time, arbitrary(ad_id) AS ad_id, arbitrary(cpc_micros) AS cpc_micros FROM clicks_raw WHERE arrival_hour BETWEEN '2026-09-28-07' AND '2026-09-28-11' GROUP BY click_id ) SELECT ad_id, date_trunc('hour', event_time) AS hour, count(*) AS clicks, sum(cpc_micros) AS spend_micros FROM first_copy WHERE event_time >= TIMESTAMP '2026-09-28 08:00:00' AND event_time < TIMESTAMP '2026-09-28 11:00:00' GROUP BY 1, 2;
GROUP BY click_id keeps one row per click, so a double-click counts once, in the hour of its first copy. The lookback hour matters for a double-click at 07:59:59 and 08:00:01: without arrival hour 07:00, the job would see only the second copy and count it as a new click in hour 08:00.
The Lambda writer then does, for each result row, an UpdateItem with SET clicks = :c, spend_micros = :s, computed_by = :run. It sets the value; it never adds to it. R1.9 shows why.
Tracing the two flows
1. A click
Synthesizing vector architecture diagram...
The user waits for the signature check and one log append, about 20 ms of server time. Counting happens later, off the path.
2. A report, and when its numbers appear
- The click above has
event_time10:14:09 and arrives in arrival hour 10. - At 11:10 the hourly job recounts event hours 08:00–10:00; the 10:00 row now includes it:
clicks = 88. - At 11:13 (about 1 minute for the query, 1–2 minutes for writing up to 15,000 items) the new rows are in DynamoDB.
- The advertiser's
GET /v1/ads/ad_1042/metrics?...is one DynamoDBQueryonad_id = ad_1042andhour BETWEEN '2026-09-28T08' AND '2026-09-28T10': 3 items, a few milliseconds. - The 10:00 row is recomputed again at 12:10 and 13:10, and once more by the daily run at 02:10 the next day. If a late copy or a late click arrived, the number changes; otherwise the same value is written again.
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Redirect availability | 99.9% of minutes (43.8 min a month) | Stateless tasks in 3 AZs; the only dependency on the path is Kinesis, and we fail open around it |
| Redirect server time | P99 < 50 ms | One HMAC check and one Kinesis append (~20 ms) |
| Freshness | An hour's counts ready within 20 minutes of the hour's end | Worked out below |
| Correctness | Every click in the lake counted once, in its event hour, after the daily recount | Steps 1.3, 1.5 |
Traffic and bytes
| Item | Math | Result |
|---|---|---|
| Average rate | 10,000,000 ÷ 86,400 s | 116/s |
| Busiest hour | ~3× the daily average (an assumed evening peak) | 347/s |
| Planning peak | 347/s rounded up for campaign launches and retries | 500/s (≈ 4.3× average) |
| Raw bytes per day | 10M × 500 B | 5 GB/day |
| Peak ingress | 500/s × 500 B | 250 KB/s |
| Parquet per day | 5 GB ÷ 4 (Parquet with Snappy compression on this kind of repetitive JSON; an assumption to measure) | 1.25 GB/day, 456 GB/year |
| Aggregate rows per hour | at most one per active ad | ≤ 5,000 (average ~83 clicks per ad-hour: 10M ÷ 24 ÷ 5,000) |
| Items written per hourly run | 5,000 ads × 3 hours | ≤ 15,000 |
Kinesis
| Item | Math | Result |
|---|---|---|
| Records at peak | 500/s ÷ 1,000 records/s per shard | 50% of one shard |
| Bytes at peak | 0.25 MB/s ÷ 1 MB/s per shard | 25% of one shard |
| Shards | 1 |
Freshness, step by step. These happen one after another, so the worst cases add:
| Step | Worst case |
|---|---|
| Kinesis append | ~0.1 s |
| Firehose buffer (300 s; a 64 MB minimum buffer size applies when converting to Parquet, and at 3.5 MB of input a minute the 300 s timer always fires first) | 300 s |
| Wait for the job (it starts at :10, giving Firehose deliveries a margin) | the job starts 10 min after the hour |
| Athena query | ~1 min |
| Lambda writes up to 15,000 items | ~2 min |
| Ready | ~13 min after the hour's end, inside the 20-minute target |
Rough monthly cost (us-east-1 list prices; 30.4 days a month, 730 hours; check the AWS Pricing Calculator before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| Redirect tasks | 3 Fargate tasks × (0.5 vCPU × $0.04048 + 1 GB × $0.004445) per hour × 730 h | $54 |
| Application Load Balancer | $0.0225/h × 730 h + ~5 LCU (116 new connections/s ÷ 25 per LCU) × $0.008 × 730 h | $46 |
| Kinesis | 1 shard × $0.015/h × 730 h = $10.95; 304M records × 1 PUT payload unit (25 KB each) × $0.014 per million = $4.26 | $15 |
| Firehose ingestion | Each record is billed in 5 KB increments, so a 500-byte click is billed as 5 KB: 10M × 5 KB × 30.4 = 1,520 GB × $0.029 | $44 |
| Firehose Parquet conversion | Also billed on the 5 KB-rounded volume: 1,520 GB × $0.018 | $27 |
| S3 | Month 1: 38 GB; after a year: 456 GB × $0.023 | $1 → $10 |
| Athena | Each hourly run scans 5 arrival hours of a few columns (~100 MB): $5 per TB × 0.0001 TB × 720 runs, plus the daily runs | < $2 |
| DynamoDB | 15,000 writes × 720 runs + 120,000 × 30 daily = 14.4M writes × $0.625 per million; storage 6.6 GB/year × $0.25 | $11 |
| Step Functions, Lambda, API Gateway, CloudWatch | small at this volume (estimate) | $20 |
| Total | ≈ $220 |
Two lessons:
- Firehose's 5 KB rounding makes us pay for 10× the bytes we send (1,520 GB billed for 152 GB of data). Packing several clicks into one record would cut the two Firehose lines from $71 to about $8. At this size, the $63 a month isn't worth the extra code and the new failure modes; in Round 2 it's worth thousands a month, and we pack.
- The redirect tier is the most expensive part, and it's the only part the user feels. That's the right place for the money.
R1.8 Trade-Offs
Stream vs direct database writes
| Append to Kinesis (chosen) | Insert into the database | |
|---|---|---|
| Redirect latency | One append, ~20 ms, steady | Depends on the database's load; spikes during reports |
| Database outage | Clicks keep flowing; counted later | Every click fails, or is lost if we redirect anyway |
| Recount after a bug | Raw clicks in S3; rerun the query | Only if we kept raw rows, in the same busy database |
| Moving parts | Stream, Firehose, a scheduled job | One database |
Hourly batch vs a streaming consumer, for this scope
| Hourly batch over S3 (chosen) | A consumer that counts as clicks arrive | |
|---|---|---|
| Freshness | ~13 min after each hour | Seconds |
| Duplicates and retries | The query dedups exactly; reruns overwrite | The consumer must keep a dedup set and make its writes safe to repeat (Round 2's whole problem) |
| Late clicks | Recompute recent hours; daily recount | Must decide when an hour is "done" |
| Operations | A query and a schedule | A long-running service with state to rebuild after crashes |
Hourly was what was asked. We'd switch the moment someone needs minutes, and Round 2 does.
Which store for the aggregates
| DynamoDB (chosen) | Query Athena for every report | Aurora PostgreSQL | |
|---|---|---|---|
| Report latency | Milliseconds | Seconds, and each query has a minimum scan charge | Milliseconds |
| Cost at this size | ~$11 a month | Cheap until many advertisers refresh often | An always-on instance, $50+ a month |
| Fit | Key lookup by ad and hour range | Ad-hoc analysis, not a product API | Fine too; flexible SQL, but we only need one access pattern |
For time-series storage in general (compression, rollups, retention tiers), see the metrics monitoring loop. Our aggregates are much simpler: one number per ad per hour.
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds | Drill |
|---|---|---|---|
| The job crashes halfway through writing | The Lambda writer dies after 7,000 of 15,000 items; some hours are new, some old. | Step Functions retries the whole step. The retry writes the same 15,000 items again with SET, so the 7,000 already written are overwritten with the same values. If the writer used ADD clicks :n instead, those 7,000 would now be doubled. Overwrite, never add, when work can be retried. | Message queue order pipeline |
| Firehose can't deliver | A schema change breaks Parquet conversion; records go to the errors/ prefix. Or S3 writes fail. | Conversion failures land in errors/, never dropped silently; an alarm on the error prefix's object count pages. S3 delivery failures are retried by Firehose, and Firehose keeps reading from Kinesis's 24-hour window, so an outage shorter than that delays counts rather than losing clicks. An alarm on Firehose's DeliveryToS3.DataFreshness > 15 min pages. After a fix, the records in errors/ are reprocessed into the lake, and the next hourly and daily runs pick them up. | – |
| Kinesis rejects writes | PutRecord errors or timeouts at the redirect tasks. | The task writes the click to its local spool file, redirects anyway, and resends the spool with backoff. Spooled clicks keep their original event_time, so they land in the right hour when they arrive. If a task is replaced while it holds a spool, those clicks are lost: acceptable for a rare double failure, and visible as a gap between the tasks' "accepted" and "delivered" counters. | – |
| A redirect task dies | One of three tasks disappears. | The load balancer stops sending to it within seconds; ECS starts a replacement. Two tasks handle the 500/s peak comfortably. | – |
| The whole redirect service is down | Every click link returns an error. | This is the one failure users see, so it gets the most redundancy (3 AZs, no dependencies on the path). The alternative, linking straight to the advertiser and reporting the click with a browser "ping" beacon, keeps users moving during an outage but makes clicks best-effort and unverifiable, which is wrong for billing. We keep the redirect. | – |
A note on the drill: a consumer that fails halfway here is either Firehose (which retries, and sends records it can't convert to an error prefix, our dead-letter queue) or the hourly job (which is safe to retry because it overwrites). Neither loses a click or counts one twice.
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | The redirect depends only on an append to a replicated log; local spool and fail-open around it; the counting job is safe to retry because it overwrites. REL 4 · REL 10 |
| Performance Efficiency | Each kind of data in the store that fits it: a log for writes, Parquet in S3 for recounts, one key-value item per ad-hour for reads. PERF 3 |
| Security | Signed click tokens; the redirect target comes from our catalog, never the request; signing keys readable only by two roles; TLS terminated at the ALB. SEC 3 · SEC 9 |
| Cost Optimization | About $220 a month; Firehose's 5 KB rounding found and priced; packing deferred because the engineering isn't worth $63 a month yet. COST 5 · COST 11 |
| Operational Excellence | Light this round: alarms on Firehose freshness, conversion errors, job failures and the spool counters. OPS 8 |
| Sustainability | Skipped this round. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Takes the database off the redirect path and explains why a log, not a background thread.
- Pre-aggregates into one row per ad per hour and keeps raw clicks cheaply for recounts.
- Deduplicates on an ID that every copy of a click shares, and explains where that ID comes from.
- Signs click links, and doesn't take the redirect target from the request.
- Buckets by event time, and can say what goes wrong with arrival time.
- Writes aggregates with overwrite, not increment, and says why.
Follow-up questions
-
"Why not have the redirect service increment a counter in DynamoDB directly? It's only 500 writes a second." Answer: it would work at this size and cost about $0.625 per million clicks, but it puts a database write back on the user's path, and an increment isn't safe to retry: a timeout after a successful
ADDfollowed by a retry counts the click twice, and a double-click adds twice unless we also keep a dedup record per click. The log plus an hourly recount gets exactness for free. -
"An advertiser says their 10:00 number changed between 11:15 and 12:15. Bug?" Answer: probably not. Every hour is recomputed three times and again the next day, so a late click or a late duplicate can move it. We should say so in the API: Round 2 adds a
finalflag, and here the report can already show "this hour may still change until 02:10 tomorrow". -
"How do you rotate the signing key without breaking links already on web pages?" Answer: the key ID in the token. We add the new key to every verifier first, then switch the ad servers to sign with it. Old links carry the old key ID and keep verifying until the old key is retired, 2 hours later: longer than a token's 1-hour life.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Insert into the database, then redirect" | Reports and maintenance on that database now slow or break every click. |
| "Increment a counter per click" | Retries and double-clicks add twice; overwrite recomputed totals instead. |
| "Bucket by when it arrived" | Pipeline delays move clicks between hours and days, and into the wrong bill. |
| "Put the destination URL in the link" | Our domain becomes an open redirect for phishing. |
| "Dedup by IP and a few seconds" | Shared IPs drop real clicks; retries from other paths slip through. Dedup on the signed click ID. |
Round 2 · Senior · "A Billion Clicks a Day, Billed Exactly"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 1B clicks/day: 11,574/s average, 100K/s peak · 100K active ads · dashboards < 3 s fresh (P99) · zero double-billing · redirect 99.95%
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "A small ad network: 10 million clicks a day, 500 a second at peak, 5,000 ads. Ad links carry an HMAC-signed token with a per-impression
click_id, the ad, the price and the impression time. A stateless redirect service checks the token, appends the click to a one-shard Kinesis stream, and sends a 302; if Kinesis fails it spools locally and redirects anyway. Firehose writes the stream to S3 as Parquet every 5 minutes. Every hour, an Athena query recounts the last three event hours, keeping only the first copy of eachclick_id, and a Lambda sets one DynamoDB item per ad per hour. A daily run recounts the whole previous day. Reports read DynamoDB. About $220 a month. Open costs: counts are an hour old, nothing filters bots, and the design has never seen a hot ad or a late phone."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: a log on the click path, a lake of raw clicks, and an hourly exact recount.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Recording slows the redirect | Append to Kinesis, then redirect; spool, fail open | Asynchronous counting |
| 1.2 | Reports scan millions of rows | Parquet lake; hourly Athena job; one item per ad-hour | An hour of lag |
| 1.3 | Double-clicks | Dedup on the signed click_id with a lookback hour | A bounded window |
| 1.4 | Forged links | HMAC-signed tokens; target from the catalog | Key management |
| 1.5 | Which hour? | Event time; recompute 3 hours; overwrite | Numbers settle later |
Open costs: hour-old numbers; no fraud filtering; one shard; no idea what to do with clicks that arrive long after they happened.
R2.1 The Scope Raise
Interviewer: "We've been bought by a large ad platform. It's now 50 billion impressions a day. Advertisers' bidding tools poll their numbers constantly and want them within 3 seconds. Every valid click must be billed exactly once: finance will not accept a double charge, ever. Our mobile SDK records clicks on the phone, and phones that were offline upload them up to 45 minutes late. Bot farms are clicking our ads. And during the Super Bowl, one advertiser's campaign alone got 40,000 clicks a second."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| What's the click-through rate, so how many clicks? | About 2%. | 50B × 2% = 1B clicks/day, 11,574/s on average. We derive one planning peak in R2.6: 100K/s. |
| What does "within 3 seconds" mean exactly? | From the click to the number the API returns, 99% of the time. About 100,000 ads are active, and tools poll each one every 10 seconds. | An hourly job can't do it; we need a stream processor (step 2.1). 100,000 ÷ 10 s = 10,000 queries/s, and a response cache won't help: each ad is asked once per 10 s. |
| Is the 3-second number also the billed number? | No. Billing is a daily export, and it must be exact. | Two answers: a fast, provisional count for dashboards, and a slower final count for billing (step 2.3). |
| How do SDK clicks work? | The app records the click with the phone's clock and uploads batches. Offline phones upload up to 45 minutes later. | Event time now comes from a device, which can be wrong or forged. We need watermarks (step 2.2) and a late path (step 2.3). |
| Does one campaign really do 40K/s? | For a few minutes, yes. | A single hot key beyond any one shard's or worker's limit (step 2.5). |
| What should happen to bot clicks? | Never billed. Advertisers want to see how many we filtered, by reason. | Fraud filters in the stream, invalid clicks counted separately, heavier checks offline (step 2.6). |
| Can a finalized bill change? | Not silently. Anything we find later becomes a credit. | Finalized numbers are written once; later changes are a separate adjustments record (Round 3 builds it out). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Clicks | 10M/day, 500/s peak | 1B/day, 11,574/s average, 100K/s peak |
| Active ads | 5,000 | 100,000 |
| Freshness | Within the hour | < 3 s (P99) for dashboards; billing daily, exact |
| Click sources | Web redirect only | Web redirect + mobile SDK uploads, up to 45 min late |
| Fraud | Forged links only | Bots and click farms; invalid clicks counted, never billed |
| Dashboard load | Occasional reports | 10,000 queries/s |
| Hot keys | None | One ad at 40K clicks/s |
| Availability | 99.9% redirect | 99.95% redirect |
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| Hourly Athena job | Numbers are 13 to 73 minutes old; the target is 3 seconds. |
| One Kinesis shard | 100K/s is 100× one shard's record limit. |
| Recount everything every hour | At 1B clicks a day, each run scans hours of data just to refresh numbers that are provisional anyway. |
| "Dedup in the query" | Fine for the batch, but a stream must remember recent click_ids itself, and remember them correctly across crashes. |
| Retry by overwrite | Still right, but a stream that crashes and replays must produce the same totals, or the overwrite writes a different number. |
| Event time from our own server | SDK clicks carry a phone's clock: late by 45 minutes, sometimes wrong by days. |
| No fraud filtering | Bot clicks are billed. |
| Partition key doesn't matter | At 40K/s, the key that decides the shard and the worker decides whether we keep up. |
We fix them in this order: freshness (2.1), completeness of a window (2.2), late clicks (2.3), crashes and replays (2.4), the hot ad (2.5), bots (2.6).
R2.3 New Requirements and API Additions
Minute-level metrics with valid and invalid counts
httpGET /v1/ads/ad_1042/metrics?from=2026-09-28T10:10Z&to=2026-09-28T10:15Z&granularity=minute HTTP/1.1 Authorization: Bearer <advertiser token>
json{ "ad_id": "ad_1042", "granularity": "minute", "data_complete_through": "2026-09-28T10:14:06Z", "rows": [ { "minute": "2026-09-28T10:12Z", "valid": 14, "invalid": { "duplicate": 1, "velocity": 0, "datacenter": 2 }, "spend_micros": 6300000, "status": "provisional" }, { "minute": "2026-09-28T10:13Z", "valid": 11, "invalid": { "duplicate": 0, "velocity": 1, "datacenter": 0 }, "spend_micros": 4950000, "status": "provisional" }, { "minute": "2026-09-28T10:14Z", "valid": 3, "invalid": { "duplicate": 0, "velocity": 0, "datacenter": 0 }, "spend_micros": 1350000, "status": "in_progress" } ] }
data_complete_throughis the stream's watermark (step 2.2): every minute before it is complete as far as the stream knows. It also tells a caller whether the pipeline is moving: if it stops advancing, the numbers are stale, even when nothing looks wrong.statusisin_progress(the minute is still open),provisional(closed in the stream, not yet recounted), orfinal(from the exact recount, step 2.3).
The billing export, with a finalized flag
httpGET /v1/billing/daily?account=acct_77&date=2026-09-27 HTTP/1.1
json{ "account": "acct_77", "date": "2026-09-27", "time_zone": "America/New_York", "finalized": true, "finalized_at": "2026-09-28T06:20:00Z", "lines": [ { "ad_id": "ad_1042", "valid_clicks": 2210, "invalid_clicks": 131, "spend_micros": 994500000 } ] }
A day in the advertiser's time zone (here UTC 04:00 on the 27th to 04:00 on the 28th) is finalized once its last hour is final: 2 hours 20 minutes after that hour ends (R2.6 shows the timing).
SDK click upload
httpPOST /v1/sdk/clicks HTTP/1.1 Content-Type: application/json { "batch_id": "b-5d1e0c77a2", "clicks": [ { "token": "v1.k7.Y2xr...", "device_time": "2026-09-28T09:31:12.004Z" }, { "token": "v1.k7.Y2xr...", "device_time": "2026-09-28T09:33:40.519Z" } ] }
json{ "accepted": 2 }
The app resends a batch whose response it never got, so some batches arrive twice. That's safe: every click inside carries its own click_id, and step 2.6 drops the copies.
The event-time rule for device clocks. A phone's clock can be minutes or days off, and a fraudster can set it to anything. The ingest service computes:
event_time = clamp(device_time, impression_time, arrival_time)
The click can't have happened before the signed impression, or after it reached us. A click whose arrival_time − impression_time is more than 2 hours is recorded as invalid (stale), never billed. Forty-five minutes of offline upload plus up to an hour between impression and click fits inside that.
R2.4 Design Evolution: Seconds-Fresh, Exactly Billed
Step 2.1: Counts Must Be 3 Seconds Fresh
The problem: advertisers' bidding tools poll each ad every 10 seconds and act on the numbers. Round 1's numbers are 13 to 73 minutes old. What would you do?
The meaning of the window, as streaming SQL:
sqlCREATE TABLE clicks ( click_id STRING, ad_id STRING, cpc_micros BIGINT, verdict STRING, -- 'valid' or an invalid reason, set by step 2.6 event_time TIMESTAMP(3), WATERMARK FOR event_time AS event_time - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kinesis' /* stream name, region, EFO options */ ); SELECT ad_id, window_start, SUM(CASE WHEN verdict = 'valid' THEN 1 ELSE 0 END) AS valid_clicks, SUM(CASE WHEN verdict <> 'valid' THEN 1 ELSE 0 END) AS invalid_clicks, SUM(CASE WHEN verdict = 'valid' THEN cpc_micros ELSE 0 END) AS spend_micros FROM TABLE(TUMBLE(TABLE clicks, DESCRIPTOR(event_time), INTERVAL '1' MINUTE)) GROUP BY ad_id, window_start, window_end;
We use the SQL to show what the window means. The production job is written with Flink's DataStream API, because we need two things SQL windows don't give us: a side output for late records (Flink SQL windows simply drop them) and custom early firings.
Primitive: Message Queues vs Event Streams
Step 2.2: When Is a Minute Complete?
The problem: the 10:14 window must close at some point, so we can write its row and free its memory. Clicks from 10:14:59 are still in flight at 10:15:00, some SDK clicks from 10:14 will arrive 40 minutes from now, and a phone with a broken clock says it's 2031. What would you do?
A small trace of a single shard:
| Arrival (wall clock) | Event time of the click | Max event time seen | Shard watermark (max − 5 s) | What happens |
|---|---|---|---|---|
| 10:15:01.2 | 10:14:59.8 | 10:14:59.8 | 10:14:54.8 | Added to window 10:14 |
| 10:15:03.9 | 10:15:03.1 | 10:15:03.1 | 10:14:58.1 | Added to window 10:15 |
| 10:15:04.0 | 10:14:57.2 | 10:15:03.1 | 10:14:58.1 | Older than the watermark, but its window ends at 10:15:00, which the watermark hasn't reached: on time, added to window 10:14 |
| 10:15:05.6 | 10:15:05.4 | 10:15:05.4 | 10:15:00.4 | This shard's watermark passes 10:15:00; window 10:14 closes once every other shard's has too |
| 10:15:06.0 | 10:14:58.9 | 10:15:05.4 | 10:15:00.4 | Its window (ending 10:15:00) has closed: late, goes to step 2.3's path |
Step 2.3: Clicks Arrive 45 Minutes Late
The problem: a phone clicks at 09:31, goes into a tunnel, and uploads at 10:16. Window 09:31 closed 45 minutes ago. The click is real and billable. What would you do?
Synthesizing vector architecture diagram...
Both paths read the same stream. The stream answers in seconds; the recount answers exactly, hours later, and writes into its own attributes.
Primitive: Event Sourcing & CQRS (the lake as the log of record; tables rebuilt from it)
Step 2.4: A Worker Crash Replayed a Minute and Double-Billed
The problem: a Flink worker dies. The job restarts, rereads the last 70 seconds of the stream, and the dashboards show 10:14 with twice the clicks it had before. The first version of the sink did ADD fast_valid :n for each batch of clicks it saw.
What would you do?
Synthesizing vector architecture diagram...
The replay repeats the output, and the write is a SET of an absolute total, so the repeat changes nothing. An ADD would have made it 1,624.
Primitive: Two-Phase Commit & Saga Orchestration
Step 2.5: One Campaign's 40K Clicks a Second
The problem: a teammate proposes using ad_id as the Kinesis partition key, so all of an ad's clicks land on one shard and one Flink worker, with no reshuffling. During the Super Bowl, one ad gets 40,000 clicks/s.
What would you do?
Synthesizing vector architecture diagram...
Each subtask sends two partial sums a second for the hot ad instead of 625 clicks, so its owner receives 128 records a second (up to ~256 at a minute boundary), not 40,000.
Primitive: Database Sharding & Partition Keys
Step 2.6: Bots Are Clicking
The problem: a click farm and a botnet are clicking ads on a few publishers' sites. Some clicks are repeats of the same token; some come from one IP 50 times a minute; some come from cloud data centers where no human browses. Finance says these must never be billed, and advertisers want to see how many we filtered, and why. What would you do?
Remembering 2 hours of click IDs. Three ways to do step 1:
Redis SET of every click_id, no expiry | Rotating Bloom filter | Exact keyed state in Flink, cleared 2 h after the impression (chosen) | |
|---|---|---|---|
| Memory | 1B × ~64 B = 64 GB per day, forever | 10-minute window at peak: n = 100K × 600 = 60M, p = 0.001 → 107.8 MB, 215.6 MB with current + previous | 83.3M IDs on average (11,574 × 7,200 s) × ~60 B ≈ 5 GB, on local disk (RocksDB), not RAM |
| Answers | Exact | "Definitely new" or "maybe seen" | Exact |
| Real clicks dropped | 0 | Up to 0.1% at peak, as false "duplicates" | 0 |
| Consistent after a Flink restore | No: an external store isn't rolled back, so replayed clicks look like duplicates and are dropped | Only if it's Flink state | Yes, restored with every other operator |
| Window covered | Unbounded | 10–20 minutes | All 2 hours |
How the exact state expires. Flink's built-in state TTL counts processing time, and RocksDB physically removes expired entries only when it compacts, so a TTL alone could forget an ID early during a backlog, or keep it long after. Instead, each entry is cleared by an event-time timer at impression_time + 2 h. The timer fires only when the watermark passes that time, so lag can't expire it early. A copy that is still accepted must arrive within 2 hours of its impression, and the watermark can't pass that point before the copy is read (event time is clamped to arrival), so every accepted copy finds its original. A 26-hour processing-time TTL stays on as a guard in case the watermark ever gets stuck; if it ever fires first, the recount still catches the duplicate.
The Bloom filter math, for reference: with million bits MB, and hash functions.
Why we don't choose it here:
- False positives drop real, billable clicks. At its design load, 0.1% of new clicks look like duplicates. Because the filter is sized for the peak, it's almost empty at average load (6.9M of 60M entries in 10 minutes), where the false-positive rate is about , so the loss is concentrated in the busiest minutes.
- Overfilling is silent. If a botnet storm pushed 200M IDs into the 60M-entry filter, it would have 4.3 bits per entry and a false-positive rate of : a third of real clicks thrown away as duplicates, with no error anywhere. A filter must rotate on a count as well as a timer, or be rebuilt larger.
- It only covers 10–20 minutes, and SDK resends come 45 minutes later.
- The memory argument is weak inside Flink. Flink's state lives in RocksDB on each worker's local disk (50 GB per KPU), and RocksDB keeps its own small Bloom filter per file, so a lookup for a new ID rarely touches disk. 5 GB of exact state (43 GB in the worst case of 2 hours at full peak) costs nothing extra.
A Bloom filter is the right tool when an exact set would not fit anywhere affordable, as in a crawler's billions of URLs. Here the exact set fits.
Why not an external Redis for dedup and velocity at all? Besides the memory, every click would cost a network round trip, and, more important, external state isn't part of Flink's checkpoint. After a restore, Flink replays clicks that Redis already recorded, so they'd be marked as duplicates: a crash would under-bill. Keeping this state inside Flink makes it roll back with everything else.
Primitives: Bloom Filters & Counting Filters · Distributed Rate Limiting (the per-IP velocity rule is a rate limit that marks instead of blocking) · Drill: Bloom filter crawler dedup
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | 3 s freshness | Flink on Managed Service for Apache Flink; 1-min tumbling windows; early firings to Valkey every second; closed minutes to DynamoDB | Stateful job to run; provisional numbers |
| 2.2 | When is a minute done? | Watermarks: max event time − 5 s, minimum over shards; clamp; idleness | Delay vs completeness |
| 2.3 | 45-minute-late clicks | Late side output → per-hour late totals; hourly exact recount writes final_*, gated on delivery | Two numbers to reconcile |
| 2.4 | Replay double-billing | Checkpoints (exactly-once state) + SET of absolute totals (idempotent sink) + full flush after restore + job generation | Checkpoint overhead; replay and flush after failures |
| 2.5 | 40K/s on one ad | Key with no ad in it; local pre-aggregation before keyBy(ad_id), deciding lateness per click and flushing before watermarks and checkpoints | 500 ms; a shuffle for every click |
| 2.6 | Bots | Exact dedup in Flink state, cleared by event-time timers 2 h after the impression; velocity per (IP, ad); data-center ranges; offline checks into the final count | False positives; tuning |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Two readers of one stream: Flink for seconds-fresh provisional numbers, Firehose for the lake that the exact hourly recount reads. The dashboard reads the last two hours from Valkey and older history from DynamoDB.
Why these pieces:
- Network Load Balancer in TCP pass-through mode; the ingest tasks terminate TLS themselves. Nearly every click is a new TLS connection, and a Layer 7 load balancer bills per new connection (R2.6 compares them).
- Redirect and SDK ingest service on EC2 (Graviton
c7g.xlarge), at least 2 per AZ. It checks tokens, applies the event-time clamp, packs clicks into Kinesis records by bytes (concatenated JSON objects), closing a record before the next click would push it past ~4.8 KB, so no record crosses Firehose's 5 KB billing step; at a 500 B average that's about 9 clicks. It writes with a random partition key. It redirects without waiting for Kinesis, holding at most 100 ms of clicks in memory (the cost of that choice is in R2.7). It keeps Round 1's local disk spool for Kinesis errors, and publishes its counters every minute: requests accepted, records delivered, clicks spooled, and its "delivered-through" time. - Kinesis Data Streams, 64 provisioned shards (R2.6), reached through an interface VPC endpoint.
- Flink reads with enhanced fan-out (EFO): each consumer gets its own 2 MB/s per shard, pushed to it, instead of sharing 2 MB/s and five
GetRecordscalls per second with Firehose. - Valkey holds each ad's per-minute running totals for the last 2 hours: what dashboards poll. Each of its 3 shards has a primary and two replicas, one node in each AZ, and the query service reads from the node in its own AZ, so the 10K reads a second don't pay cross-AZ transfer.
- DynamoDB holds closed minutes (8 days), hourly rows (13 months), the late totals and the final counts.
- The exceptions log is Flink writing, for every click it did not count as valid on time (duplicates, invalid clicks, late clicks), one line to S3:
request_id,click_id, reason andjob_gen. For a duplicate it records therequest_idit dropped, so the recount knows exactly which copy the stream kept. Flink's file sink commits files only when a checkpoint completes, so the log matches the stream's state exactly. It's small: about 5% of clicks, ~60 bytes each. - Firehose turns the stream into the Parquet lake, as in Round 1.
- The hourly job recounts from the lake and writes
final_*; the daily billing export reads final rows only.
The Flink job
Synthesizing vector architecture diagram...
Three shuffles: by click for dedup, by source and ad for velocity, by ad for counting. Pre-aggregation sits just before the last one, where the hot key would otherwise land, and it is where lateness is decided, click by click. Hour rows' fast_* values are the sum of that hour's closed minutes, rolled up by the window operator when the hour's last minute closes.
The data model
ad_metrics (DynamoDB), one table for ads and campaigns:
| Attribute | Example | Written by |
|---|---|---|
entity (partition key) | ad_1042 or cmp_88 | – |
bucket (sort key, String) | M#2026-09-28T10:14 or H#2026-09-28T10 | – |
fast_valid, fast_invalid_dup, fast_invalid_velocity, fast_invalid_dc, fast_spend_micros | 812, 9, 3, 14, 365400000 | Flink, when the minute closes (minute rows); hour rows get the sum of the hour's 60 closed minutes when the last one closes, never a separate 1-hour window, so a click late for its minute can't be counted in both fast_* and late_* |
late_valid, late_spend_micros | 23, 10350000 | Flink's late path (hour rows only) |
final_valid, final_invalid, final_spend_micros, final_status | 48240, 6310, 21708000000, final | Hourly recount (hour rows only) |
job_gen | 41 | Flink; the condition in step 2.4 |
expires_at | epoch seconds | Minute rows: 8 days, for TTL deletion |
Each writer sets only its own attributes with UpdateItem ... SET, so the stream and the recount never overwrite each other. The minute rows expire through DynamoDB TTL, which deletes items typically within a few days after expires_at, not at an exact time; the query service ignores minute rows older than 7 days, so a late deletion is invisible.
Valkey keys (cluster mode; the {ad_1042} hash tag keeps an ad's keys in one slot so one MGET reads them):
| Key | Value | Expiry |
|---|---|---|
m:{ad_1042}:202609281014 | "valid,invalid,spend_micros", for example "37,2,16650000" | 2 hours |
wm:17 | watermark of window subtask 17, rewritten every second | 60 s |
Flink only rewrites an ad's key when its count changed, so an ad with no new clicks is never touched. The wm:* heartbeats tell the query service the difference between "no clicks" and "the pipeline stopped": data_complete_through is the minimum over all subtasks' wm:* keys, and a missing key means that subtask is not reporting.
Reconciliation: three equations that cover every path
A number we can't explain is a number we can't bill. Every hour, the recount checks three equations. Any non-zero difference holds that hour's billing and pages the on-call.
-
Ingest. For each ingest host and minute: requests accepted (its own counter) = distinct
request_ids in the lake + requests still in its spool. This proves the lake has everything the edge took in. -
Stream. For each minute, from Flink's counters: records read = on-time valid + on-time invalid + duplicates + late + unparseable (sent to a dead-letter prefix). This proves the stream dropped nothing unaccounted.
-
Fast vs final, for each ad and event hour:
final_valid = fast_valid + late_valid − A + BA= clicks the stream counted valid that the recount rejects: offline fraud verdicts (and duplicates, which should be zero because the dedup state covers the whole acceptance window).B= clicks the recount counts valid that the stream rejected: for example velocity verdicts overturned because the IP is a known carrier gateway.- The recount computes
AandBclick by click, by joining the lake with the exceptions log (keeping the highestjob_genper click). The stream's valid set is every copy in the lake whoserequest_idis not in the log asduplicateor invalid;lateentries stay in the set, because they are counted inlate_valid. For duplicates, the recount keeps the same copy the stream kept (the one whoserequest_idthe log didn't drop), not the one with the earliestevent_time, so a double-click across an hour boundary lands in the same hour in both counts. The equation must balance exactly. The same equation runs onspend_micros.
Trace 1: a normal web click (times from R2.6's freshness budget)
Synthesizing vector architecture diagram...
The user leaves after the token check. The count reaches Valkey through two buffers (100 ms at ingest, 500 ms of pre-aggregation) and one timer (the 1-second firing).
Trace 2: a click that arrives 45 minutes late
- 09:31:12 on the phone: the user taps an ad. The app records it with the token and
device_time09:31:12. - 10:16:03: the phone uploads. The ingest service computes
event_time = clamp(09:31:12, impression 09:30:58, arrival 10:16:03) = 09:31:12. Arrival − impression = 45 min 5 s, under 2 hours: accepted. - Flink: dedup says new; velocity and ranges pass. At the pre-aggregation operator, the watermark is ~10:15:58, far past the end of its minute (09:32:00): late.
- The side output adds it to
(ad_1042, 09:00); within 10 seconds, the hour row'slate_validgoes from 22 to 23. The dashboard's 09:00 hour now includes it. The minute row for 09:31 does not change. - The exceptions log records
c-… late. - Hour 09:00 (it ends at 10:00) is recounted by the 10:20, 11:20 and 12:20 runs. The 10:20 run may miss this click, since Firehose delivers it by about 10:21; the 11:20 run includes it in
final_valid. The 12:20 run is the first that starts at least 2 h 5 min after 10:00, so it marks hour 09:00final.
Trace 3: a replay after a crash
- 10:04:30: checkpoint 1,207 completes (every operator's state plus each shard's read position).
- 10:04:52: a worker dies. Window 10:03 closed at about 10:04:05, before the checkpoint, so its row is written and its state gone; window 10:04 is still open.
- ~10:05:40: the service has restarted the job from checkpoint 1,207 (restart time of about 50 seconds is an assumption; we measure it). Flink rereads everything after the saved positions: about 70 seconds of clicks, ~810,000 at the average rate.
- Before emitting anything new, every sink operator does its full flush: it rewrites each key it holds in restored state (open minutes in Valkey, late totals and other pending rows in DynamoDB), about 2 minutes for the ~300K late-total keys at 2,800 WCU. Any value written between 10:04:30 and the crash is replaced, including keys the replay will never touch; keys created after 10:04:30 that restored state doesn't hold are found by their
ckpttag and deleted. - Early firings for 10:04 briefly show smaller running totals in Valkey (state rolled back to 10:04:30), then climb back as the replay catches up.
data_complete_throughstays at about 10:04:25 until then, so tools can tell. - With 32 KPUs processing about 64K clicks/s and 11.6K/s arriving, the backlog drains at ~52K/s: about 16 seconds of processing, running alongside the flush.
- Window 10:04 closes on the watermark and writes
SET fast_valid = …once. No row was added to twice.
R2.6 Numbers and Cost
Targets
| Quality | Target | Why this number |
|---|---|---|
| Redirect availability | 99.95% (21.9 min a month) | Stateless, 3 AZs; nothing slow on the path |
| Dashboard freshness | Click to API < 3 s, P99 | Budget below |
| Billing correctness | 0 double-billed clicks; every hour final 2 h 20 min after it ends; the three equations balance | Steps 2.3, 2.4, R2.5 |
| Stream durability | No click lost once Kinesis has acknowledged it | 24-hour retention in 3 AZs; checkpoints |
We don't promise "five nines" (99.999%, about 5 minutes a year), which a common textbook answer claims for click ingestion: one region can't honestly back it. Round 3 adds regions and raises the target to 99.99%.
Traffic, and one planning peak
| Item | Math | Result |
|---|---|---|
| Clicks per day | 50B impressions × 2% | 1B |
| Average | 1B ÷ 86,400 s | 11,574/s |
| Busiest hour on a normal day | ~3× average (assumed evening peak) | ~35K/s |
| Super Bowl night | normal evening ~35K/s + the 40K/s campaign + other advertisers' event ads (assumed ~20K/s) | ~95K/s |
| Planning peak | rounded up | 100K/s (8.6× average) |
A common textbook answer calls 100K/s a "5× surge", but 5 × 11,574 is 57,870. We use one peak, 100K/s, everywhere below, and say how we got it.
Bytes and storage
| Item | Math | Result |
|---|---|---|
| Raw per day | 1B × 500 B | 500 GB/day |
| Peak ingress | 100K/s × 500 B | 50 MB/s = 400 Mbps |
| Parquet per day | 500 GB ÷ 4 | 125 GB/day |
| Per year | 125 GB × 365 | 45.6 TB/year |
Kinesis: records and bytes, unpacked vs packed
| One click per record | ~9 clicks per record (chosen) | |
|---|---|---|
| Records at peak | 100,000/s | 100,000 ÷ 9 ≈ 11,111/s |
| Shards for records | 100,000 ÷ 1,000 = 100 | 11,111 ÷ 1,000 = 12 |
| Shards for bytes | 50 MB/s ÷ 1 MB/s = 50 | 50 (packing doesn't shrink bytes) |
| With 80% headroom | 100 ÷ 0.8 = 125 → 128 | 50 ÷ 0.8 = 62.5 → 64 |
| Per shard at peak | 781 records/s, 0.39 MB/s | 174 records/s, 0.78 MB/s |
With a random key per record, each shard's share varies only by chance. Records per shard per second behave roughly like a Poisson count with mean 174, so the standard deviation is about √174 ≈ 13, and the busiest of 64 shards in a given second sits about 2.4 standard deviations up: ~205 records/s × ~4.5 KB ≈ 0.92 MB/s, about 18% above the mean and still under the 1 MB/s limit. That is why we keep the 80% headroom rather than sizing to the mean.
Provisioned vs on-demand Kinesis. On-demand needs no shard planning: a new on-demand stream starts at 4 MB/s of writes and adapts to twice its highest write rate of the previous 30 days, up to 10 GB/s in us-east-1 and eu-west-1 and 200 MB/s by default in most other regions. A jump beyond twice the previous peak can throttle for about 15 minutes, though On-demand Advantage lets us set warm throughput ahead of an event. In every mode, one partition key still can't exceed a single shard's limits. At our volume:
| Mode | Math | ≈ Monthly |
|---|---|---|
| Provisioned, 64 shards + EFO (chosen) | shards $701 + PUT units $47 + EFO $899 | $1,647 |
| On-demand Standard | $0.04/h stream × 730 = $29; writes 16,900 GB (each record rounded up to 1 KB) × $0.08 = $1,352; EFO reads 15,200 GB × $0.05 = $760; Firehose's reads 15,200 GB × $0.04 = $608 | ≈ $2,750 |
| On-demand Advantage | $0.032/GB in, $0.016/GB out, with an account minimum of 25 MB/s each way (~65.7 TB a month): 65.7 TB × $0.032 = $2,102 + 65.7 TB × $0.016 = $1,051; our 16.9 TB in and 30.4 TB out are below both minimums | ≈ $3,150 |
Provisioned wins here because our traffic follows a predictable daily curve and our spikes are scheduled events we pre-scale for. On-demand would be the right call for a new, unpredictable workload.
Flink
AWS's starting guidance for Managed Service for Apache Flink is about 1 MB/s per KPU (a KPU is 1 vCPU and 4 GB of memory, with 50 GB of local disk). We plan with that and load-test:
| Item | Math | Result |
|---|---|---|
| Per KPU | 1 MB/s ÷ 500 B | ~2,000 clicks/s |
| Normal days | busiest hour 35K/s ÷ 2,000 = 18 KPUs; with room for restarts to catch up | 32 KPUs (parallelism 32, 2 shards per source subtask) |
| Super Bowl | 100K/s ÷ 2,000 = 50 | pre-scaled to 64 KPUs (parallelism 64) |
We pre-scale on a schedule before known events, because the service's automatic scaling reacts only after 15 minutes of CPU above 75%, doubles parallelism, and restarts the application to do it. 64 KPUs is also the default per-application limit, so any bigger event needs a quota increase requested well ahead. If traffic beats the plan, Flink applies backpressure and falls behind; the 24-hour stream holds the backlog, and dashboards show a stalled data_complete_through rather than wrong numbers.
Flink state
| State | Math | Size |
|---|---|---|
| Dedup, cleared 2 h after the impression | 11,574/s × 7,200 s = 83.3M IDs × ~60 B | ~5 GB (worst case, 100K/s for 2 full hours: 720M × 60 B = 43 GB) |
| Velocity | ~10M active (IP or device, ad) keys (assumed) × ~64 B | ~640 MB |
| Windows | 100K ads × ~1 KB (a few open minutes plus the hour) | ~100 MB for the whole job, not per worker |
| Late totals | 100K ads × 3 hours × ~100 B | ~30 MB |
| Total | ~6 GB typical, against 32 × 50 GB = 1.6 TB of local disk |
With incremental checkpoints, each checkpoint uploads only new RocksDB files: at the average rate, about 11,574 × 30 s × 60 B ≈ 21 MB of new dedup entries per 30-second checkpoint, plus compaction output.
DynamoDB writes
| Writes | Math | Per second |
|---|---|---|
| Ad minute rows | 100K ads ÷ 60 s (upper bound: every ad clicked every minute) | 1,667 |
| Campaign minute rows | ~20K campaigns ÷ 60 s | 333 |
| Hour rows from Flink | 120K ÷ 3,600 s | 33 |
| Late totals | ~2% of clicks late (assumed) = 231/s; each (ad, hour) rewritten at most every 10 s | ≤ 231 |
| Hourly recount | 3 hours × 120K rows ÷ 3,600 s | 100 |
| Total | ~2,360/s → provisioned 2,800 WCU (items under 1 KB) |
Against writing every click at peak (100,000/s), aggregation cuts database writes by 98.3% (100,000 → 1,667 for ad rows); against the average of 11,574/s, it's 86%. The minute rows are written as windows close, which is a burst at the top of each minute; the sink spreads each minute's writes over the following ~50 seconds with a rate limiter, so a steady 2,800 WCU is enough.
Freshness, step by step. These happen one after another, so the worst cases add:
| Step | Worst case |
|---|---|
| Ingest host packs clicks | 100 ms |
PutRecords to Kinesis | 50 ms |
| EFO delivery to Flink (~70 ms on average) | 200 ms |
| Three network shuffles inside Flink (buffer timeout 50 ms each) | 150 ms |
| Local pre-aggregation | 500 ms |
| Early firing interval | 1,000 ms |
| Valkey write | 5 ms |
| Query service reads Valkey | 10 ms |
| Total | ~2.0 s, leaving ~1 s for garbage collection and brief backpressure inside the 3 s P99 |
We do not cache API responses: each ad is polled once per 10 seconds, so a cache would almost never hit, and a 1-second response cache would eat a third of the budget.
Rough monthly cost (us-east-1 list prices, on-demand, 730 hours and 30.4 days a month; check the AWS Pricing Calculator before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| Ingest hosts | c7g.xlarge at $0.145/h; 2 per AZ at night, 4 per AZ in the evening peak, averaging ~8 (assumes ~1,500 new TLS connections plus redirects per vCPU per second at 60% target load; to be load-tested) × 730 h | $850 |
| Network Load Balancer | Bytes dominate: ~6 KB per click including the TLS handshake (estimate) × 1B/day ≈ 250 GB/hour ≈ 250 NLCU × $0.006/h × 730 h, + $16 hourly | $1,110 |
| Kinesis VPC endpoint | 15.2 TB/month × $0.01/GB + 3 AZs × $0.01/h × 730 h | $174 |
| Kinesis shards | 64 × $0.015/h × 730 h | $701 |
| Kinesis PUT units | 111M packed records/day × 30.4 = 3.38B × $0.014 per million (each record under 25 KB is 1 unit) | $47 |
| Enhanced fan-out | 64 consumer-shards × $0.015/h × 730 h = $701; 15,200 GB × $0.013 = $198 | $899 |
| Managed Flink | (32 + 1 orchestration KPU) × $0.11/h × 730 h = $2,650; running storage 32 × 50 GB × $0.10 = $160 | $2,810 |
| Firehose | 111M records/day, each billed as 5 KB = 556 GB/day × 30.4 = 16,900 GB × ($0.029 ingest + $0.018 Parquet conversion) | $794 |
| S3 lake | After a year: 45.6 TB × $0.023 (S3 Standard, first 50 TB tier) | $1,050 |
| Recount and offline checks | Athena: each hourly run scans ~4.3 hours of Parquet, ~13 GB of needed columns = $0.07 × 720 runs, + daily runs ≈ $60; offline fraud jobs on EMR Serverless ≈ $500 (estimate) | $560 |
| DynamoDB | 2,800 WCU × $0.00065 × 730 = $1,329; 600 RCU = $57; storage: minute rows 8 days (1.38B items × 150 B = 207 GB) + hour rows 13 months (1.14B × 200 B = 228 GB) = 435 GB × $0.25 = $109 | $1,495 |
| ElastiCache for Valkey | 9 × cache.r7g.large (3 shards, each a primary + 2 replicas, one node per AZ, so every read stays in its AZ) at ~$0.175/h (about 20% below the $0.219 Redis OSS rate) × 730 h. Reading across AZs from 6 nodes instead would cost ~$700 a month in transfer (10K reads/s × ~2 KB × 2/3 cross-AZ × $0.02/GB), more than the 3 extra nodes ($383) | $1,150 |
| Dashboard API | ALB: 10K requests/s × ~2 KB = 72 GB/hour ≈ 72 LCU × $0.008 × 730 + $16 = $436; 6 Fargate tasks (1 vCPU, 2 GB) = $216 | $652 |
| CloudWatch, Flink-to-Valkey cross-AZ traffic, misc | cross-AZ ≈ 1.9 TB × $0.02/GB effective ($0.01 each way) ≈ $40; the rest estimated | $350 |
| Total | ≈ $12,600 |
Synthesizing vector architecture diagram...
No single line dominates. The stream processor is the largest; the lake, which people expect to be expensive, is under a tenth of the bill.
That's about $416 per billion clicks ($12,640 ÷ 30.4B). Four lessons:
- Packing paid for itself many times over. One click per record would bill Firehose for 5 KB × 1B = 5 TB a day: 152 TB × $0.047 ≈ $7,140 a month instead of $794, and need 128 shards (+$1,400 of shard-hours and EFO, +$380 of PUT units). About $8,100 a month saved, for a few lines in the ingest service, and possible only because the partition key no longer carries the ad (step 2.5).
- The request-priced services would have been the biggest bills. API Gateway for 10K dashboard queries/s: 26.3B requests a month at about $0.90 per million ≈ $23,700. AWS WAF in front of the click path: 30.4B requests × $0.60 per million ≈ $18,200. An ALB instead of the NLB on the click path: ~463 LCU for 11,574 new connections/s ≈ $2,700. Each is more than most of the table above.
- The aggregation is the saving. Writing every click to DynamoDB on demand: 30.4B writes × $0.625 per million ≈ $19,000 a month, and the hot ad's item would throttle at 1,000 writes/s per partition.
- Networking choices show up in dollars. The ingest hosts reach Kinesis through an interface endpoint at $0.01/GB; through a NAT gateway it would be $0.045/GB, ≈ $680 a month for the same bytes. Same-AZ Valkey reads save more than the replicas they need.
Two options worth checking before building the lake path, not assumed above: Firehose's pricing for a Kinesis Data Streams source without the 5 KB rounding (a higher per-GB rate on actual bytes), and Kinesis Data Streams' own delivery to S3, which AWS offers only for on-demand streams. Either could remove the need to pack records.
R2.7 Trade-Offs
Window size
| 10-second windows | 1-minute windows (chosen) | 1-hour windows only | |
|---|---|---|---|
| Rows written | 6× ours | 1,667/s | 28/s |
| Dashboard granularity | Very fine, noisy at low volume | What bidding tools use | Too coarse to steer |
| Freshness | Same: early firings give seconds either way |
Watermark allowance: delay vs completeness
| Allowance | Minute rows written after | Share of clicks "late" (assumed: web clicks arrive within ~1 s; ~10% of clicks are SDK uploads, a fifth of those delayed) |
|---|---|---|
| 1 s | ~1 s | ~2.5% |
| 5 s (chosen) | ~5 s | ~2% |
| 60 s | ~60 s | ~1.8% |
Past a few seconds, a bigger allowance buys almost nothing: the late clicks are the ones delayed by minutes, and no reasonable allowance catches them. They need the late path either way.
Lambda vs kappa
| Lambda: stream + batch recount (chosen) | Kappa: stream only; reprocess by replaying the stream | |
|---|---|---|
| Final numbers | From an independent recount over the lake | From the same job code |
| A bug in the stream job | The recount still bills correctly, and the equations flag the difference | Every number, including the bill, is wrong until someone replays |
| Replays | Not needed for billing | Need the stream kept long enough (up to 365 days, at extra cost) and a replay that doesn't double-publish |
| Two code paths | Yes: rules in both, and they must agree | No |
We keep two paths because the bill needs a check that doesn't share the stream's code. The exceptions log keeps the two paths comparable click by click. Repairs come from the lake, the stored source of truth, never by replaying raw stream records, which would re-publish clicks that were later found invalid.
Exactly-once vs idempotent at-least-once
| Transactional sink (two-phase commit) | Idempotent sink (chosen) | |
|---|---|---|
| Duplicates | Never published | Published again, overwritten with the same value |
| Latency | Output visible after each checkpoint: ≥ 30 s | Immediate |
| Sinks | Only ones that support transactions | Any store that can set a value by key |
Fast redirect vs zero crash loss
| Redirect after Kinesis acknowledges each click (Round 1) | Redirect first, pack for 100 ms (chosen) | |
|---|---|---|
| User-visible latency | +20–50 ms per click | None |
| Records and Firehose cost | 1 click per record: the $8,100 above | Packed |
| Crash of an ingest host | Nothing lost | Up to 100 ms of that host's clicks lost (at ~5K clicks/s per host, ≤ 500 clicks), never billed |
We accept the second: it can only under-bill, never double-bill, and a host crash is rare. A hard requirement of "zero loss" would put the log append back on the user's path.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds | Drill |
|---|---|---|---|
| Checkpoints stall under large state | lastCheckpointDuration climbs from ~2 s to over 60 s; checkpoints expire; after a failure the replay is longer. | Incremental checkpoints, so a checkpoint uploads only new RocksDB files, not all 6–43 GB. Managed Service for Apache Flink uses RocksDB with incremental, asynchronous checkpoints by default; we confirm the application hasn't overridden them. Under backpressure, barriers queue behind data; unaligned checkpoints let a barrier overtake buffered records (the buffers go into the checkpoint). On the managed service they're enabled in the job's code or through a support case, not a console setting. We alarm on duration > 10 s and fix the backpressure's cause, usually a hot key or too few KPUs. | – |
| A worker crashes | One task manager lost. | The service restarts the job from the last checkpoint (≤ 30 s old) and replays (trace 3). At the Super Bowl peak, 64 KPUs process ~128K/s against 100K/s arriving: a backlog of 80 s × 100K = 8M clicks drains at 28K/s in ~5 minutes. Dashboards show the stalled data_complete_through; SETs make the replay safe. | – |
| The watermark is stuck | Minute rows stop; data_complete_through stops advancing, though Valkey totals keep moving. | Usually a shard with no data: the idleness timeout (30 s) removes it from the minimum. If one shard's reader is stuck, the per-shard lag metric points at it. We alarm on watermark lag (wall clock minus watermark) > 60 s. | – |
| A hot shard | WriteProvisionedThroughputExceeded > 0 on one shard. | With random keys this means a bug: an ingest host using a fixed key. Otherwise, all shards are near their limit, and we raise the shard count (UpdateShardCount, which has per-day call limits and can at most double the count per call, so we raise it well ahead of known events). | – |
| A botnet storm | 50K extra clicks/s on a few publishers; invalid share jumps from ~3% to 30%. | The filters mark them invalid, so dashboards and budgets don't count them. Flink needs ~25 more KPUs' worth of throughput: backpressure and lag until the scheduled or manual scale-up. The invalid-ratio alarm pages the fraud on-call, who can block a publisher in the ad servers. | – |
R2.9 Production Gotchas
1. Writing every click to a database
- Symptom: throttling on one item, a large bill, and totals that are too high after every retry.
- Cause: one
UpdateItem ... ADDper click: 100K writes/s at peak, 40K/s on the hot ad's item, and an increment that retries can't undo. - Fix: aggregate in the stream and write absolute totals per window (steps 2.1, 2.4).
2. Processing time for billing
- Symptom: clicks from 23:59 billed on the next day; a backlog shifts an hour's clicks into the following hour.
- Cause: windows by arrival time or server clock.
- Fix: event time, clamped, with watermarks and a late path (steps 2.2, 2.3).
3. Unbounded click history in Redis
- Symptom: memory grows by ~64 GB a day until the cluster runs out; after a Flink restore, replayed clicks are marked as duplicates.
- Cause: every
click_idin a Redis set with no expiry, outside Flink's checkpoints. - Fix: exact keyed state inside Flink, cleared by an event-time timer at the end of the acceptance window (step 2.6).
4. Blocking the redirect on fraud analysis
- Symptom: click latency rises from ~15 ms to hundreds of milliseconds; users give up before the page loads.
- Cause: a model or a remote lookup on the click path.
- Fix: only the token check on the path; everything else in the stream and offline (step 2.6).
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | A replicated log as the buffer; checkpoints with idempotent sinks; backpressure that lags instead of dropping; three reconciliation equations that must balance before billing. REL 4 · REL 5 · REL 10 · REL 11 |
| Performance Efficiency | Aggregation cuts writes 98% at peak; local pre-aggregation removes the hot key; early firings meet 3 s without transactional sinks; EFO for each reader. PERF 3 · PERF 5 |
| Security | Signed tokens; least-privilege roles per component (below); TLS everywhere; raw IPs and user agents only in the lake, never in dashboards. SEC 3 · SEC 7 · SEC 9 |
| Cost Optimization | ≈ $12,600/month, ~$416 per billion clicks; packing saves ~$8,100; request-priced services priced and avoided; interface endpoint instead of NAT; same-AZ Valkey reads. COST 5 · COST 6 · COST 8 |
| Operational Excellence | Alarms with first actions (below); a data_complete_through heartbeat any client can see; scheduled pre-scaling for known events. OPS 8 · OPS 10 |
| Sustainability | Light this round: scale to the daily curve, pre-scale only for events; Graviton hosts; minute rows expire after 8 days. SUS 2 · SUS 4 |
Security in detail. Ingest hosts may only put records to the one stream and read the signing keys. The Flink application's role may read the stream, write to its checkpoint and exceptions prefixes, write ad_metrics and reach Valkey; it can't read the lake. Only the recount job may write final_* attributes (a separate role, with an IAM condition limiting it to those attribute names). The query service is read-only. The lake and checkpoints are encrypted with SSE-KMS.
Operations in detail.
| Alarm | Threshold | Severity | First action |
|---|---|---|---|
| Watermark lag (wall clock − job watermark) | > 60 s for 5 min | P1 | Backpressure? A stuck shard? Scale or restart |
| Kinesis iterator age for Flink's EFO consumer | > 30 s | P1 | Flink behind: check CPU and checkpoint duration |
| Checkpoint duration / failures | > 10 s, or 3 failures in a row | P2 | Hot key? Too few KPUs? |
| Late share | > 5% of clicks for 15 min | P2 | An SDK release, a carrier outage, or clock trouble |
| Invalid share, by reason | > 10%, or 3× the weekly norm | P2 (fraud on-call) | Which publishers? Block or tune |
| Reconciliation difference | ≠ 0 for any hour | P1 | Hold that hour's billing; find the path |
| Firehose data freshness | > 15 min | P2 | Delivery errors or conversion failures |
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer adds over L5
- Chooses stream processing for freshness and keeps an exact recount for billing, and says which number is which.
- Defines a watermark precisely, clamps device time, and handles idle shards and late data without reopening windows.
- Says exactly what Flink's exactly-once covers (state) and makes the sink idempotent; knows why a transactional sink would break the 3 s target.
- Does the per-shard math, not just the per-key math, and moves the hot key into Flink with local pre-aggregation.
- Chooses dedup state that rolls back with the job, and can size both the Bloom filter and the exact alternative.
- Reconciles every path with equations that must balance.
Follow-up questions
-
"Why not just run everything in Flink and skip the batch recount?" Answer: we could (kappa), and many teams do for dashboards. For billing we want a second computation that doesn't share the stream's code: if a bad deployment of the job miscounts for an hour, the recount still bills correctly and the equation flags the gap. The price is keeping the rules consistent in two places, which the exceptions log lets us check click by click.
-
"The dedup state is 5 GB on average. What happens during a 2-hour Super Bowl peak?" Answer: up to 720M IDs × ~60 B ≈ 43 GB, spread over 64 KPUs with 50 GB of local disk each (3.2 TB), so it fits with room. What grows is RocksDB compaction work and incremental checkpoint size, which is why we pre-scale and watch checkpoint duration. Event-time timers and RocksDB compaction keep it near two hours' worth.
-
"A publisher says we filtered 30% of their clicks as invalid yesterday. How do you answer?" Answer: from the exceptions log and the final rows: invalid counts by reason, by hour, and the rule that fired. If it's velocity on a carrier gateway range, that's a false positive: we add the range to the reviewed list, and the recount's
Bterm restores those clicks for every hour not yet final. For final hours, it becomes a credit, not a rewrite.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Run the batch job every minute" | Buffering, query startup and rescans make seconds impossible, at 1,440 scans a day. |
| "Close windows on the wall clock" | Any backlog makes every window close before its clicks arrive. |
| "Flink gives us end-to-end exactly-once" | It gives exactly-once state; outputs repeat after a replay, so the sink must be idempotent. |
| "Salt the key 64 ways" | Check each shard, not each key: background traffic and hash collisions still throttle. |
| "A Bloom filter in Redis for dedup" | False positives drop real clicks, an overfilled filter fails silently, and external state isn't rolled back on replay. |
| "Put the bill on the dashboard numbers" | They're provisional: late clicks, offline fraud checks and replays still change them. |
Round 3 · Architect · "Global, Budget Pacing, Privacy, Attribution"
~45 min · Principal (L7) · 3 regions (us-east-1, eu-west-1, ap-northeast-1), 3 AZs each · 5B clicks/day · 500K active ads, 200K campaigns with daily budgets · 100M conversions/day · redirect 99.99%
R3.0 Where We Left Off
Round 2 in 60 seconds. "A billion clicks a day, 100K/s at peak. Ingest hosts check signed tokens, clamp device time into [impression, arrival], redirect immediately, and pack clicks into records of up to ~4.8 KB (about nine) with a random key: 64 shards. Flink on Managed Service for Apache Flink, 32 KPUs, reads with enhanced fan-out: exact dedup on
click_idin keyed state until 2 hours after the impression, a velocity rule per IP or device and ad, data-center ranges, local pre-aggregation that decides lateness click by click, then 1-minute event-time windows per ad with a 5-second watermark. Running totals go to Valkey every second, closed minutes and late totals to DynamoDB, all as absolute SETs, and every sink rewrites all its keys after a restore, so replays are harmless. Firehose writes the Parquet lake; an hourly Athena recount writes separate final attributes, and an hour is final 2 hours 20 minutes after it ends, once every host has delivered. Three equations reconcile ingest, stream and final counts before billing. About $12,600 a month, $416 per billion clicks. Open costs: one region; the counts never influence which ads we serve; raw IPs sit in the lake; and conversions don't exist yet."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: one stream, a fast provisional path and an exact recount that writes its own attributes.
Round 2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | 3 s freshness | Flink windows; early firings to Valkey | Provisional numbers |
| 2.2 | When is a minute done? | Watermarks, clamp, idleness | Delay vs completeness |
| 2.3 | Late clicks | Side output; hourly exact recount into final_* | Two numbers |
| 2.4 | Replay double-billing | Checkpoints + absolute SETs + full flush after restore + job generation | Replays and flushes after failures |
| 2.5 | Hot ad | Ad-free key; local pre-aggregation | A shuffle per click |
| 2.6 | Bots | Exact dedup state; velocity; ranges; offline checks | False positives |
Open costs: one region; nothing feeds back into ad serving; raw IPs and user agents kept indefinitely; no conversions; one region's totals are the whole truth.
R3.1 The Scope Raise
Interviewer: "We're global now: 5 billion clicks a day across the Americas, Europe and Asia. Advertisers set daily budgets, and when a budget runs out their ads must stop, without us overspending by much. Privacy laws in several markets limit what user-level data we keep and where. Advertisers want to know which clicks led to purchases. And finance needs one bill per advertiser that matches across regions."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Where are the clicks? | About 45% Americas, 30% Europe, 25% Asia-Pacific. | Three regional pipelines: 2.25B, 1.5B and 1.25B clicks/day (R3.6). Clicks stay in the region that received them. |
| How do budgets work? | A daily budget per campaign, in the advertiser's time zone. We never bill above it. | Spend must reach ad serving within seconds (step 3.1). Anything past the budget is served free: overspend is our cost, not the advertiser's. |
| How much overspend is acceptable? | Under 1% of the budget, typically. | A safety margin from the full timing chain, and a cap on how fast any budget may be spent (step 3.1). |
| Do campaigns run in several regions? | Many do, with one global budget. | Regional shares of a global budget, rebalanced without a cross-region call on the serving path (step 3.2). |
| How do conversions reach us? | Advertisers send them server-to-server with the click reference we gave them, up to 7 days after the click. | A join of conversions to clicks by key, within 7 days (step 3.3). |
| What do the privacy rules require? | Keep user-level data only as long as needed, keep it in its region, don't report tiny audiences, and respect users' consent choices. The legal team gives us the specifics per market. | Pseudonymized identifiers, retention limits, reporting thresholds, consent flags (step 3.4). |
| How is billing reconciled? | One invoice per advertiser per month; daily statements that must add up across regions. | Regional finals merged in one billing region, capped at budgets, with an append-only adjustments record (step 3.5). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Clicks | 1B/day, one region | 5B/day in 3 regions |
| Active ads / campaigns | 100K / 20K | 500K / 200K, with daily budgets |
| Feedback into serving | None | Budget state read by every ad request |
| Conversions | None | 100M/day, attributed to clicks within 7 days |
| User-level data | Raw IPs and user agents kept | Pseudonymized, 30-day detail, regional |
| Billing | Daily export, one region | Global statements, reconciled daily, capped at budget |
| Availability | 99.95% redirect | 99.99% redirect, surviving a region |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| One region | Asian and European users pay a cross-ocean round trip on every click; a regional outage stops all clicks; European data leaves Europe. |
| Counts go only to dashboards | Ad servers keep serving a campaign long after its budget is gone. |
| Per-region totals are "the" totals | A global campaign's budget is spent in three places at once. |
| Raw IPs and user agents in the lake forever | More user-level data, kept longer, than the rules allow. |
| No conversions | Advertisers can't see what their clicks were worth. |
| Final numbers per region | Nothing merges them, caps them at the budget, or explains a disputed invoice. |
We fix them in this order: stopping on budget (3.1), regions (3.2), conversions (3.3), privacy (3.4), billing disputes (3.5), and build-or-buy (3.6).
R3.3 New Requirements and API Additions
Budget and pacing settings
httpPUT /v1/campaigns/cmp_88/budget HTTP/1.1 Content-Type: application/json { "daily_budget_micros": 2000000000, "time_zone": "America/New_York", "pacing": "accelerated", "regions": ["us", "eu"] }
pacing: even spreads spend over the day; accelerated spends as fast as traffic allows, subject to the rate cap in step 3.1. cmp_88 runs accelerated for an evening sale, which R3.5's trace follows.
The click reference. At redirect time, the ingest host appends our reference to the advertiser's URL (https://shoes.example/spring-sale?adref=eu.c-7a0c…). The advertiser's site stores the latest one in a first-party cookie and sends it back with the conversion. The eu. prefix names the region that recorded the click, so a conversion can be routed to the one region that holds it.
Conversion ingestion
httpPOST /v1/conversions HTTP/1.1 Authorization: Bearer <advertiser server key> Content-Type: application/json { "conversion_id": "ord-55120", "adref": "eu.c-7a0c5e91b24d4f08a1c3d7e60b95f214", "conversion_time": "2026-09-29T09:15:40Z", "value_micros": 89990000, "consent": { "measurement": true } }
json{ "status": "accepted", "conversion_id": "ord-55120" }
Advertisers retry, sometimes days later. conversion_id is the idempotency key, and it's enforced twice: the stream remembers IDs for 7 days, and the batch attribution deduplicates on (advertiser, conversion_id) across the whole 13 months the lake keeps. A retry after 8 days finds no stream memory, but the batch still counts it once. A short idempotency window alone isn't a guarantee.
Privacy-safe breakdowns
httpGET /v1/ads/ad_1042/breakdown?dimension=country&date=2026-09-28 HTTP/1.1
json{ "rows": [ { "country": "US", "valid_clicks": 18204 }, { "country": "CA", "valid_clicks": 1240 }, { "country": "other", "valid_clicks": 131 } ], "suppressed_below": 50 }
Any cell with fewer than 50 clicks is folded into other. Only fixed dimensions (country, device type, hour) are offered, so a caller can't subtract two overlapping custom filters to isolate a small group.
Billing statements
httpGET /v1/billing/statements/2026-09?account=acct_77 HTTP/1.1
json{ "account": "acct_77", "month": "2026-09", "days": [ { "date": "2026-09-28", "valid_spend_micros": 2000250000, "over_budget_free_micros": 250000, "billed_micros": 2000000000, "invalid_clicks": 6310, "status": "final" } ], "adjustments": [ { "id": "adj-3391", "date": "2026-09-14", "reason": "invalid_traffic_found_later", "credit_micros": -351000000 } ], "total_billed_micros": 58122640000 }
R3.4 Design Evolution: Feeding Counts Back, Across Regions, Under Rules
Step 3.1: Stop Serving Ads When the Budget Is Spent
The problem: campaign cmp_88 has $2,000 a day. Clicks are counted within seconds, but ad servers never look at those counts, so the campaign keeps winning auctions after its budget is gone, and we give the clicks away.
What would you do?
The timing chain. Every delay between a click and an ad server acting on it, in order:
| Step | Worst case |
|---|---|
| Ingest packing | 0.1 s |
| Kinesis and EFO delivery | 0.25 s |
| Flink shuffles, pre-aggregation, 1 s firing | 1.65 s |
| Pacer cycle | 1.0 s |
| Delta written, replicated to each AZ's replica | ~0.01 s |
| Ad server poll interval | 1.0 s |
| Pipeline lag L | ≈ 4 s |
Then two terms that are not pipeline delays:
- Clicks on ads already shown. Impressions served before the stop can be clicked later. If the mean delay from impression to click is 20 seconds (an assumption to measure), the clicks still to come equal about 20 seconds of spend.
- Late uploads. About 2% of clicks come from phones that upload them late (Round 2's assumption), and we assume, as a new assumption to measure, that they arrive 15 minutes late on average: 0.02 × 900 s = 18 seconds of spend we haven't seen yet.
Total: 4 + 20 + 18 = 42 seconds. Expected overspend without a margin ≈ spend_rate × 42 s.
| Campaign | Spend rate | Overspend without margin | % of $2,000 |
|---|---|---|---|
| Even pacing | $2,000 ÷ 86,400 s = $0.023/s | $0.97 | 0.05% |
| Accelerated, uncapped, at $5/s | $5/s | $210 | 10.5% |
| Accelerated, capped | ≤ $2,000 ÷ 4,200 = $0.476/s | ≤ $20 | ≤ 1% |
With the margin, the expected overspend is about zero; the cap bounds how far a bad minute (more late uploads than usual) can push it. Whatever lands past the budget is served free and shows as over_budget_free on the statement.
How budget state reaches the ad servers
- One writer at a time. Each region runs an active pacer and a standby. The active one holds a lease: an item in a regional DynamoDB table with an
epochnumber and a heartbeat counter it increments every second. If the standby sees the counter unchanged for 3 seconds (on its own clock), it takes over with a conditional update,SET epoch = :seen + 1on the conditionepoch = :seen, using numbers it read, never DynamoDB's clock. Every sequence number is the pair(epoch, n), so a paused old pacer that wakes up writes with a lower epoch, and everyone ignores it; it also stops itself when it next reads the lease. - The pacer writes each second's changes as one hash,
{pacing}:delta:<epoch>:<n>(campaign → probability and status), with a 5-minute expiry, then sets{pacing}:head = <epoch>:<n>. Every 60 seconds it writes a full snapshot,{pacing}:snap:<epoch>:<n>, with a 5-minute expiry, so only the latest few exist (without an expiry, 12.8 MB a minute would pile up to ~18 GB a day). The shared{pacing}hash tag keeps every pacing key in one shard, so replicas apply them in the order they were written. - A delta is written every second even when nothing changed. It then holds only a sentinel field
_seq, because Valkey deletes a hash that has no fields. The steadily advancingheadis the keep-alive: an ad server can tell "no changes" from "the pacer is dead". - Each ad server polls
{pacing}:head, fetches the deltas it's missing in order, and loads the latest snapshot when it starts, when it finds a gap (a delta already expired), or when the epoch rises; it ignores anything with a lower epoch than the one it has. - If
headhasn't moved for 5 seconds, the ad server treats its state as stale and slows down: campaigns with under 10% of their budget left stop, and the rest serve at half their last probability, until fresh state arrives. Stale budget state must fail toward spending less. - The pacer's budget settings come from the
campaignstable's own change stream (DynamoDB Streams), not from the API service writing to two places. On every start, a pacer first notes its position in the change stream (or starts from the stream's oldest record), then scans the whole table (200K items is cheap), then applies stream records from the noted position, keeping only items with a newer version than the scan saw. Streams keep changes for only 24 hours, so a pacer never relies on replaying them alone. - The budget store is one Valkey primary with two replicas, one node per AZ. Ad servers in the primary's AZ read the primary; the other two AZs read their own replica.
Synthesizing vector architecture diagram...
Ad requests never leave the ad server's memory. Only ~2 small reads a second per ad server cross the network, and each stays inside its AZ, so they don't pay cross-AZ transfer ($0.01/GB each way).
Step 3.2: Clicks Happen in Every Region
The problem: cmp_88 runs in the US and Europe with one $2,000 budget. Each region's pacer sees only its own spend. Asking another region on every pacing decision adds 80+ ms and fails when that region is down.
What would you do?
Why S3 and not a DynamoDB global table for the 5-second snapshots? A snapshot of 200K campaigns × 16 B is 3.2 MB. DynamoDB bills writes per KB, so one snapshot is ~3,200 write units locally plus 3,200 replicated units in each other region: 640 units/s written and 1,280/s replicated, per region, ≈ 15 billion units a month across three regions, about $9,500 a month at us-east-1 rates. The same data through S3 costs a few PUT requests and about $440 of inter-Region transfer (R3.6).
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active
Step 3.3: A Purchase Happened 3 Days After the Click
The problem: a user clicks a shoe ad on Monday, comes back on Thursday and buys. The advertiser sends us the purchase. They want to see, per ad, how many purchases and how much revenue its clicks led to. What would you do?
Step 3.4: Privacy Rules Limit User-Level Data
The problem: the lake holds every click's full IP address and user agent, forever, and some of it has been copied to the US for analysis. Several markets' rules say: keep personal data only as long as it's needed, keep it where it was collected, don't publish statistics about tiny groups, and honor users' consent choices. What would you do?
Step 3.5: The Advertiser Disputes the Bill
The problem: an advertiser says: "Your dashboard showed 49,005 clicks for ad_1042 between 10:00 and 11:00 on the 14th. Your statement bills 48,240. And last month you credited us, then charged us again. Which number is real?"
What would you do?
Step 3.6: Build or Buy?
The problem: the stack is three managed streaming services per region, plus our own code. Leadership asks whether we're overpaying for "managed", and whether a vendor's analytics store would replace half of it. What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Ads keep serving after the budget is gone | Spend from Flink → single-writer pacer (lease epoch) → per-AZ budget nodes → ad server memory; 42 s margin; rate cap; keep-alive deltas | Underspend near the limit |
| 3.2 | Clicks in every region | Regional stacks; shares handed off every 5 s via each region's own S3 object, raised only into released budget; stale shares frozen | Shares lag spend |
| 3.3 | Conversions days later | Keyed join on our adref; 24 h stream preview; exact 7-day batch; dedup by conversion_id | Bigger state; 9 days to final |
| 3.4 | Privacy rules | HMAC with monthly keys deleted after the day-30 rewrite; 30-day detail, 13-month pseudonymized; data kept in its market area; K = 50; consent flags | Coarser reports; fraud linking within a month only |
| 3.5 | Disputed bills | Finals from the lake only; itemized difference; append-only adjustments; separate overrides | Two numbers to explain |
| 3.6 | Build or buy | Per component, by cost per billion clicks | Markups and quotas |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region is Round 2's complete stack plus a pacer. Only two things cross regions: 5-second spend snapshots between pacers, and final aggregates to billing. Raw clicks never do.
Why these pieces:
- Route 53 latency records with health checks send each click to the nearest region; each latency record points to a failover pair, the home region first and its standby cell second (below). Failover is decided by Route 53's health checks, which keep working when a region is down; nothing needs an API call to the failed region, or a DNS record change during the outage. We don't alarm on the health checks' own status metric for this; the canary clicks in R3.9 tell us whether clicks actually land.
- Signing keys in every region and cell. Tokens minted anywhere verify anywhere: keys are replicated to each region's Secrets Manager (replicated secrets), so a click rerouted during a failover still verifies.
- Standby ingest cells, one per region, in the same market area: us-west-2 for us-east-1, eu-central-1 (Frankfurt) for eu-west-1, and ap-northeast-3 (Osaka) for ap-northeast-1. A cell is ingest only: load balancer, ingest hosts, a small Kinesis stream, Firehose to a lake bucket in the cell's own region, and a small function that sums spend per campaign every 5 seconds and publishes it like any region's snapshot.
Regional failover
| Failed region | Clicks go to | What runs there | Capacity plan |
|---|---|---|---|
| us-east-1 | us-west-2 cell | Ingest, stream, lake; spend summaries to the other pacers | Pre-warmed for us-east-1's average (26K clicks/s): 9 hosts, 20 shards |
| eu-west-1 | eu-central-1 cell (EU data stays in the EU) | Same | EU average (17K/s): 6 hosts, 12 shards |
| ap-northeast-1 | ap-northeast-3 cell (Japanese data stays in Japan; confirm every service we need is offered there) | Same | APAC average (14K/s): 6 hosts, 10 shards |
Capacity choice: throttle and spool, count later. A cell is sized for its region's average, not its peak. Above that, Kinesis throttles and the hosts spool to local disk and still redirect, while auto scaling adds hosts over the next ~5–10 minutes; if the region fails at its busiest, some redirects are slow or fail until then, and an alarm on the cell's spool depth and error rate pages. Full N+1 capacity for the largest region's peak (225K/s: ~63 hosts and 144 shards) would cost roughly $8,200 a month for the US cell alone, against ~$1,190 for average capacity (R3.6). Clicks recorded in a cell aren't in any region's Flink: they reach dashboards and bills through the home region's recount once it recovers (the recount reads the cell's lake too), and the cell's spend summaries count every click, even duplicates and invalid ones, so the pacers err toward spending less.
What a failover costs in availability. Health checks every 10 seconds with a failure threshold of 3 detect a dead region in about 30 seconds, and resolvers may keep the old answer for the record's 60-second TTL: about 90 seconds of failed redirects for that region's users. Our 99.99% target allows 4.4 minutes a month (43,800 min × 0.0001), so one regional failover spends about a third of a month's budget.
- Per-region stacks as in Round 2, sized in R3.6. Conversions are ingested in every region and forwarded to the region in their
adref. - The billing region (us-east-1) receives only final hourly aggregates per
(ad, hour, region), a few hundred MB a day, and builds daily statements.
Trace 1: a budget running out (cmp_88, US share $1,180, spend rate $0.25/s, margin 0.25 × 42 = $10.50)
Synthesizing vector architecture diagram...
The stop takes effect about 2 seconds after the counter crosses the margin (one pacer cycle, one poll). The margin covers the clicks still to come; the last $0.25 is served free.
Step by step: at $0.25/s, the remaining $20.00 falls to the $10.50 margin in (20.00 − 10.50) ÷ 0.25 = 38 s, at 21:40:38. In-flight clicks bring the US to $1,181.25 (the expected $10.50 plus a little variance). Europe finishes at $819.00. Total $2,000.25: the statement bills $2,000.00 and shows $0.25 as over_budget_free.
Trace 2: an attributed conversion
- Monday 18:03:12 UTC (20:03 in Paris): a click on
ad_1042is recorded in eu-west-1 withclick_idc-7a0c…; the ingest host appendsadref=eu.c-7a0c…to the landing URL. Flink stores the click's ad, time, price andvalidverdict in its keyed state until 24 hours after the impression. - Tuesday 09:15:40 UTC: the user buys. The advertiser's server posts the conversion with that
adrefto our nearest endpoint, in us-east-1, which forwards it to eu-west-1 (the prefix). - Flink looks up
c-7a0c…: found (15 h 12 min old, inside the 24-hour preview), valid, consent allows measurement.ad_1042's provisional conversions for Monday go up by one, value $89.99. - Wednesday 03:00: the daily batch in eu-west-1 joins Tuesday's conversions with the last 7 days of clicks and confirms it. Monday's attribution becomes final 9 days after Monday.
Trace 3: a billing dispute (the numbers from step 3.5)
- The advertiser opens a dispute for
ad_1042, 2026-09-14, 10:00–11:00. - We pull the hour row from each region (all of it was us-east-1):
fast_valid47,500,late_valid1,505,final_valid48,240. - The recount's saved terms for that hour:
A= 780 (offline checks: one publisher's traffic from a device farm),B= 15 (velocity verdicts overturned by the reviewed carrier-gateway list). 47,500 + 1,505 − 780 + 15 = 48,240: the equation balances. - We export the 780 clicks' IDs, times and reasons. If the advertiser's logs show real purchases from some of them, the fraud team reviews those clicks; any change becomes an
overridesrow (for the recount of hours not yet billed) or anadjustmentscredit for this billed day.
R3.6 Numbers and Cost
Per region
| us-east-1 | eu-west-1 | ap-northeast-1 | Total | |
|---|---|---|---|---|
| Share | 45% | 30% | 25% | |
| Clicks per day | 2.25B | 1.5B | 1.25B | 5B |
| Average rate | 26,042/s | 17,361/s | 14,468/s | 57,870/s |
| Planning peak (Round 2's 8.6× ratio; peaks don't coincide) | 225K/s | 150K/s | 125K/s | |
| Peak bytes | 112.5 MB/s | 75 MB/s | 62.5 MB/s | |
| Shards (bytes ÷ 0.8, rounded up) | 144 | 96 | 80 | 320 |
| Flink KPUs, normal days (32 × volume vs Round 2) | 72 | 48 | 40 | 160 |
| Flink KPUs, pre-scaled for events | 144 | 96 | 80 | |
| Parquet per day | 281 GB | 188 GB | 156 GB | 625 GB |
The us-east-1 application needs more than the default 64 KPUs even on a normal day, and 144 for events, so its quota increase must be requested and confirmed before launch; the other two need it for events.
Budget-state reads. "How many budget reads a second?" has two answers:
| Read | Math | Rate |
|---|---|---|
| Budget lookups by ad requests | 250B impressions/day ÷ 86,400 ≈ 2.9M ad requests/s × ~20 candidate campaigns | ~58M/s, all in ad-server memory |
| Remote reads of budget state | ~4,300 ad servers at peak (assumes ~2,000 requests/s each, 3× average at peak) × ~2 reads/s (head + delta) | ~8,700/s globally, each within its AZ |
| Delta traffic per AZ replica (us-east-1, peak) | ~650 servers × ~32 KB (2,000 changed campaigns × 16 B, a typical second) | ~21 MB/s |
Attribution state
| State | Math | Size |
|---|---|---|
| Stream preview (24 h of clicks) | 5B × ~80 B (ID, ad, time, price, verdict) | ~400 GB (US 180, EU 120, AP 100), against 160 × 50 GB = 8 TB of Flink local disk |
| Batch join input (7 days) | 35B clicks × ~30 B of compressed columns | ~1.05 TB per daily run (8 days read: ~1.2 TB, ≈ $6 of Athena) |
| Conversions | 5B × 2% (assumed conversion rate) | 100M/day, 1,157/s |
Lake growth
| Tier | Math | Steady state |
|---|---|---|
| Full detail, 30 days | 625 GB × 30 | 18.75 TB |
| Pseudonymized, days 31 to 395 (the rewrite drops ~40% of the bytes, assumed) | 375 GB × 365 | 137 TB |
| Total | ~156 TB, reached after 13 months |
The rewrite on day 30 replaces each day's files instead of copying them, so no day is ever stored twice.
Rough monthly cost (list prices per region, on-demand; check the AWS Pricing Calculator before quoting)
Checked regional prices: Kinesis shard-hour $0.015 / $0.017 / $0.0195 (US / Ireland / Tokyo), EFO consumer-shard-hour the same, EFO data $0.013 / $0.0147 / $0.0169 per GB, PUT units $0.014 / $0.0165 / $0.0215 per million; Flink KPU-hour $0.11 / $0.12 / $0.142, running storage $0.10 / $0.11 / $0.12 per GB-month; Firehose ingest $0.029 / $0.031 / $0.036 and Parquet conversion $0.018 / $0.019 / $0.022 per GB; S3 Standard $0.023 / $0.023 / $0.025 per GB-month (first 50 TB). For EC2-based lines outside us-east-1 we apply an estimated +10% (Ireland) and +20% (Tokyo).
| Line | us-east-1 | eu-west-1 | ap-northeast-1 |
|---|---|---|---|
| Kinesis shards + EFO + PUT | 144 × $0.015 × 730 × 2 = $3,154; EFO data 34,200 GB × $0.013 = $445; PUT 7.6B × $0.014/M = $106 → $3,705 | 96 × $0.017 × 730 × 2 = $2,383; 22,800 GB × $0.0147 = $335; 5.07B × $0.0165/M = $84 → $2,802 | 80 × $0.0195 × 730 × 2 = $2,278; 19,000 GB × $0.0169 = $321; 4.22B × $0.0215/M = $91 → $2,690 |
| Managed Flink | 73 × $0.11 × 730 = $5,862 + 72 × 50 GB × $0.10 = $360 → $6,222 | 49 × $0.12 × 730 = $4,292 + $264 → $4,556 | 41 × $0.142 × 730 = $4,250 + $240 → $4,490 |
| Firehose (5 KB per packed record, + conversion) | 38,000 GB × $0.047 → $1,786 | 25,330 GB × $0.050 → $1,266 | 21,110 GB × $0.058 → $1,224 |
| S3 lake (45 / 30 / 25% of 156 TB) | 50 TB × $0.023 + 20.2 TB × $0.022 → $1,594 | 46.8 TB × $0.023 → $1,076 | 39 TB × $0.025 → $975 |
| DynamoDB aggregates, Round 2's 2,800 WCU per 100K ads (300K / 200K / 150K ads active per region, assumed); Round 2's $166 of reads and storage per 100K ads; Ireland and Tokyo at their on-demand price ratios to us-east-1 (1.128, 1.144), our estimate for provisioned rates | 8,400 WCU × $0.00065 × 730 = $3,986 + 3 × $166 = $498 → $4,484 | 5,600 × $0.00065 × 1.128 × 730 = $2,997 + 2 × $166 × 1.128 = $375 → $3,372 | 4,200 × $0.00065 × 1.144 × 730 = $2,280 + 1.5 × $166 × 1.144 = $285 → $2,565 |
| Budget: pacer + budget Valkey (1 primary + 2 replicas) | 3 × $0.175 × 730 = $383; active + standby pacer, 2 × c7g.xlarge = $212; lease table, snapshot PUTs and GETs, change-stream reads ≈ $88 (estimate) → $683 | $683 × 1.1 → $751 | $683 × 1.2 → $820 |
| Ingest hosts + NLB (Round 2's $850 + $1,110 = $1,960 × volume × uplift) | $1,960 × 2.25 → $4,410 | $1,960 × 1.5 × 1.1 → $3,234 | $1,960 × 1.25 × 1.2 → $2,940 |
| Dashboard Valkey, API, recount, offline checks, endpoint, misc (Round 2: $1,150 + $652 + $560 + $174 + $350 = $2,886 × volume × uplift) + attribution batch | $2,886 × 2.25 = $6,494 + $80 → $6,574 | $2,886 × 1.5 × 1.1 = $4,762 + $55 → $4,817 | $2,886 × 1.25 × 1.2 = $4,329 + $45 → $4,374 |
| Region total | ≈ $29,500 ($29,458) | ≈ $21,900 ($21,874) | ≈ $20,100 ($20,078) |
| Global line | Math | ≈ Monthly |
|---|---|---|
| Spend snapshots between regions | Each region's 3.2 MB object read by 2 regions every 5 s: 3.36 TB out per region per month; $0.02/GB from us-east-1 and eu-west-1, $0.09/GB from Tokyo (checked): $67 + $67 + $302 | $440 |
| Standby ingest cells | us-west-2: 9 × c7g.xlarge × $0.145 × 730 = $953 + NLB ~$20 + 20 shards × $0.015 × 730 = $219 → $1,190; eu-central-1: 6 hosts at +10% = $699 + NLB ~$22 + 12 shards ≈ $149 → $870; ap-northeast-3: 6 hosts at +20% = $762 + NLB ~$24 + 10 shards ≈ $142 → $930 (Frankfurt and Osaka shard prices assumed equal to Ireland and Tokyo) | $2,990 |
| Campaigns global table, billing region, misc | small writes; final aggregates are a few hundred MB/day | $250 |
| Total | 29,458 + 21,874 + 20,078 + 440 + 2,990 + 250 | ≈ $75,100 |
Synthesizing vector architecture diagram...
The cross-region line is small because only snapshots and aggregates cross between regions. Tokyo carries 25% of the clicks and about 27% of the cost, from its higher list prices.
That's about $494 per billion clicks ($75,100 ÷ 152B clicks a month). Lessons:
- Regional prices move the bill: Tokyo's Flink KPU-hour is 29% above us-east-1's, its Firehose ingest 24% above.
- What crosses regions decides the transfer bill, and so does how it crosses: DynamoDB global tables for the snapshots would cost ~$9,500 in write units alone.
- The stream processor is the largest single service in every region, which is why step 3.6 revisits it first.
- Standby capacity is a choice with a price: ~$3,000 a month buys average-rate cells in every market area; peak-rate cells would cost several times that.
R3.7 Trade-Offs
Pacing lag vs overspend
| Poll every 1 s, 42 s margin (chosen) | Poll every 10 s | Check a central counter per request | |
|---|---|---|---|
| Pipeline lag | ~4 s | ~22 s (pacer and poll both 10 s) | ~0, plus a network call per candidate |
| Margin | 42 s of spend | 60 s of spend | Still ~38 s: clicks already shown and late uploads don't go away |
| Load | ~8,700 small reads/s globally | 10× fewer | 58M reads/s |
Most of the margin isn't our pipeline at all; it's how users behave (click delay, late uploads). Polling faster than once a second buys little.
Attribution models
Last click via the advertiser's stored adref (chosen) | Last click by our own user matching | Multi-touch (credit split across clicks) | |
|---|---|---|---|
| Needs | Nothing but our reference | A user ID across sites | A user ID across sites, all touches |
| Privacy | Strong: no cross-site identity | Weak | Weak |
| Credit to earlier ads | None | None | Some |
Privacy vs granularity
| K = 10 | K = 50 (chosen) | K = 200 | |
|---|---|---|---|
| Cells shown | Most | Most for large campaigns | Small campaigns see mostly other |
| Risk of identifying small groups | Higher | Lower | Lowest |
Closing the loop. Round 1 asked "how do we count clicks without slowing the click?" and answered with a log, a lake and an hourly exact recount. Round 2 asked "how do we count a billion a day within seconds, and still bill exactly?" and answered with event-time windows, watermarks, exactly-once state with idempotent sinks, local pre-aggregation and three reconciliation equations. Round 3 asked "how do the counts change what we serve, across regions, under privacy rules?" and answered with a pacer and a margin from the full timing chain, regional shares, a keyed attribution join, pseudonymization and an append-only adjustments record. Round 1's rule never went away: every total is set, not added, and the lake is the source of truth.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| Budget state goes stale | A region's pacer or Flink stalls; {pacing}:head stops moving. | After 5 s without a new head, ad servers slow down: campaigns under 10% of budget stop, others serve at half their last probability. Serving never continues at full speed on old budget state. Pages the pacing on-call. REL 11 |
| A region is lost | eu-west-1 unreachable. | After ~90 s (detection + TTL), Route 53 sends its clicks to the eu-central-1 cell, so EU data stays in the EU; signing keys are there, so clicks verify and are recorded there, tagged with the recording cell. The other pacers mark eu-west-1 stale after 15 s and freeze its shares; the cell's spend summaries keep counting against them. Clicks eu-west-1 recorded before failing are safe in its stream (3 AZs) and lake, and are counted when it recovers, together with the cell's lake; statements for affected days wait for those final rows. The "campaign stopped" notification is sent once, by the campaign's home region, when the merged spend reaches the budget (a region's share running out is not "campaign stopped"). Its idempotency key is (campaign, budget_day, event), claimed with a conditional put (attribute_not_exists) in a regional table, and checked again after a recovery, so retries never send it twice. REL 13 |
| Conversion feed delays | An advertiser's uploads stop for two days, then arrive at once. | The stream preview misses conversions older than 24 h after their click; the daily batch attributes everything within the 7-day window, and deduplicates by conversion_id. Attribution for those days is simply final later (up to 9 days). |
| A fraud model error | A new offline model marks 20% of one day's clicks invalid. | The recount's A term jumps from ~2% to 20% for many hours; an alarm on A above 3× its weekly norm holds finalization for those hours. Models run in shadow for a week (their verdicts recorded, not applied) before they may change bills. If a bad verdict reached a billed day, the fix is an adjustments credit, never an edit. OPS 6 |
R3.9 Runbook
Signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Ingest rate vs forecast | ±30% for 10 min | P2 | Traffic shift, an outage upstream, or a bot storm? |
Flink consumer lag (EFO SubscribeToShardEvent.MillisBehindLatest) | > 30 s for 5 min | P1 | Backpressure: hot key or too few KPUs |
| Watermark delay (wall clock − watermark) | > 60 s for 5 min | P1 | Stuck or idle shard; stalled operator |
| Late-event share | > 5% for 15 min | P2 | SDK release, carrier issue, clock trouble |
| Invalid-click share, by reason | > 10% or 3× weekly norm | P2 (fraud) | Which publishers? Block or tune |
| Checkpoint duration | > 10 s, or 3 failures in a row | P2 | Hot key, backpressure, state growth |
| Budget overspend | Any campaign-day > 1% over budget | P2 | Late-upload spike? Stale state? Rate cap bypassed? |
{pacing}:head age | > 5 s | P1 | Pacer or its Flink input stalled; did the standby take the lease? |
| Canary clicks | A synthetic click per region and cell every 10 s, from outside AWS; alarm if none lands in a Kinesis stream for 60 s | P1 | Is the region failing over? Check the cell's spool depth |
| Reconciliation difference | ≠ 0 for any hour | P1 | Hold billing for that hour |
Procedure: a hot key
- Confirm: Flink's backpressure metrics show one subtask of the window operator busy while the rest are idle, and consumer lag climbing.
- Check the pre-aggregation operator is enabled in the running version (a deployment may have removed it) and that its interval is 500 ms.
- If the key is a campaign-level sum (not an ad), confirm the campaign aggregation also pre-aggregates.
- If lag keeps growing, scale the application (command below); the stream holds the backlog meanwhile, and dashboards show a stalled
data_complete_through.
Procedure: checkpoint stalls
- Is backpressure the cause (a hot key or too few KPUs)? Fix that first; checkpoints recover by themselves.
- Confirm the application hasn't overridden the service's default incremental checkpoints, and that unaligned checkpoints are enabled in the job's code for backpressured periods.
- Check state size growth: a stuck watermark stops the event-time timers that clear dedup and attribution entries, and then only the guard TTL (and RocksDB compaction) removes them.
- While checkpoints fail, the job keeps processing, but a crash would replay further back: raise the priority accordingly.
Commands (AWS CLI; names and IDs are placeholders; check them before running)
textaws cloudwatch get-metric-statistics --region us-east-1 --namespace AWS/Kinesis --metric-name SubscribeToShardEvent.MillisBehindLatest --dimensions Name=StreamName,Value=clicks-use1 Name=ConsumerName,Value=click-agg-use1 --start-time 2026-09-28T21:00:00Z --end-time 2026-09-28T21:30:00Z --period 60 --statistics Maximum aws cloudwatch get-metric-statistics --region us-east-1 --namespace AWS/KinesisAnalytics --metric-name lastCheckpointDuration --dimensions Name=Application,Value=click-agg-use1 --start-time 2026-09-28T21:00:00Z --end-time 2026-09-28T21:30:00Z --period 60 --statistics Maximum aws kinesisanalyticsv2 describe-application --region us-east-1 --application-name click-agg-use1 aws kinesisanalyticsv2 update-application --region us-east-1 --application-name click-agg-use1 --current-application-version-id 57 --application-configuration-update '{"FlinkApplicationConfigurationUpdate":{"ParallelismConfigurationUpdate":{"ConfigurationTypeUpdate":"CUSTOM","ParallelismUpdate":144,"ParallelismPerKPUUpdate":1,"AutoScalingEnabledUpdate":false}}}' aws kinesis update-shard-count --region us-east-1 --stream-name clicks-use1 --target-shard-count 288 --scaling-type UNIFORM_SCALING
The first two show Flink's lag behind the stream and its checkpoint duration. The fourth scales the application to 144 KPUs (it restarts from a snapshot, so expect a short pause and a replay); it needs the version ID that describe-application returns. The last doubles the stream, the most one call can do.
Incident flow OPS 10
Synthesizing vector architecture diagram...
The first question is whether money can be wrong. Freshness problems are visible and temporary; money problems are held before they reach a statement.
Game days. Monthly: kill a Flink worker at peak and time the catch-up; stop a pacer and confirm ad servers enter safe mode within 5 s. Quarterly: fail a region in staging and check that shares freeze, clicks move, and the reconciliation equations still balance after recovery. REL 12 · OPS 11
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Regional stacks with Route 53 health-check failover that needs nothing from the failed region; shares frozen, not reassigned, when a region goes stale; budget safe mode; game days. REL 10 · REL 12 · REL 13 |
| Performance Efficiency | Budget lookups from ad-server memory; per-AZ replicas; users served by the nearest region. PERF 3 · PERF 4 |
| Security | Pseudonymized identifiers with monthly keys deleted after the day-30 rewrite; data classified into full-detail (30 days) and pseudonymized (13 months); SSE-KMS on lakes; least-privilege roles per component. SEC 3 · SEC 7 · SEC 8 |
| Cost Optimization | ≈ $75,100 a month, $494 per billion clicks; regional list prices; S3 snapshots instead of global-table write units; standby cells sized for average, not peak; build or buy per component. COST 1 · COST 8 · COST 11 |
| Operational Excellence | Money-first incident flow; finalization holds; shadow runs before models change bills; runbooks with checked commands. OPS 1 · OPS 6 · OPS 10 · OPS 11 |
| Sustainability | Data kept only as long as needed and in the region of use; pre-scaling only for known events; aggregates, not raw data, cross regions. SUS 1 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Closes the loop from counts to serving, and derives the overspend margin from every delay in the chain, including user behavior.
- Bounds overspend by construction (a rate cap), and makes stale budget state fail toward spending less.
- Splits a global budget into regional shares with no cross-region call on the serving path, and freezes a stale region's share instead of reallocating it.
- Attributes conversions with a key it controls rather than a cross-site identity.
- Turns privacy rules into mechanisms: deleted keys, two retention tiers, regional data, thresholds, consent flags, and expiry of every copy.
- Keeps billing auditable: finals written once, adjustments and overrides as separate layers, cost per billion clicks tracked.
Follow-up questions
-
"Why not reserve the whole global budget in each region and stop when any region sees it spent?" Answer: each region would see only its own spend, so all three could spend nearly the full budget: up to 3× overspend. Shares make each region's limit meaningful locally; rebalancing moves budget toward where it's being spent, and the floors let a quiet region serve a sudden burst.
-
"An advertiser in Germany asks where their click data is and how long we keep it." Answer: clicks recorded in eu-west-1 stay there, and during a regional failover they go to our Frankfurt cell, still in the EU: full detail with pseudonymized keys for 30 days, then without them for 13 months, then deleted, including old object versions. Only per-ad hourly aggregates go to the billing region. The monthly keys that pseudonymize IPs and device IDs are deleted once that month's last day has been rewritten, so after that nothing can be linked back to an IP. For the legal specifics, we point to what the legal team has set per market; the pipeline enforces it.
-
"Your margin assumes a 20-second click delay. What if it's really 2 minutes?" Answer: then T becomes 4 + 120 + 18 = 142 s, and without a change we'd overspend about 3.4× more than planned. We measure it: the recount knows each click's impression time, so the pacer's T is recomputed daily from the observed distribution, per region and per device type, and the rate cap follows (
budget ÷ (100 × T)).
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Check the billing table on each ad request" | 58M reads a second, against numbers hours old. |
| "Stop when the counter reaches the budget" | Clicks already shown and late uploads still arrive: ~42 s of spend. |
| "A global table counter decremented by every region" | Last-writer-wins overwrites concurrent decrements; conditions check only the local copy. |
| "Give a dead region's share to the others" | We don't know what it spent before it went quiet; freeze it. |
| "SHA-256 the IPs" | 4 billion IPv4 addresses are easy to hash and reverse; use a keyed hash and delete the key. |
| "Fix a disputed bill by editing the final row" | Sent statements stop matching; use an append-only adjustments record. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping questions | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API (the token) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Design steps 1.0–1.5 | Design steps 2.1–2.6 | Design steps 3.1–3.6 |
| 40–50 min | Numbers (shards, Firehose rounding, freshness) + trade-offs | Peak derivation, per-shard math, state sizes, freshness budget, cost | Timing chain and margin, regional sizing, cost per billion |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design, let me ask: what's counted, how fresh must it be, which number is billed, and how late and how duplicated can clicks be?"
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the counting side is down?" (REL 4) | The redirect only appends to a replicated log, spools if that fails, and redirects anyway; counting catches up later. | 1–2 | Step 1.1, R2.7 |
| "A worker crashes mid-window. Are advertisers double-billed?" (REL 4) | No: checkpoints restore state and stream positions together, and every sink sets absolute totals, so replays overwrite with the same value. | 2 | Step 2.4 | |
| "What if a region goes down?" (REL 13) | Health-checked DNS moves its clicks, after about 90 seconds, to a standby ingest cell in the same market area, sized for the region's average; keys verify there, the clicks are counted later, and the lost region's budget shares freeze. | 3 | Step 3.2, R3.5, R3.8 | |
| Performance | "How do you get 3-second freshness at 100K clicks a second?" (PERF 3) | Event-time windows in Flink with early firings every second into Valkey, idempotent sinks instead of transactions, and local pre-aggregation for hot ads. | 2 | Steps 2.1, 2.5 |
| "How do ad servers check budgets without slowing auctions?" (PERF 3) | They keep budget state in memory, refreshed each second from a replica in their own AZ. | 3 | Step 3.1 | |
| Security | "How do you stop forged clicks?" (SEC 3) | HMAC-signed tokens per impression, keys readable only by two roles, and the redirect target taken from our catalog. | 1 | Step 1.4 |
| "What user data do you keep?" (SEC 7) | Keyed hashes with a key per month, deleted after the day-30 rewrite; 30 days of detail, 13 months pseudonymized, in the market area of collection. | 3 | Step 3.4 | |
| Cost | "What does it cost per billion clicks?" (COST 1) | About $416 in one region, $494 across three with standby cells, with the stream processor as the largest single service. | 2–3 | R2.6, R3.6 |
| "Any surprises on the bill?" (COST 5) | Firehose bills 5 KB per record, so we pack nine clicks per record and save about $8,100 a month; request-priced services would each cost more than the whole platform's biggest line. | 1–2 | R1.7, R2.6 | |
| "Build or buy?" (COST 11) | Per component: managed Flink and Kinesis stay until self-running saves more than the engineers it needs. | 3 | Step 3.6 | |
| Operations | "How do you know the numbers are right?" (OPS 8) | Three reconciliation equations (ingest, stream, fast vs final) must balance exactly before an hour is billed. | 2 | R2.5 |
| "How do you roll out a new fraud model safely?" (OPS 6) | A week in shadow, an alarm on the recount's adjustment term, and a finalization hold if it jumps. | 3 | R3.8 | |
| Sustainability | "Do you keep more than you need?" (SUS 4) | Minute rows expire in 8 days, raw detail in 30, and only aggregates cross regions. | 2–3 | R2.5, step 3.4 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Getting clicks in | A log on the path, not a database; fail open. | Packing for cost, random keys, per-shard math. | Regional ingest with health-checked failover to same-market standby cells, and keys everywhere. |
| Time | Event time, recompute recent hours. | Watermarks, clamped device time, idle shards, a late path. | Budget days in the advertiser's time zone, by event time. |
| Counting once | Dedup on the signed click ID; overwrite, never add. | Exactly-once state + idempotent sinks; dedup state that rolls back. | Conversion dedup beyond the stream's window; adjustments, not edits. |
| Fraud | Signed tokens. | Stream rules, offline checks, invalid counted by reason. | Models in shadow; finalization holds. |
| Fast vs exact | Only exact, hourly. | Provisional vs final, reconciled by three equations. | Final merged across regions and capped at budget. |
| Feedback | – | – | Pacer, margin from the full timing chain, rate cap, safe mode. |
| Cost | ~$220/month; Firehose rounding found. | ~$12,600/month, $416 per billion. | ~$75,100/month, $494 per billion, regional prices, standby cells. |
| Evolving under new scope | Builds from one table, one problem at a time. | Opens with "what breaks" and rechecks every inherited number. | Changes what the counts are for: serving, privacy, money across regions. |