Index/Ad Click Aggregation

SponsorGitHub
Design15 min

Ad Click Aggregation

A million events a second aggregated into dashboards that are fast and billing numbers that are exact.

Primary concepts and the hard part

Concepts
stream processingevent time vs processing timewatermarkswindowingexactly-once semanticsLambda vs Kappa architectureOLAP storagededuplication at extreme volumebackfill and reprocessing
The hard part they’re probing
Event time. Clicks arrive late, out of order, and duplicated. Aggregating by arrival time is easy and wrong — it puts a click that happened at 10:00 into the 10:07 bucket, and advertisers are billed from these numbers. They want to see watermarks, late-data policy, and a story for correcting yesterday's totals.

Requirements

Functionalwhat it must do
Ingest ad impression and click events
Aggregate counts per (ad_id, minute), per campaign, per country/device
Serve near-real-time dashboards (last few minutes)
Serve historical reports (arbitrary ranges, arbitrary dimensions)
Detect and exclude fraudulent/duplicate clicks
Support billing — numbers must eventually be exactly right
Non-functionalhow well it must do it
Ingest 1M events/sec, spiky
Dashboard freshness: under ~1 minute
Billing accuracy: exact, reconcilable, auditable
Must tolerate late-arriving events (mobile offline, retries)
Must support reprocessing after a bug
Out of scopedeliberately left out
Ad serving/auction (a different, latency-critical system)
ML click prediction
Scale
Events:       1M/sec → 86B/day
Event size:   ~200B → 17 TB/day raw
Cardinality:  1M ads × 1,440 minutes = 1.4B aggregate rows/day (before dimensions)
Query load:   dashboards ~1,000/sec; reports lower volume, heavier
Conclusion
Raw events are too big to query directly and too valuable to discard. So: keep raw in cheap storage for reprocessing, serve queries from pre-aggregates. That split is the architecture.

API / Model

POST
/v1/events
{event_id, type, ad_id, user_id, ts, country, device}
fire-and-forget from the ad server; never blocks a page
202Accepted
GET
/v1/stats?ad_id=&from=&to=&granularity=minute|hour|day&group_by=country
200OKaggregated stats
GET
/v1/campaigns/{id}/summary
200OKcampaign summary
primary keyforeign key → referenced columnHover a table or column to trace its keys

High-level architecture

DatabaseObject storageQueue / streamFocusClick a node for details

Every event enters through one ingest path and then splits in two: a hot path in Flink produces approximate numbers within seconds, and a cold path recomputes exact numbers from the raw archive overnight. Both write to the same OLAP store.

  1. Ad servers fire events at the regional ingest gateway without waiting for a reply. The gateway validates them, adds geo and device, stamps a receive time next to the event time, and writes to Kafka ad.events, partitioned by ad_id. Flink consumes the topic and dedupes on event_id first.
  2. Event-time windowing puts each event in a tumbling 1-minute bucket by when it happened, not when it arrived.
  3. The watermark, max_event_time − δ, decides when a bucket closes. Flink emits the bucket once the watermark passes its end, emits an update for a late event still within the grace period, and sends anything later to a side output.
  4. Aggregation adds up counts and builds HyperLogLog sketches for unique counts, then writes the fast, approximate rows to the OLAP store.
  5. The query service reads the OLAP store for dashboards, reports and the billing export, and caches recent windows in Redis.

The cold path reads the same topic. Raw events are archived as Parquet in object storage, partitioned by date and hour, and outlive Kafka's 7-day retention. Each night, reconciliation recomputes yesterday from the archive and overwrites the stream estimates in the OLAP store with the exact figures billing uses. In parallel, the fraud filter reads ad.events and flags suspicious clicks for that nightly run instead of deleting them.


Trade-offs and deep dives

01
Event time vs processing time — the core of the whole design
text
  A click HAPPENS at 10:00:30 on a phone that's in a tunnel.
  It ARRIVES at your ingest at 10:07:15.

  Processing-time windowing → counted in the 10:07 bucket.  WRONG.
  Event-time windowing      → counted in the 10:00 bucket.  RIGHT.

Advertisers are billed per minute and compare your numbers against their own. Attributing a click to the wrong minute is a billing dispute. Windowing must use the event's own timestamp.

But event-time windowing raises the question: when do you decide the 10:00 window is finished? You can't wait forever. That's what watermarks are for.

02
Watermarks, explained

A watermark is the processor's assertion: "I believe all events with timestamp earlier than T have now arrived." It's typically computed as max_observed_event_time − allowed_lateness. When the watermark passes a window's end, the window fires and emits its result.

The trade-off is explicit and worth stating: a larger allowed-lateness δ captures more stragglers but delays every result by δ. A smaller δ gives fresher dashboards but drops or defers more late data. Pick δ from the observed distribution of arrival delay (e.g. δ = p99 of arrival_time − event_time), and say you'd measure it rather than guess.

03
Late data policy — three tiers

Have an answer for each:

  1. Within the watermark — included normally.
  2. After the window fired but within a grace period — emit an updated result (a retraction plus a new value). Downstream stores must support upsert, which is why the OLAP layer is keyed by (ad, minute, dimensions) rather than append-only.
  3. Beyond grace — route to a side output and let the nightly batch job fix it. Don't distort the streaming pipeline to chase the long tail.
04
Lambda vs Kappa, and why this design is Lambda-ish
  • Kappa (stream only, reprocess by replaying) is simpler and increasingly the default.
  • Lambda (stream for speed, batch for truth) duplicates logic in two systems, which can drift.

For billing, the honest answer is a hybrid that leans Kappa: one stream pipeline produces the live numbers, and a reprocessing run of the same code over archived raw events produces the authoritative nightly figures. You get the correctness of a batch layer without maintaining two separate implementations. Stating it that way — same code, replayed — shows you understand why classic Lambda is criticized.

05
Exactly-once, scoped honestly

Flink's checkpointing gives exactly-once state semantics within the pipeline: on recovery, it restores state and rewinds Kafka offsets so no event is double-counted internally. But the moment you write to an external store, you need either a transactional sink or idempotent upserts keyed by (ad_id, window, dimensions). The end-to-end guarantee is effectively exactly-once, built from at-least-once delivery plus idempotent writes — the same framing as Module 5, applied to analytics.

Application-level dedupe on event_id is still required, because the ad server itself may retry and send the same event twice. That's outside Flink's guarantee entirely.

06
Why an OLAP store

These queries scan billions of rows to compute sums grouped by a few dimensions. A row-oriented OLTP database reads entire rows off disk to sum one column. Columnar stores read only the columns referenced, compress each column separately (very effectively, since adjacent values are similar), and vectorize the scan. Orders of magnitude difference for exactly this access pattern. Never run these reports against the transactional database.

07
Pre-aggregation and rollups

Storing every raw event forever in the query store is unaffordable. Pre-aggregate at ingestion to minute granularity, then roll minutes into hours after a few days and hours into days after a few months. Query granularity degrades with age, which matches how people actually use analytics — nobody needs minute-level data from eighteen months ago.

08
Unique counts need sketches

"Unique users who saw this ad" cannot be pre-aggregated by simple addition — you can't sum two unique-counts. Use HyperLogLog sketches, which are mergeable: the union of two sketches gives the unique count of the union, in a few KB, with ~2% error. If exact uniques are required for billing, compute them in the batch layer over raw data. This is a great place to show you know when approximation is acceptable and when it isn't.

09
Ingest must never block ad serving

The ad server fires events asynchronously and does not wait. If the analytics pipeline is down, ads still serve and events are dropped or buffered locally. Analytics completeness is subordinate to ad delivery — state that priority explicitly.

10
Partitioning by ad_id

Gives per-ad ordering and lets stateful operators keep per-key state locally. The risk is a hot key: one viral ad concentrates on one partition. Mitigate by salting the key for known-hot ads (ad_123#0..9) and summing the sub-aggregates downstream — you trade a merge step for even distribution.


Possible follow-up questions

6 questions·try answering before you reveal