Case Studies

Design an Ad-Click Aggregator

The streaming-analytics canonical: count ten thousand clicks a second correctly enough to bill against — a partitioned log, event-time windows with watermarks, and an honest account of what exactly-once actually promises.

Suggest an edit

📊 Design an Ad-Click Aggregator

Prerequisites: Design a News Feed, Analytics & Column Stores | You'll be able to: design a click pipeline that survives a viral ad without melting one shard; place a late-arriving click in the right window using event time and watermarks, and say what happens to the ones that arrive too late; explain — precisely — what "exactly-once" does and does not promise, and back a billing-grade count with a reconciliation path.


🧨 The problem (why this exists)

Every case study so far served a user. This one serves a number — and the number is money. An ad-click aggregator sits between two parties with opposite incentives: users clicking ads on a platform, and advertisers paying per click. The system's whole job is to count. But "count things" at 10,000 events per second, across a fleet of machines that crash, retry, and redeliver, while an advertiser refreshes a dashboard expecting sub-second answers about the last minute of traffic — that is the streaming-analytics interview in its canonical form. This is a data-processing question, not a product question: the delivery framework shifts weight from user-facing entities toward the system interface (what comes in, what goes out) and the data flow between them.

What makes it more than an analytics exercise: clicks are billed. An overcounted click is an advertiser overcharged; an undercounted one is revenue silently dropped. DDIA gives this stake a name — this is an integrity requirement, not a timeliness one: a dashboard that lags a few seconds is annoying and self-heals, but a count that is wrong stays wrong forever unless something detects and repairs it [i]. That framing — violations of timeliness are temporary, violations of integrity are perpetual — is the spine of this whole design.

The brief: ads are placed on a website or app (think a Facebook-scale platform). Ad targeting and serving, fraud detection, cross-device tracking, and conversion tracking are all out of scope.

Functional requirements:

  1. Users click an ad and are redirected to the advertiser's site.
  2. Advertisers query aggregated click metrics over time, at a minimum granularity of 1 minute.

Non-functional requirements — quantified:

  1. Scale: 10M active ads; peak of 10k clicks/second; ~100M clicks/day.
  2. Query latency: sub-second responses for advertiser analytics.
  3. Fault-tolerant and accurate: no lost click data — the counts feed billing.
  4. As real-time as possible: metrics queryable soon after the click.
  5. Idempotent tracking: the same click must never be counted twice.

Read requirements 3 and 5 together and notice, per the non-functional requirements discipline, that they pull in opposite directions from requirement 4: "never lose, never double-count" wants careful, coordinated bookkeeping; "as real-time as possible" wants to fling events through the pipe. Most of the design is about refusing to pick one — a fast path that is almost always right, plus a slow path that makes it provably right.


💡 Intuition first

The naive design is one table and two queries. Every click is an INSERT:

INSERT INTO clicks (event_id, ad_id, user_id, ts) VALUES (...);

and every dashboard load is a GROUP BY (the "store and query from the same database" option):

SELECT date_trunc('minute', ts) AS minute, COUNT(*)
FROM clicks
WHERE ad_id = 913 AND ts BETWEEN :from AND :to
GROUP BY minute;

This is genuinely correct — every click is durably stored, every query computes from the raw truth. It dies on arithmetic, twice.

The write side. 10k inserts/second at peak into one transactional table. A single Postgres instance handling 10k sustained writes/second alongside index maintenance is already at the edge of what's reasonable; add read traffic and it tips over. At this scale, the shared database quickly becomes a bottleneck. You can buy time with a write-optimized store — Cassandra is the natural pick, whose LSM-tree engine (storage engines) absorbs write floods happily — but that makes the read side worse, because LSM SSTables are built for point reads, not range aggregations.

The read side is what really kills it. Consider one popular ad that collected 50M clicks over a week. "Show me clicks per minute for the last 7 days" must touch all 50M rows — on every dashboard refresh, for every advertiser, concurrently — to produce 10,080 numbers. The database recomputes the same aggregation from scratch each time, at query time, on the hot path. Sub-second latency is arithmetic fantasy; row stores aren't built for this scan pattern, and even a column store merely makes the scan cheaper without removing it.

The corrected instinct: do the aggregation once, ahead of the query — move work from the read path to the write path. Store raw clicks somewhere cheap and append-only; maintain a separate, pre-aggregated table keyed by (ad_id, minute) that queries hit directly. The first version of this is batch: a Spark job every few minutes reads new raw events, aggregates, and writes to an OLAP store. It works — and its two flaws name the rest of the design. First, latency: advertisers always see data minutes stale, and while you can shrink Flink-style aggregation windows to seconds, you can't realistically launch Spark jobs every few seconds. Second, spikes: nothing between the click endpoint and the database absorbs a surge. Both fixes point to the same component — a durable, partitioned log with a stream processor consuming it. That is the design.


⚙️ How it works

🧱 Core entities

Three pieces of state anchor everything:

  • Click event — small, self-contained, immutable, timestamped: {impression_id, ad_id, ts, ...}. Immutability is load-bearing, not stylistic: an event records that something happened, so it is never updated, only appended — which is exactly what makes the raw log replayable and auditable later [i].
  • Impression ID — a unique ID minted by the Ad Placement Service for each instance of an ad shown to a user, signed with a secret key before it's sent to the browser. It rides along with the click and serves as the idempotency key. Note what it identifies: not the user, not the ad — the showing. The same user clicking the same ad shown twice (retargeting) is legitimately two clicks.
  • Aggregate window — the derived row advertisers actually query: (ad_id, minute_bucket) → click_count. Everything else in the system exists to compute these rows correctly and quickly.

🔌 The API

Two surfaces, one per party:

POST /click  {impression_id, ad_id, ...}   → 302 Location: <advertiser URL>
GET  /ads/{adId}/metrics?granularity=minute&from=...&to=...   → [{minute, clicks}, ...]

The /click endpoint does double duty: record, then redirect. Weigh client-side redirects (browser navigates directly to the target, click POSTed in parallel — simple, but sophisticated users and inevitable browser extensions bypass tracking entirely) against server-side 302 redirects, where the user reaches the advertiser only through us — every click tracked, at the cost of one server hop on the click path. The same status code that powered the URL shortener's entire product is here a data-integrity decision. Per the API design discipline, note also what the metrics endpoint doesn't offer: arbitrary ad-hoc queries. It reads pre-aggregated rows, which is precisely why it can promise sub-second answers.

🗺️ High-level architecture

Browserclick with signedimpression IDClick Processor fleetverify signature · dedup · 302Redis Clusterimpression-ID cacheKafka / Kinesisclick log, sharded by AdId(retention ~7 days)Flink jobtumbling 1-min event-timewindows per AdIdOLAP store(ad_id, minute) → countS3 data lakeraw click eventsSpark reconciliationhourly / daily re-aggregationAdvertiserdashboard POST /clickseen thisimpression ID? 302 redirectappend eventconsumeflush window countsdump raw eventsread rawcorrect discrepanciesGET metrics(sub-second)

Walk one click through it. The browser POSTs the click with its signed impression ID. The Click Processor verifies the signature (rejecting fabricated IDs), checks the impression ID against a Redis cache — a duplicate turns around right here — then appends the event to the log and 302s the user onward. From the log, a Flink job consumes each ad's events, maintains running counts in one-minute windows, and flushes each closed window into the OLAP store, where the advertiser's dashboard reads pre-aggregated rows. Quietly, in the background, raw events also land in S3, and a periodic Spark job recomputes the same aggregates from scratch — the safety net whose reason for existing is the third deep dive.

The log in the middle is doing more than buffering. It is an append-only, sharded sequence of records — a queue-and-broker in which each shard totally orders its messages by offset [i] — which means it decouples ingestion rate from processing rate (a Flink hiccup doesn't drop clicks; they wait, durably), it retains events after consumption so they can be re-read (consumption is non-destructive, unlike a classic message queue [i]), and with a retention window of, say, 7 days, it is the recent system of record. The whole architecture is DDIA's derived-data pattern in miniature: raw events are written once to a log; everything downstream — Flink's counts, the OLAP rows, the S3 archive — is a derived view that can be recomputed from it [i].


🤿 Deep dives

🔥 1. The write path at scale — sharding by AdId and the viral-ad problem

10k clicks/second exceeds what one log shard will take — Kinesis, for instance, caps a shard at 1 MB/s or 1,000 records/s — so the click log must be sharded, and the shard key is a real decision. Shard by AdId: all events for a given ad land in one shard, so each Flink task consumes a disjoint set of ads and can keep each ad's running count in purely local state — no cross-shard coordination on the hot path, and within a shard the events arrive in a total order [i]. The partition key is doing exactly what DDIA says a partition key is for: routing all events that must be aggregated together to the same place [i].

Uniform key distribution is not uniform load distribution. Hashing spreads the 10M AdIds evenly across shards — but the traffic isn't attached evenly to the keys [i]. Consider this example: Nike launches a LeBron James ad and it goes viral. Every one of its clicks hashes to the same shard — a hot shard, the write-side sibling of the hot-key celebrity problem you met in the news feed [i]. That shard's throughput cap becomes the viral ad's ceiling: latency climbs and, at the extreme, events get dropped — data loss concentrated precisely on the highest-revenue ad in the system.

The fix is key salting: for hot ads only, append a random suffix to the partition key — AdId:0 through AdId:N — so the one hot key becomes N keys hashing to N different shards [i]. DDIA's warning travels with the technique: salting splits the write load but taxes the read side, because anything that wants the total must now read and combine all N sub-keys, and someone must do the bookkeeping of which keys are salted and by how much [i] — which is why you salt the few hot keys, not everything. Here the "read side" is the aggregation layer: N Flink tasks now each hold a partial count for the ad, and the per-minute totals must be merged downstream (a second-stage aggregation, or summing sub-rows in the OLAP store at query time). How do you know which ads to salt? Choose by ad spend or observed click volume — a static-ish policy; detecting heat dynamically and re-salting live is genuinely hard operational work (rule of thumb, not from source: most teams pre-salt anything capable of virality rather than reacting to it).

Before the log there's a plainer scaling story: the Click Processor fleet is stateless — signature verification needs only the secret key, dedup lives in Redis — so it scales horizontally behind a load balancer. And the dedup cache is small enough not to worry about: 100M clicks/day × 16 bytes per impression ID ≈ 1.6 GB — tiny, though you run it as a replicated Redis Cluster with persistence anyway, because losing the dedup set reopens the double-count window.

🪟 2. Windowed streaming aggregation — event time, watermarks, and the tumbling window

The Flink job's logic sounds trivial — keep a counter per (ad_id, minute), flush when the minute ends — until you ask the question DDIA calls "surprisingly tricky" [i]: which minute? A click has two timestamps. Event time is when the click happened; processing time is when the Flink operator gets around to handling it — and the two diverge whenever anything queues, retries, backlogs, or restarts [i]. In this design, event time is assigned by the Click Processor at receipt — a server clock, mercifully, so we dodge the untrustworthy-device-clock swamp (DDIA's fix for that, logging three timestamps to estimate device clock offset [i], matters for mobile SDKs that buffer clicks offline — name it as out of scope). But the gap between the Click Processor's stamp and Flink's processing moment is real and elastic: a Flink restart means minutes of backlog processed in a rush.

Windowing by processing time makes that rush look like traffic. DDIA's Figure 12-8 argument [i]: restart a lagging consumer, and the backlog burst appears as a fake spike of clicks in the current window — while the minutes during the outage look eerily quiet. For a dashboard, that's misleading; for billing, it's charging Nike for a click distribution that never happened. So: window by event time, always — the click counts toward the minute it occurred, no matter when it's processed. Event-time windowing has a second, structural virtue: it's the only choice that stays meaningful when you reprocess historical events, because on replay the processing time is "now" but the event times are still true [i] — and deep dive 3 depends on replay.

The window itself is a tumbling window: fixed one-minute length, every event in exactly one window — bucket by rounding the event timestamp down [i]. That matches the product contract (1-minute granularity), and non-overlap means each click increments exactly one counter — no hopping-window smoothing, no sliding-window buffers; coarser granularities (hour, day) roll up from minutes in the OLAP store rather than widening the streaming window. State stays small: a counter per ad per open window, not a buffer of events [i]. And because Flink can flush provisional results every few seconds while the window is still open, the current minute shows up on dashboards incomplete-but-live — the streaming design's real advantage over batch, since shrinking a Spark cadence to seconds is impractical while shrinking a flush interval is a config knob.

Event time forces one honest complication. Clicks can arrive out of order — an event stamped 12:00:59 can show up after one stamped 12:01:02 — so the window [12:00, 12:01) can never be certain it has seen everything [i]. When do you close it? The mechanism is a watermark: a signal flowing through the stream asserting "no more events earlier than t are coming" — DDIA describes exactly this special message, along with its catch: with multiple producers, each has its own notion of t, so the consumer must track the minimum across them [i]. (Watermark is Flink's term for this signal; it advances behind the observed event times by a configured bound. [web: Apache Flink docs — "Timely Stream Processing"]) When the watermark passes 12:01, the window closes and flushes.

And for the click that still arrives after that — the straggler — you have exactly two options, and naming both is the senior move [i]: ignore it, tracking a dropped-late-events metric so you know how much money you're quietly not counting; or emit a correction, an updated value for the already-flushed window, which your OLAP write path must then handle as an upsert rather than an insert. For billing, corrections beat silent drops — and note the quiet dependency: choosing corrections means the sink must tolerate the same row being written twice, which is idempotence, which is deep dive 3's subject.

Watch one click travel the pipeline:

STEP 1 — 12:00:58 · a click lands in the log and the open windowKafka shard for AdId 913… | 41868 | 41869 | 41870 ← click(ad 913, et 12:00:58)Flink · open window [12:00, 12:01)count(ad 913): 4,181 → 4,182watermark: 12:00:55OLAP row (ad 913, 12:00)provisional flush: 4,182 (incomplete) consume, offset 41870 early flush every few seconds

🎯 3. Exactly-once counting, honestly

Requirement 5 says a click is never counted twice — the general shape of this problem is idempotency and exactly-once delivery. Here is why it wants to be counted twice: every reliability mechanism in the pipeline works by retrying, and a retry is a duplicate wearing a safety vest. The browser retries a POST whose response was lost — the click was recorded, but the user's network dropped the 302. The log consumer restarts and resumes from its last recorded offset, reprocessing every event it had handled but not yet checkpointed — DDIA is explicit that this is baked into how consumer offsets work: messages processed but not committed are processed a second time on failover [i]. Drop-or-retry is the fundamental fork, and since dropping is data loss, everything downstream of "retry" is about making the duplicates harmless [i].

The defenses layer, innermost to outermost:

Dedup at the door — the impression ID. The key insight is where dedup must live: before the stream, not inside Flink. A windowed operator can only dedup within its window — the same impression clicked at 12:00:59 and again at 12:01:01 lands in two different windows, sails past any per-window check, and counts twice. So the Click Processor checks the impression ID against Redis on ingress: seen → drop; new → record and append. And the ID must be signed, because an unsigned unique ID invites the opposite fraud — a malicious script minting fresh fake impression IDs that are all "unique" and all counted; verifying the signature before the cache check closes that hole. Step back and this is DDIA's end-to-end argument wearing ad-tech clothes: TCP dedups packets, Kafka can dedup producer retries, Flink can give exactly-once inside the framework — and none of them can see that a user's browser retried, because that duplication happened above all of them. Only an identifier minted at the true source and carried end-to-end can suppress duplicates end-to-end [i]. The impression ID is this system's request ID.

Checkpointing — crash recovery without recounting. Flink periodically snapshots operator state — every open window's counters, plus the input offsets they correspond to — to durable storage, coordinated by barriers flowing through the stream [i]. On a crash, it restores the last checkpoint and rewinds consumption to the checkpointed offsets: state and position move together, so no click is skipped and none is double-applied to state. Worth stating the contrarian note plainly, because it scores seniority points: with one-minute windows, checkpointing is arguably optional — a crashed job has at most a minute of state in flight, and since the log retains events, it can simply re-read and re-aggregate the lost minute. Checkpointing earns its keep when windows are long or state is large. Know the mechanism; question whether you need it.

The honest asterisk — what "exactly-once" really promises. DDIA lands the precise phrase: exactly-once means the visible effect is as if each record were processed once — "effectively-once would be a more descriptive term" [i] — and it holds within the framework's boundary. The moment output crosses that boundary — a write to the OLAP store, a message to another system — a restarted task performs the side effect again [i]. Checkpointing restores Flink's counters perfectly; it does not un-write the rows Flink flushed between the checkpoint and the crash. Two escapes exist [i]: atomic commit kept inside the framework — Kafka's transactions commit output messages and consumer offsets together, all or nothing — or idempotent writes, which fit this design naturally: the OLAP sink upserts by key — (ad_id, minute_bucket) → count — so writing the same final window twice converges to the same row instead of doubling it. (Idempotence carries its own fine print: replays must be deterministic and in-order, and a failover may need fencing so a presumed-dead task can't keep writing [i].) The expert one-liner: exactly-once state inside, idempotent effects outside — anyone who claims a distributed pipeline gives exactly-once side effects unconditionally hasn't operated one.

The safety net — batch reconciliation. After all that, this design still adds a second path, and DDIA explains why it's principled rather than paranoid. Transient Flink bugs, a bad code push, an out-of-order edge case — software errors slip through machinery designed for infrastructure failures. So: dump raw click events from the stream into S3; run a periodic Spark job — hourly or nightly — that recomputes the aggregates from raw events, from scratch; compare against what streaming wrote; investigate and correct discrepancies. This works because batch output is pure derived data: regenerated from immutable input on every run, so a corrected job corrects the numbers — DDIA calls the ability to recover from buggy code by rerunning "human fault tolerance" [i], and names reprocessing-from-the-log as exactly how derived views are rebuilt when logic changes [i]. Running fast-stream and correct-batch over the same log is the two-path shape DDIA discusses as the lambda architecture — with the modern refinement that one engine can run both roles (kappa) if it replays history through the same event-time logic [i]. The dashboards run on the stream; the invoices trust the reconciliation. And notice the quiet prerequisite: reconciliation is only as good as the raw log's integrity — which is why the append-only, immutable event log is the one component nothing is allowed to shortcut. Count-affecting disputes end at the log, or they don't end.

The whole design as a walkthrough — three boards rather than one picture: the system in context, its containers, and the code level inside the stream aggregator. Any box carrying a link badge drills down a level; the ◀ ▶ ⌂ controls and the board menu walk back out.

Ad viewer[Person]Advertiser[Person]Ad-Click Aggregator[Software System] clicksmetrics queries

🛠️ Hands-on: run this design

A runnable implementation of the stream aggregator lives at _proof-of-concepts/07-case-studies/10-ad-click-aggregator/ in the repo root — the three classes above (ImpressionDeduper, WindowAggregator, IdempotentSink), over Postgres.

cd _proof-of-concepts/07-case-studies/10-ad-click-aggregator
./run            # build + start api (8410) + Postgres (8411)
./run test       # mypy --strict + smoke
./run stop

./run test feeds clicks with explicit event times and shows the stream semantics: clicks bucket into event-time windows that emit only once the watermark passes their end (a window still open is held back); a replayed impression id is deduped away; and a click landing in an already-emitted window is a correction — the window's count is recomputed and upserted (2 → 3), never lost. Upsert-by-(ad, window) is what makes replay and late data safe to bill on.


🧱 Component reference

13 components — what each one owns, the invariant it protects, and where it breaks

👤 Ad viewer

Actor · Browser · mobile app

The Ad viewer is whoever clicks the ad — and the system's founding realism is that "whoever" includes a double-clicker, a flaky network that retries a lost POST, and a malicious script fabricating clicks for profit. Every one of those produces an HTTP request that looks like a click; only one of them is a billable event. The viewer is therefore not just a traffic source but the origin of the pipeline's hardest requirement: idempotency has to hold end to end, from the browser down, because a browser retry happens above every framework guarantee in the stack — no amount of Kafka or Flink machinery can see it.

Responsibilities

  • Carry the signed impression ID minted when the ad was shown — the click's identity — back with the click. The unit of idempotency is the impression, not the (user, ad) pair: the same user legitimately clicking the same ad shown twice is two billable clicks.
  • Reach the advertiser's site only through the tracker's 302 redirect, so no click escapes counting.
  • Retry freely: the design's promise is that a re-sent POST collapses to one count at ingest, not that the viewer behaves.

Where it breaks. The adversarial tail — click fraud. An unsigned unique ID would let a script mint endless fresh "impressions," each one unique and each one counted; the signature check at ingest closes that hole. Broader fraud detection (bots, click farms) is deliberately out of this design's scope.

👤 Advertiser

Actor · Analytics dashboard

The Advertiser is the party the numbers are for — and the party that pays based on them, which is what turns this from an analytics exercise into an integrity problem. A dashboard that lags a few seconds is annoying and self-heals; an invoice computed from a wrong count stays wrong forever unless something detects and repairs it. The advertiser's two demands pull the architecture in opposite directions: sub-second queries over the last minute of traffic (speed), and counts trustworthy enough to be billed against (truth). The design refuses to pick one — the stream path serves the dashboard, the batch reconciliation path backs the invoice.

Responsibilities

  • Query aggregated click metrics through the Metrics API at 1-minute minimum granularity, expecting sub-second answers — which is only possible because the rows are pre-aggregated, not computed at query time.
  • Accept that the current minute is provisional: open windows flush early and incomplete, and late-arriving clicks can revise an already-published number via correction upserts.
  • Dispute invoices — the adversarial read path. "The dashboard says so" is not an answer; the immutable raw log is what turns a dispute into a replay-and-compare procedure instead of a negotiation.

Where it grows. Today's surface is fixed-granularity counts per ad. The pressure is toward richer slices — geography, device, campaign roll-ups — each of which multiplies the pre-aggregation keyspace and pushes the OLAP store toward a real real-time analytics engine.

🏢 Ad-Click Aggregator

System · Log + stream + OLAP + batch reconciliation

The Ad-Click Aggregator does one thing — count clicks — at 10k events/second, across machines that crash, retry, and redeliver, correctly enough that advertisers are billed on the result. That last clause is the whole design: an overcounted click is an overcharged advertiser, an undercounted one is revenue silently dropped, and a wrong count is perpetual until something detects and repairs it. So the architecture is two paths over one immutable log: a stream path (log → event-time windows → OLAP) that is fast and almost always right, and a batch path (raw archive → periodic recompute) that makes it provably right. Dashboards run on the stream; invoices trust the reconciliation.

Responsibilities

  • Ingest every click exactly once in effect: verify the signed impression ID, dedup at the door, append to a partitioned log — salted for viral ads so one hot key can't melt a shard.
  • Aggregate by event time, not processing time, with a watermark deciding when a window is complete enough to emit — so a consumer restart never bills a phantom click spike.
  • Serve pre-aggregated (ad, minute) rows with sub-second latency, and keep them honest with idempotent upserts and batch corrections.

Where it breaks. Every guarantee inside the pipeline has a boundary: "exactly-once" is effectively-once state within the framework, and side effects crossing out of it replay on failure. The system survives that not by trusting any single mechanism but by layering three — end-to-end impression IDs, checkpointed state, idempotent sinks — plus the reconciliation audit for whatever slips through.

⚙️ Click ingest

Service · Python · FastAPI

Click ingest is the door, and the design's key insight is how much correctness work belongs at the door rather than deeper in the pipeline. A windowed stream operator can only dedup within its window — the same impression clicked at 12:00:59 and again at 12:01:01 lands in two different windows and counts twice. So dedup lives here, before the stream: verify the impression ID's signature first (an unsigned unique ID invites fraud — a script minting fresh "unique" IDs that all get counted), then check the ID against a Redis set; seen means drop, new means record and append. This is the end-to-end argument in ad-tech clothes: only an identifier minted at the true source — the ad impression — and carried through the whole path can suppress duplicates the frameworks below can't see, like a browser retrying a POST whose 302 got lost.

Responsibilities

  • POST /click: verify signature → dedup check → append to the click log → 302 redirect to the advertiser. Server-side redirect, so every click passes through us; record, then redirect.
  • Stay stateless — signature needs only the secret key, dedup state lives in Redis — so the fleet scales horizontally behind a load balancer to absorb the 10k/s peak.
  • Stamp the event's event time at receipt (a trustworthy server clock), the timestamp every window downstream will bucket by.

Where it breaks. The dedup cache is the soft spot: ~1.6 GB for a day of impression IDs is tiny, but losing it reopens the double-count window — hence a replicated Redis Cluster with persistence, for a cache whose contents you can't recompute.

🌊 Click log

Event stream · Kafka

The Click log is the component nothing is allowed to shortcut: an append-only, partitioned sequence of immutable click events, totally ordered within each shard. It buffers (a click spike or a slow consumer widens the lag instead of dropping data — the peak is ~8× the average, and the log is what lets consumers be sized nearer the average), it retains (~7 days: consumption is non-destructive, so a crashed job rewinds its offset and re-reads), and it is the recent system of record — every downstream artifact, from Flink's counters to the OLAP rows to the S3 archive, is a derived view recomputable from it. Count-affecting disputes end at the log, or they don't end.

Responsibilities

  • Shard by AdId, so each ad's events land in one shard in total order and each stream task aggregates its ads with purely local state — no cross-shard coordination on the hot path.
  • Salt hot keys: a viral ad's clicks all hash to one shard, whose throughput cap becomes the ad's ceiling. For hot ads only, the key becomes AdId:0…N across N shards — splitting the write load at the cost of N partial counts that must be merged downstream. Salt the few, not the many.
  • Feed two consumers: the stream aggregator (fast path) and the raw archive (truth path).

Where it breaks. Retention is the outage budget: a consumer that falls behind by more than the retention window starts missing events permanently. "7 days" really means "we can survive a long weekend of pipeline failure and still recompute."

🛠️ Stream aggregator

Worker · Flink-style worker

The Stream aggregator is the fast path from click to queryable count — and the place where the innocent question "which minute does this click belong to?" gets its honest answer. A click has two timestamps: event time (when it happened) and processing time (when this worker gets to it), and they diverge whenever anything queues, retries, or restarts. Windowing by processing time turns a restart's backlog into a phantom traffic spike billed into the recovery minute; so this container windows by event time, always, and a watermark — the signal asserting "nothing earlier than t is coming" — decides when a one-minute tumbling window is complete enough to close and flush. Clicks arriving after that fork to a correction rather than being silently dropped: for billing, corrections beat losses.

Responsibilities

  • Consume the log per shard, keeping a counter per open (ad, minute) window — small state, checkpointed with its input offsets so state and position recover together.
  • Close windows on watermark passage; flush provisional counts early so the current minute is live-but-incomplete on dashboards.
  • Emit closed windows to an idempotent sink — because "exactly-once" is effectively-once state inside the framework, and a replayed flush is a side effect the framework can't un-write.

Three classes carry that pipeline — the C4 code level, mirrored 1:1 by the forthcoming POC:

Each class maps to a file in the POC at 06-case-studies/examples/ad-click-aggregator/app/ (deferred to the hands-on phase) — click the code-level boxes for their docs.

Where it breaks. A salted hot ad means N tasks each hold a partial count, so per-minute totals must be merged downstream. And with one-minute windows, checkpointing is arguably optional — the retained log lets a crashed job replay its lost minute — a pushback worth making before installing every feature the framework offers.

🧩 ImpressionDeduper

Code · Python + Redis set

ImpressionDeduper exists because every reliability mechanism in the pipeline works by retrying, and a retry is a duplicate wearing a safety vest. The browser re-sends a POST whose 302 was lost; a consumer resumes from its last committed offset and reprocesses what it had handled but not checkpointed. Dropping duplicates is data loss's opposite twin — so the design retries freely and makes the duplicates harmless here, keyed by the one identifier that survives the whole journey: the signed impression ID, minted when the ad was shown. Not the user, not the ad — the showing: the same user clicking the same retargeted ad twice is legitimately two counts, which is why (user_id, ad_id) is the classic wrong key.

Responsibilities

  • seen(impression_id) → bool: check the ID against the dedup set (Redis-backed, ~1.6 GB per 100M-click day); seen means drop, new means record-and-pass in one step.
  • Sit upstream of windowing — a windowed operator only sees duplicates within one window, and the 12:00:59 / 12:01:01 retry pair straddles a boundary and would count twice.
  • Assume the signature was verified at ingress; an unsigned "unique" ID is fraud's front door.

The invariant it protects: one impression, one count — end to end, from the browser down, which no framework-level exactly-once can see.

Where it breaks. Losing the dedup set reopens the double-count window for its whole horizon — the one cache in this design whose contents can't be recomputed. Mirrored by the forthcoming POC at 06-case-studies/examples/ad-click-aggregator/app/impression_deduper.py.

🧩 WindowAggregator

Code · Python (event-time windowing)

WindowAggregator answers the question that sounds trivial and isn't: which minute does this click belong to? A click has two timestamps — event time (when it happened) and processing time (when this operator handles it) — and bucketing by the wrong one turns a restart's backlog into a phantom traffic spike billed into the recovery minute, while the outage minutes look eerily quiet. So it buckets by event time, into tumbling one-minute windows: fixed length, every click in exactly one window, one counter incremented per click — small state, no event buffers. Event time is also what keeps replay meaningful: on reprocessing, "now" is wrong but the event stamps are still true.

Responsibilities

  • ingest(click): round the event timestamp down to its minute and increment that window's counter per ad — flushing provisional values while the window is open, so the current minute is live on dashboards.
  • on_watermark(ts) → list~WindowCount~: when the watermark — the assertion that nothing earlier than ts is coming — passes a window's end, close it and emit its final counts.
  • Route stragglers deliberately: a click arriving after its window closed forks to a correction (an upsert against the already-flushed row) rather than a silent drop — for billing, corrections beat quietly uncounted money.

The invariant it protects: the watermark closes windows; late clicks fork to correction — no window is final by wall clock, only by evidence.

Where it breaks. The watermark is a heuristic bound, not an oracle: set it tight and corrections multiply; set it loose and finality lags. Mirrored by the forthcoming POC at 06-case-studies/examples/ad-click-aggregator/app/window_aggregator.py.

🧩 IdempotentSink

Code · Python (OLAP upsert writer)

IdempotentSink is where the honest asterisk on "exactly-once" gets handled. Checkpointing restores the aggregator's counters perfectly — state and input offsets snapshot together, so recovery neither skips nor double-applies a click to state. What it cannot do is un-write the rows flushed to the OLAP store between the last checkpoint and the crash: exactly-once is effectively-once within the framework's boundary, and a restarted task performs its external side effects again. The two escapes are atomic commit kept inside the framework, or idempotent writes — and this design's output shape makes idempotence the natural fit: aggregates are keyed rows, so writing the same row twice can be made to converge instead of double.

Responsibilities

  • flush(window_counts): write every emitted window as an UPSERT keyed by (ad, window) — never an INSERT — so a replayed flush overwrites the same row with the same value, and recovery never double-counts.
  • Serve all three writers of the same contract: final window closes, late-click corrections, and batch reconciliation's fixes — one row identity, any number of safe rewrites.
  • Honor idempotence's fine print: replays must be deterministic and in-order, and failover needs fencing so a presumed-dead task can't keep writing stale values over fresh ones.

The invariant it protects: replay-safe upserts keyed by (ad, window) — effectively-once state inside, idempotent effects outside.

Where it breaks. Idempotence dedups identical aggregate writes, not duplicate events — a double-counted click is already inside the number by the time it reaches this class, which is why the deduper upstream exists. Mirrored by the forthcoming POC at 06-case-studies/examples/ad-click-aggregator/app/idempotent_sink.py.

📊 Aggregates store

Data warehouse · OLAP store

The Aggregates store holds the rows the whole system exists to compute: (ad_id, minute_bucket) → click_count, pre-aggregated so the advertiser's dashboard reads answers instead of computing them. It embodies the design's corrected instinct — move work from the read path to the write path. The naive alternative (GROUP BY over raw clicks at query time) re-scans millions of rows per dashboard refresh; here the aggregation happened once, upstream, and a week-long query touches ~10,080 pre-built rows. Sub-second latency is a property of the shape of the data, not of heroic query optimization.

Responsibilities

  • Serve the Metrics API's range reads at minute granularity, with day/week roll-up tables shrinking long-range queries further.
  • Accept writes as upserts keyed by (ad, window), never blind inserts — the property that makes replayed flushes, late-click corrections, and batch reconciliation all converge on the same row instead of doubling it. Three writers, one idempotent contract.
  • Hold provisional values for open windows (early flushes) that later flushes overwrite with finals.

Where it breaks. Two edges. Salted hot ads land as N sub-rows whose totals must be merged at query time or by a second-stage aggregation — the read-side tax salting always charges. And batch corrections must arrive through controlled ingestion, not raw per-row UPDATE storms: batch jobs writing straight into a live serving store throttle the job and degrade query latency — the reason real-time OLAP engines ingest from streams by design.

🪣 Raw archive

Object storage · S3-style object store

The Raw archive is every click, untouched, forever cheap — the source of truth the batch path recomputes from. Its power comes from a property decided at ingest: click events are immutable. An event records that something happened; it is never updated, only appended — which is exactly what makes the archive replayable and auditable. Everything downstream of the log is a derived view, and derived views earn their trustworthiness from the ability to be re-derived: same input, fixed code, correct output. The archive is where that ability lives beyond the stream's 7-day retention.

Responsibilities

  • Receive raw click events dumped from the stream path and keep them as the durable, append-only record — ~10 GB/day at 100M clicks, a rounding error against the reconciliation value it buys.
  • Feed the reconciliation job's periodic from-scratch recomputation, and any ad-hoc replay: a bad deploy becomes a re-run instead of an apology.
  • Anchor billing disputes: an advertiser challenging an invoice triggers a procedure — replay Tuesday's events, recompute, compare — not a negotiation. Signed impression IDs in the raw events let you check dedup, fabrication, and timeline gaps after the fact.

Where it breaks. The archive is only as good as what reaches it: it can prove or disprove anything about events it holds, and arbitrate nothing about clicks that were never captured. That's why the append-only, immutable log discipline is the one thing this design never compromises — integrity violations upstream of the archive are the ones no audit can repair.

🏗️ Reconciliation job

Batch job · Batch (Spark-style)

The Reconciliation job is why the stream's numbers can be billed against — the principled paranoia after three layers of exactly-once machinery already did their jobs. Checkpoints and idempotent sinks defend against infrastructure failures; what slips through is software: a transient stream bug, a bad code push, an out-of-order edge case that quietly corrupts counts. Wrongness of that kind is perpetual until something detects and repairs it — so this job periodically recomputes the aggregates from the raw archive, from scratch, and compares against what streaming wrote. It works because batch output is pure derived data, regenerated from immutable input on every run: fix the code, re-run, and the numbers correct themselves — recovery from buggy logic by re-execution, the human fault tolerance batch uniquely offers. Fast-stream plus correct-batch over the same log is the lambda shape; the dashboards run on the stream, the invoices trust this job.

Responsibilities

  • Read raw click events from the archive on an hourly or nightly cadence and re-aggregate (ad, minute) counts end to end — the load is trivial (a 5-minute cadence at peak is ~300 MB per run); the value is independence from the stream's code path.
  • Diff against the OLAP store's rows; surface discrepancies for investigation and write corrections — through the same idempotent upsert contract every other writer honors.
  • Serve as the continuous audit: run on schedule, not just when an advertiser disputes an invoice.

Where it breaks. Two operational taxes: a second system to run, and discrepancy-investigation toil — every diff is a question someone must answer. Accepted knowingly, because the alternative is a billing pipeline with no independent check on itself.

⚙️ Metrics API

Service · Python · FastAPI

The Metrics API is the advertiser's window into the counts: GET /ads/{adId}/metrics?granularity=minute&from=…&to=…, answered sub-second. Its speed is inherited, not engineered — it reads rows the pipeline already aggregated, so a week of an ad's traffic is ~10,080 pre-built numbers rather than a 50M-row scan. Notice what the endpoint deliberately doesn't offer: arbitrary ad-hoc queries. Constraining the read surface to pre-aggregated shapes is precisely what makes the latency promise keepable — the naive design died on exactly the flexibility this API declines to provide.

Responsibilities

  • Serve range reads over (ad_id, minute_bucket) → count at 1-minute minimum granularity; answer coarser granularities (hour, day) from OLAP roll-up tables rather than widening any streaming window.
  • Present the current minute honestly: open windows flush early and provisional, so the freshest number is live-but-incomplete and firms up when the watermark closes the window.
  • Merge salted sub-rows where a hot ad was split into AdId:0…N — the read side pays salting's bookkeeping tax, either here at query time or in a second-stage aggregation upstream.

Where it breaks. The API can only be as correct as the rows beneath it, and those rows are revisable: late-click corrections and batch reconciliation both rewrite history. A number an advertiser screenshotted yesterday may legitimately differ today — the design's stance is that a corrected count beats a stable wrong one, and the invoice trusts the reconciled figure, not the dashboard's first draft.

⚖️ Trade-offs

Option Gives you Costs you Use when
Stream-only (Flink → OLAP, no batch path) One codebase, one pipeline, lowest latency No independent check on correctness; a stream bug silently corrupts billing data until someone notices [i] Metrics are advisory, not billed — dashboards, trends
Stream + batch reconciliation (this design's choice) Real-time reads and provable counts; bad code fixed by re-running [i] Two systems to operate; discrepancy-investigation toil; S3 lake + Spark cost Counts move money — this design
Small window (1 min) Fresh data; tiny in-flight state; cheap recovery by replay More flush traffic; corrections span more window rows The product promises minute granularity — here
Large window (hour/day) Fewer flushes; less OLAP churn Big in-memory state; checkpointing becomes genuinely necessary; an hour of work at risk per crash Coarse-only reporting; better rolled up in OLAP instead
Dedup before the stream (Click Processor + Redis) Catches duplicates across window boundaries; keeps fakes out of the log A cache on the ingest hot path; cache loss reopens the window Idempotency is a hard requirement — here
Dedup inside the stream processor No extra infra Blind to duplicates straddling window boundaries Only if windows are huge relative to retry gaps
Dedup at the sink only (idempotent upserts) Simplest possible pipeline Only dedups identical aggregate writes, not duplicate events — a double-counted click is already inside the number Never alone; always as the outer layer

🔢 Numbers that matter

The scale figures, and what each one decides (arithmetic per the estimation discipline):

  • 100M clicks/day ≈ 1,200/s average; peak 10k/s — a peak-to-average ratio of ~8×. The peak drives sharding (Kinesis: 1,000 records/s per shard → ≥10 shards before any headroom); the gap justifies the log — a buffer that absorbs an 8× swing so consumers can be sized nearer the average.
  • ~100 bytes/click event → 10k/s peak is ~1 MB/s of ingest — trivially small in bandwidth terms. The problem was never bytes; it's write coordination and read aggregation.
  • The batch math: a 5-minute Spark cadence at 10k/s processes 3M events ≈ 300 MB per run — well within Spark's capacity. The batch path fails on latency, not on load.
  • Dedup cache: 100M/day × 16-byte impression IDs ≈ 1.6 GB — small enough that "can Redis hold it?" is not the question; "what happens when it's lost?" is.
  • Raw log retention: 100M/day × 100 B = 10 GB/day, 70 GB for a 7-day stream retention — a rounding error against the reconciliation value it buys (rule of thumb, not from source).
  • Aggregate rows: at most one per ad-minute with ≥1 click, so ≤100M/day (usually far fewer) — the OLAP store grows by roughly the raw click volume ÷ clicks-per-ad-minute, and pre-aggregated day/week roll-up tables shrink long-range queries by another ~1,440×.

🏭 In production

Operating this pipeline is mostly the discipline of watching lag. A stream processor has three responses to falling behind — drop, buffer, or backpressure [i] — and this design chose buffering by putting a durable log in the middle: a click-spike or a slow Flink job widens the gap between the log's head and the consumer's offset instead of dropping data. That gap — consumer lag — is the pipeline's vital sign, and its event-time twin is watermark delay: how far the watermark trails wall-clock time, i.e., how stale "final" windows are. Alert on both; a growing lag with a flat input rate means the consumer is sick, while lag growing with input is the hot-shard signature from deep dive 1 — per-shard lag tells you which (operational practice: rule of thumb, not from source).

The log's retention window is also the outage budget: a consumer that falls behind by more than retention starts missing events permanently [i] — so "7 days of retention" really means "we can survive a long weekend of pipeline failure and still recompute." And replay is the production superpower the log quietly grants: because reading is non-destructive and the offset is consumer-controlled, you can rewind to yesterday and reprocess with fixed code, repeatedly, without disturbing other consumers [i] — this, plus the S3 lake feeding Spark re-aggregation, is how a bad deploy becomes a re-run instead of an apology. For the serving layer, note DDIA's warning about batch jobs writing straight into a live database — per-record writes throttle the job and can degrade the serving store's query performance [i]; real-time OLAP systems like Druid and Pinot are built to ingest from Kafka streams instead [i], which is why the reconciliation path's corrections also flow through controlled ingestion rather than raw UPDATE storms.

Then there is the reason the audit machinery gets funded: billing disputes. An advertiser challenges an invoice; "the dashboard says so" is not an answer. The immutable raw log is what turns the dispute into a procedure — replay the events, recompute the aggregate, compare — and DDIA's "trust, but verify" argument says to run that procedure continuously, not just when challenged: hardware and software both corrupt data eventually, so integrity must be checked end-to-end, and event-sourced systems are auditable precisely because state can be re-derived deterministically from the log [i]. The hourly reconciliation job is that principle on a schedule — a continuous audit that happens to also fix the numbers. (Ad platforms' actual dispute and audit workflows are not documented in our sources; the mechanism here is the DDIA-grounded shape, not a claim about how any named company runs billing.)


🪤 Pitfalls & interview traps

⚠️ The datasheet trap. "We enabled exactly-once mode, so counts are correct" is the answer interviewers set up on purpose. Exactly-once is effectively-once state within the framework [i] — it says nothing about a browser retrying a POST above the pipeline, and nothing about side effects leaving it below. Correctness here is three separate layers — end-to-end impression-ID dedup at ingress, checkpointed state inside, idempotent upserts at the sink — and dropping any one of them reopens a double-counting hole the other two cannot see.

  • Dedup by (user_id, ad_id). Feels obvious; breaks retargeting — the same ad legitimately shown to the same user twice yields two billable clicks. The unit of idempotency is the impression, not the user-ad pair. Interviewers ask this as a follow-up precisely because the fix (impression IDs) then invites the fraud question — and unsigned impression IDs walk into it.
  • Dedup inside the windowed operator. The duplicate that straddles a window boundary — 12:00:59 and 12:01:01 — defeats any per-window check. Dedup must live upstream of windowing.
  • Windowing by processing time. Restart a lagged consumer and watch it bill a phantom click spike into the recovery minute [i]. Event time, always — and when the interviewer hears "event time," the follow-up is "so when do you close the window?", which is your cue for watermarks and the ignore-vs-correct straggler decision [i].
  • Proposing checkpointing as reflex. Interviewers push back deliberately here: with one-minute windows and a retained log, replay-and-reaggregate loses you at most a minute of work. Reciting a feature is mid-level; sizing whether it's needed is senior.
  • Sharding by AdId and stopping. The design is correct until the first viral ad, which is to say: the design is incorrect. Volunteer the hot-shard failure and the salting fix — plus salting's read-side cost [i] — before being asked.
  • Forgetting the counts are money. Latency questions get all the airtime, but the requirement that shapes this system is integrity — a stale dashboard self-heals, a wrong invoice doesn't [i]. Saying that sentence out loud reframes the whole interview.

✅ Check yourself

Q: The interviewer says: "You mentioned Flink checkpointing. Do you actually need it here?" What's the strong answer?

Probably not, and here's the reasoning rather than the reflex. Checkpointing protects in-flight state — with tumbling one-minute windows, that's at most a minute of partial counts per ad. The log is durable and retains events for days, so a crashed job can restart, rewind its offsets a minute, and re-aggregate what it lost (this is exactly the pushback worth making: candidates propose checkpointing because they've read about it, and the experienced answer is to notice the windows are tiny). Where checkpointing does earn its keep: long windows or big keyed state — daily aggregations, joins — where "just replay" means re-reading hours or days of the log [i]. The meta-lesson is the seniority signal worth calling out explicitly: matching machinery to the actual failure cost, instead of installing every fault-tolerance feature the framework offers.

Q: An advertiser disputes last Tuesday's invoice: "You charged us for 1.2M clicks; our landing-page logs show 900k visits." Walk through how this design answers.

The answer runs on the raw log, not the dashboard. First, the counts are re-derivable: Tuesday's raw click events are in S3 (and possibly still within stream retention), so you re-run the batch aggregation from immutable input and compare — if streaming miscounted, reconciliation shows the discrepancy and the corrected number, which is precisely why the dual path exists (DDIA2 pp. 545–546, 576–578). Second, the kind of gap is diagnosable: per-impression events with signed IDs let you check dedup (were duplicates counted?), signature failures (was there click fabrication?), and the timeline (did their landing page drop traffic — a gap on their side of the 302?). What you can't do is arbitrate data you never captured — which is why the immutable, signed, append-only event log is the component this design never compromises: it's the difference between a dispute being a query and being a negotiation. Note the honest boundary: real ad platforms layer fraud detection on top (scoped out here), so this answer covers count integrity, not click quality.


🔬 PoC — Proof of concepts

Run it yourself. Ad-click aggregator — a click stream windowed and aggregated in near-real-time, with the reconciliation batch that fixes what the fast path approximated; the classic speed-vs-accuracy split. From _proof-of-concepts/07-case-studies/10-ad-click-aggregator/, run ./run.

Study real implementations.

  • Apache Kafka — the durable, replayable ingestion log every click flows onto; replay is what makes the reconciliation pass possible.
  • Apache Flink — windowed, exactly-once stream aggregation with event-time and watermarks; the engine that does the fast-path counting for real.
  • Apache Druid — the real-time analytics store these aggregates land in for sub-second slice-and-dice queries.

📚 Sources

DDIA2 ch. 12 pp. 488–528 (immutable events, partitioned logs & consumer offsets, event vs processing time, stragglers & window types, checkpointing, effectively-once & idempotence) · DDIA2 ch. 11 pp. 451–481 (derived output & human fault tolerance, pre-aggregation & OLAP serving, batch writes to serving stores) · DDIA2 ch. 13 pp. 541–578 (derived data & reprocessing, kappa architecture, end-to-end argument & request IDs, timeliness vs integrity, auditing) · DDIA2 ch. 7 pp. 255–264 (skew, hot shards, key salting) · [web: Apache Flink docs — "Timely Stream Processing"] (the term "watermark" for the event-time completeness signal)

Mark as read