Hard

Design an Ad Click Aggregator

Count billions of ad clicks per day into minute-level aggregates that advertisers are billed on: stream processing with exactly-once effects, dedup of client retries, late-event handling, and nightly reconciliation against raw logs.

1Requirements

Functional

  • Ingest click events (ad_id, user_id, click_id, timestamp) from browsers and mobile SDKs worldwide
  • Serve clicks per ad per minute, queryable within seconds, for dashboards and pacing
  • Support aggregate queries over ranges: clicks for ad X between t1 and t2, top ads by clicks
  • Deduplicate client retries and detect basic click fraud (same user hammering one ad)
  • Provide billing-grade corrected totals within 24 hours via reconciliation against raw logs

Non-functional

  • Peak ingest of 500k clicks/sec (10k/sec average with 50x spikes during major events)
  • End-to-end latency under 10 seconds from click to queryable aggregate for the real-time path
  • Aggregates used for billing must be exactly-once: no lost clicks, no double counting; money is on the line
  • Tolerate a stream-processor crash without producing duplicate or missing counts
  • Raw events retained 90 days for audit and reprocessing

2Back-of-envelope estimation

Clicks per day~1B
Raw event size~200 bytes
Raw daily volume~200 GB/day, ~18 TB/90 days
Aggregate rows10M active ads x 1,440 min = 14.4B possible; ~500M non-zero/day
Aggregate write rate~350k row upserts/min
Kafka partitions500k/s peak / ~10 MB/s per partition x 200 B = ~100 partitions

3API design

POST /v1/clicks

Fire-and-forget click beacon: {click_id, ad_id, user_id, ts}. Returns 204 immediately; client retries with the same click_id.

GET /v1/ads/{ad_id}/clicks?start=...&end=...&granularity=minute

Time-series of aggregated clicks for one ad.

GET /v1/ads/top?window=1h&n=100

Top N ads by clicks in a recent window, from pre-aggregated data.

GET /v1/reconciliation/{date}

Per-ad drift report: realtime total vs batch total vs correction applied.

4High-level design

Click beacons hit lightweight collectors behind a CDN and geo load balancing. Collectors do zero business logic: validate shape, stamp arrival time, write to Kafka keyed by ad_id, return 204. Client SDKs generate a UUID click_id at click time and retry with the same id on timeout, which converts network flakiness into a dedup problem downstream instead of data loss.

A Kafka connector also archives every raw event to S3 as hourly parquet files. This raw log is the system's ground truth and exists specifically so the streaming answer never has to be trusted alone.

A Flink job consumes the stream and does the core work: dedup on click_id within a TTL window, then a 1 minute event-time tumbling window keyed by (ad_id, minute) with allowed lateness, incrementing a count per window. On window close (watermark passes end plus grace), it emits the aggregate to the serving store. Flink checkpoints its state (dedup index, open windows, Kafka offsets) atomically, so a crash rewinds to the last checkpoint and recomputes without gaps or double emission into state.

The serving store is a columnar OLAP database (ClickHouse or Pinot): aggregates keyed by (ad_id, minute_ts) support fast range scans and top-N. Writes from Flink are idempotent upserts keyed by (ad_id, minute_ts, window_version) so replay after a crash overwrites rather than adds; this is how exactly-once effects survive an at-least-once delivery boundary.

A nightly Spark batch job recomputes per-ad-per-minute counts from the S3 raw logs with full dedup and fraud filtering, then diffs against the streaming aggregates. Drift beyond a threshold triggers correction rows and an alert. Billing reads the corrected batch numbers; dashboards read the realtime ones. This is the lambda-architecture shape, kept honest by making reconciliation a product feature (advertisers see corrections) rather than an internal patch job.

5Data model

raw_clicks (S3 parquet)

click_id, ad_id, user_id, event_ts, arrival_ts, ip_hash, ua_hash, collector_id

immutable ground truth, hourly partitions, 90 day retention

minute_aggregates (ClickHouse)

ad_id, minute_ts, clicks, unique_users_est, window_version, updated_at

ReplacingMergeTree on window_version makes replays idempotent

dedup_state (Flink RocksDB)

click_id -> first_seen_ts

TTL 15 min; checkpointed with offsets so recovery is consistent

reconciliation_report

date, ad_id, realtime_total, batch_total, drift, correction_applied

billing reads batch; drift over 0.1% pages the on-call

6Deep dives

What exactly-once actually means here

Exactly-once delivery over a network is impossible; what real systems build is exactly-once processing effects: each click influences the final count exactly once, even though the event may be transmitted, read, and processed multiple times. Every hop achieves it differently and an interviewer wants the per-hop story.

Client to collector: at-least-once via retries with a stable click_id, making duplicates detectable. Collector to Kafka: idempotent producer (producer id plus sequence number dedups broker-side retries). Inside Flink: checkpointing snapshots offsets and state atomically, so reprocessing after a crash resumes from a consistent point; duplicates from the rewound input are caught by the click_id dedup state, which was also rewound consistently. Flink to ClickHouse: the sink is not transactional, so we make writes idempotent instead: the row key (ad_id, minute_ts) plus a deterministic window_version means writing the same window twice converges to one row.

The pattern to name: end-to-end exactly-once = at-least-once delivery + idempotent or transactional effects at every boundary. Saying that sentence, then walking each boundary, is the difference between hand-waving 'Flink has exactly-once' and demonstrating you know why.

Late and out-of-order events

Mobile clicks arrive late constantly: a user clicks in a subway, the SDK queues the event, and it arrives 4 minutes after event_ts. If you window on arrival time, counts land in the wrong minute and advertiser reports disagree with their own logs. So windows must key on event time, which forces the watermark question: how long do you wait before declaring a minute closed?

The standard answer is a bounded-out-of-orderness watermark (say, max observed event time minus 30 seconds) plus allowed lateness of a few minutes. Events inside lateness re-fire the window with an updated count, and the idempotent sink overwrites the previous emission, incrementing window_version. Events later than that go to a side output, land in a late_clicks table, and are picked up by nightly reconciliation rather than being dropped silently.

The tradeoff is explicit: shorter watermark delay means fresher dashboards but more corrections; longer means stabler numbers but stale pacing decisions. Since billing reads the batch layer anyway, tune the realtime path aggressively fresh and let reconciliation absorb the tail. Also plan for the pathological case: one skewed device with a broken clock sending event_ts hours in the future can drag the watermark forward and prematurely close everyone's windows, so clamp event_ts to arrival_ts plus a small tolerance at ingest.

Dedup state at 500k events/sec

Naive dedup ('keep a set of all click_ids') is unbounded. The realistic version is a TTL: client retries happen within seconds, so a 15 minute TTL on the dedup index catches essentially all retry duplicates. At 500k/s that is 450M ids in flight; at ~50 bytes each in RocksDB that is ~22 GB of state spread across Flink workers, heavy but routine, and it checkpoints incrementally.

You can halve this with a two-tier scheme: an in-memory Bloom filter per worker as a cheap first pass (a negative means definitely new, skip the RocksDB read), with the exact store consulted only on Bloom positives. This trades a tiny false-positive-driven extra read for a large reduction in state lookups.

Duplicates older than the TTL (a phone offline for an hour replaying its queue) slip through the realtime path by design. They are caught by the batch layer, which dedups over the full day with an exact distinct on click_id. This is a deliberate split: bounded state and speed in the stream, unbounded correctness in batch.

Reconciliation is the real product

Every serious counting pipeline drifts: a Flink bug, a bad deploy replaying an hour, a collector that silently dropped 0.3% of beacons. The design decision that separates senior answers is treating the raw S3 log plus nightly recomputation as the billing source of truth, with the streaming layer explicitly labeled as a fast estimate.

Mechanically: the Spark job recomputes per-(ad, minute) counts from raw parquet with exact dedup and fraud filters, joins against the streaming aggregates, and writes a drift report. Small drift silently writes correction rows (the aggregates table keeps both realtime and corrected columns). Drift above a threshold, say 0.1% for any ad spending over 1,000 dollars a day, pages on-call because it means a systemic bug, not noise.

This also gives you free disaster recovery: if the streaming pipeline is down for 3 hours, dashboards go stale but zero money is lost, because billing was never derived from the stream. Reprocessing is 'replay Kafka from offset X' or 'rerun Spark for the window', both idempotent by construction.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Redpanda (single binary Kafka) + a Node consumer with SQLite state + Postgres aggregates, on one $20 VPS

  1. 01Run Redpanda in Docker; create topic clicks with 8 partitions keyed by ad_id
  2. 02Build a Fastify beacon endpoint that validates {click_id, ad_id, user_id, ts}, clamps ts to now+5s, and produces to Kafka; return 204
  3. 03Also tee every raw event to a daily NDJSON file (the poor man's S3 raw log)
  4. 04Write the aggregator consumer: per-event dedup on click_id in SQLite with a 15 min TTL sweep, then increment an in-memory (ad_id, minute) window map
  5. 05Close windows when watermark (max event ts minus 30s) passes window end; upsert (ad_id, minute_ts, clicks, version) into Postgres ON CONFLICT UPDATE
  6. 06Commit Kafka offsets only after the upsert succeeds, so replays re-upsert idempotently instead of losing data
  7. 07Write the reconciliation script: recompute counts from the NDJSON raw log with exact dedup, diff against Postgres, print per-ad drift
  8. 08Chaos test: kill -9 the consumer mid-stream, restart, rerun reconciliation, and verify drift is zero

Tumbling window aggregator with dedup and watermark

typescript
interface Click { clickId: string; adId: string; eventTs: number }

const DEDUP_TTL_MS = 15 * 60 * 1000;
const LATENESS_MS = 30 * 1000;

const seen = new Map<string, number>();            // clickId -> firstSeen (SQLite in real MVP)
const windows = new Map<string, number>();          // "adId|minuteTs" -> count
let watermark = 0;

export function onEvent(c: Click, flush: (adId: string, minuteTs: number, count: number) => void) {
  const now = Date.now();
  if (seen.has(c.clickId)) return;                  // duplicate retry, drop
  seen.set(c.clickId, now);

  const minuteTs = Math.floor(c.eventTs / 60000) * 60000;
  watermark = Math.max(watermark, c.eventTs - LATENESS_MS);

  if (minuteTs + 60000 <= watermark) {
    // window already closed: route to late side-output for reconciliation
    lateOutput(c);
    return;
  }
  const key = c.adId + "|" + minuteTs;
  windows.set(key, (windows.get(key) ?? 0) + 1);

  // Close every window whose end is behind the watermark.
  for (const [k, count] of windows) {
    const ts = Number(k.split("|")[1]);
    if (ts + 60000 <= watermark) {
      const adId = k.split("|")[0];
      flush(adId, ts, count);                       // idempotent upsert downstream
      windows.delete(k);
    }
  }
  // TTL sweep for dedup state (run on a timer in real code)
  if (seen.size > 1_000_000) {
    for (const [id, t] of seen) if (now - t > DEDUP_TTL_MS) seen.delete(id);
  }
}

function lateOutput(c: Click) {
  // append to late_clicks NDJSON; nightly reconciliation picks it up
}

Idempotent aggregate upsert (replay-safe sink)

sql
CREATE TABLE minute_aggregates (
  ad_id      TEXT NOT NULL,
  minute_ts  TIMESTAMPTZ NOT NULL,
  clicks     BIGINT NOT NULL,
  version    BIGINT NOT NULL,       -- bumped on each re-emission of the window
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (ad_id, minute_ts)
);

-- Replaying the same closed window overwrites instead of double-counting.
-- Version guard means an old replay can never clobber a newer correction.
INSERT INTO minute_aggregates (ad_id, minute_ts, clicks, version)
VALUES (:ad_id, :minute_ts, :clicks, :version)
ON CONFLICT (ad_id, minute_ts) DO UPDATE
SET clicks = EXCLUDED.clicks,
    version = EXCLUDED.version,
    updated_at = now()
WHERE minute_aggregates.version <= EXCLUDED.version;

Reconciliation against the raw log

python
import json
from collections import defaultdict

def recompute_from_raw(paths):
    seen = set()
    counts = defaultdict(int)  # (ad_id, minute_ts) -> clicks
    for path in paths:
        with open(path) as f:
            for line in f:
                e = json.loads(line)
                if e["click_id"] in seen:      # exact full-day dedup
                    continue
                seen.add(e["click_id"])
                minute_ts = (e["event_ts"] // 60000) * 60000
                counts[(e["ad_id"], minute_ts)] += 1
    return counts

def reconcile(raw_counts, db_rows, threshold=0.001):
    drifts = []
    db = {(r["ad_id"], r["minute_ts"]): r["clicks"] for r in db_rows}
    for key in set(raw_counts) | set(db):
        truth, rt = raw_counts.get(key, 0), db.get(key, 0)
        if truth == rt:
            continue
        drift = abs(truth - rt) / max(truth, 1)
        drifts.append({"key": key, "batch": truth, "realtime": rt, "drift": drift,
                       "page_oncall": drift > threshold})
    return drifts  # apply corrections: upsert batch value with bumped version

Bottlenecks & failure modes

  • Hot ad skew: one viral ad concentrates load on a single Kafka partition and Flink key; pre-aggregate per collector or salt the key into ad_id#0..7 subkeys merged at the sink
  • Dedup state size grows with TTL x throughput; Bloom-filter front, TTL discipline, and incremental checkpoints keep it manageable
  • Watermark stalls from one idle or clock-skewed partition holding back all window closes; use idle-partition timeouts and clamp event_ts at ingest
  • ClickHouse merge pressure from high-frequency upserts; batch sink flushes to once per window close, not per event
  • Checkpoint duration under peak load blocks throughput if state is large; incremental RocksDB checkpoints and unaligned checkpoints mitigate

Key takeaways

  • Exactly-once means at-least-once delivery plus idempotent or transactional effects at every boundary; walk each hop
  • Window on event time with watermarks and bounded lateness; send stragglers to a side output, never drop them silently
  • Client-generated stable click_ids turn retries from data corruption into a solvable dedup problem
  • Keep raw immutable logs and reconcile nightly; bill from batch, dashboard from stream
  • Bound streaming state with TTLs and Bloom filters, and let the batch layer own long-tail correctness

Brush up on the underlying topics