Hotshard

Design an Ad Click Aggregator

An ad is shown. Someone clicks it. Now multiply that by a firehose: about 10k clicks a second — several times that at peak — for millions of different ads. Two consumers want a number out of that stream, and they want opposite things. An advertiser watching a live dashboard wants the count to move within seconds, and a little error is fine. Billing wants the exact count for each ad at the end of the day, because that count is what the advertiser is charged. Build the system that ingests the firehose, serves a fast rough number now, serves an exact billable number later, and counts each click exactly once.

Predict ~1.5 h · Read ~50 min

The problem i

The hard part of this problem is not counting. Counting clicks is a windowed sum: group the clicks by ad and by minute, add them up, done. The hard part is that two answers are owed from the same stream, and they pull in opposite directions. The dashboard's number must be fast, and it may be a little rough. Billing's number must be exact, and it may take hours to settle. At this rate, one path cannot serve both.

The stream itself is the second part of the problem. It is about 10k clicks a second, and it is not spread evenly: one viral ad can take a huge share of the whole stream by itself. Whatever the design, it has to survive that one ad.

And each click must be counted exactly once. The same click can arrive twice: a phone on a bad network re-sends a click when it gets no reply, even if the first send actually got through. Counting it twice is a billing error just as surely as dropping it.

Functional requirements what it must do

  • Ingest every click, and never lose one.
  • Serve a fast, near-real-time aggregate for dashboards.
  • Serve an exact, advertiser-billable count.
  • Count each click exactly once.

Non-functional requirements what it must be

  • The fast number may be provisional; the billable number must be exact.
  • Survive the click firehose without a synchronous per-click database write.
  • Handle late, retried, and duplicated clicks correctly.
  • Survive a viral ad concentrating clicks on one key.

Out of scope left out on purpose

  • How a click becomes a charge
  • How a fraudulent click is spotted
  • How the ad was chosen and priced
  • A unique-clickers or distinct-user count

How the design works

The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.

The API

The whole product is these endpoints. Every box in the design below exists to serve one of them.

RECORD clickRecord a click — append it durably so it can never be lost. The click_id is generated on the device at the moment of the click: a UUID, random enough that ids never collide and need no coordination. This runs at the full firehose rate, so it must not block on a shared counter.
contract ▾
# Append a click to the durable record — at the full firehose rate, never on a shared counter.
 record(click_id, ad_id, campaign_id, geo, ts)
 ok                            # appended durably and retained; can be read back for the exact recompute
READ live-countRead the live count — a dashboard reads the fast, rough aggregate for an ad over a recent window. A few seconds behind is fine.
contract ▾
# The fast provisional number — a windowed sum, a few seconds behind.
 liveCount(ad_id, window)
 count                         # provisional by design; corrected later by the exact recompute
READ billable-countRead the billable count — billing reads the exact aggregate for an ad over an accounting period. This is the number the advertiser is charged.
contract ▾
# The exact billable number — recomputed from the complete record.
 exactCount(ad_id, period)
 count                         # exact, the one the advertiser is charged
RECONCILE by-clockNo caller asks for this. On a cadence, the system re-reads the complete record, recomputes the exact count, and overrides the provisional one. It is driven by the clock, not by a request.
contract ▾
# Not caller-driven — the clock recomputes the exact number and overrides the provisional one.
when the reconcile cadence fires: replay the retained log, recompute exact, upsert-override
 (no caller RECONCILE) — the exact count overrides the provisional one on a stable key

part 1 The single-server version

Start with the smallest thing that works: one service that counts clicks, and one database that holds the counts.

First, the vocabulary. A click, or click event, is one ad-click: a unique id, the ad and campaign it belongs to, a place, and a time. The id is made by the device itself at the moment of the click — a UUID, a random number drawn from a space so large that two devices picking the same one is effectively impossible. That is why no server has to hand out ids: every device can stamp its own, with no coordination. A count, or aggregate, is clicks grouped by ad and by minute, summed. Two versions of the count are owed. The fast, or provisional, count is what a dashboard reads: quick, a little rough, corrected soon. The exact, or billable, count is what billing reads: exact, slower to settle, the one an advertiser is charged. The failure to prevent is a double-count, or its twin, a dropped click. Either puts a wrong number on a bill.

The day-one design has two boxes. A Counter service takes each click. A Database holds one row per ad with a running count. When a click on ad 42 arrives, the Counter service runs one statement: add one to that ad's row. The dashboard and billing both read that same row. For a small ad system taking a handful of clicks a second, this is genuinely fine. It is simple, and it is exact.

Now look ahead at what this design will face. A real firehose is about 10k clicks a second — a planning figure, derived rather than measured. And it is not spread evenly: one viral ad can take a large share by itself. That means thousands of writes a second on that one ad's row. Each write must take the row's lock in turn, so the writers queue. And one row can only be updated so fast, no matter how big the database is. The viral ad does not slow this system down. It takes it down.

click sourceappends a clickCounter service· single-serverone serverDatabase ·one tableone row per adsync increment
The whole system on day one.
Following a request
A click in from the click source
  1. a click on ad 42 arrives at the Counter service~1 ms
  2. the Counter service runs one statement — UPDATE counts SET clicks = clicks + 1 WHERE ad_id = 42 — a synchronous increment on that ad's row~10 ms

One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.

This design fails on two fronts. The write path fails first: a synchronous increment on a hot row cannot keep up with the firehose, and a failed write path drops clicks for good. And even if it could take the writes, one shared count cannot be both fast and exact — and this problem owes one answer of each kind. The scaled design starts by getting the clicks off the hot path.

part 2 The version that survives scale

This section walks the design by use case, in decision order. First, get the clicks off the hot path and win back the fast number. Then split the paths: a second, slow path recomputes the exact number for billing — the signature move. Along the way, each engine doing the work gets its own sizing: how big the log really is, how the counter runs on many machines, how the replay finishes in time. Then harden the counting against duplicates, late clicks, and one hot ad. Last, a real incident shows what a counting error costs once it is billed. The finished diagram waits at the end.

Two ways through this section

The seven decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.

use case 1 The firehose kills the per-click increment

The synchronous per-click increment is dead on arrival: one viral ad pounding one row breaks it. The first job is to get the clicks off the hot write path and win the fast count back another way. Then the two engines doing that work get sized: how big the log really is, and how the counter runs on more than one machine.

deep dive 1 The firehose kills the per-click increment
The question

The synchronous increment has just fallen over on one viral ad's row. How do you take about 10k clicks a second without losing any — and still give the dashboard a number that moves?

The instinct is to keep the counter and spread it out: shard the count, add database nodes. For many problems that reflex is right. Here it is not, because the contention is not spread across the keyspace — it sits on one key. A viral ad's clicks all belong to one count, so its row is still one row no matter how many shards you add, and every write to it still serializes. Adding nodes helps the ads nobody is clicking. It does nothing for the one ad everybody is clicking. You cannot escape a hot row by adding cold ones. So the write path must not touch a shared counter at all.

Append every click to a durable, partitioned, retained event log, and count off to the side. Two clicks for the same ad are two independent appends — there is no row lock to fight over. The log is partitioned, so ingest scales by adding partitions. It is durable and replicated, so an accepted click cannot be lost. And the log is retained — kept for 30 days, not drained — because a second consumer, later in this article, will read the whole thing back. That is why it is a log and not a queue: a queue is drained and gone, and this record must survive to be replayed. For now the log is a shape with requirements, not a product; which real system fits it, and whether it can really take the load, is its own deep dive.

That takes the clicks. Now the fast number. A second service — the Live counter — reads the log as it fills and keeps the counts. The counting itself is simple. Take each click, look at its ad id, and add one to that ad's count for the current 1-minute window — a fixed slice of time that fills, is counted, and closes as the next one opens. (1 minute is the window Uber's real ad pipeline uses.) When a window closes, the counter writes each ad's count for that minute to the Results store, a database built for serving these small aggregate rows to the dashboard quickly. Because the Live counter reads from the log, it never touches a hot row: a viral ad is just more records. Because its counts live in memory, each click costs about 1 ms to process, not the 10 ms of a durable write. The number it serves is a few seconds behind and approximate — the next use cases say why — but it is fast. Like the log, the counter is a shape for now; the engine that runs it, and how it runs on more than one machine, is also its own deep dive. And the store holds only this provisional number; a second, exact number is the whole of the use case after.

click sourceappends a clickEvent log ·partitioned durable logpartitioned · 30d retentionreplayableLive counter ·stream aggregator1-min window · provisionalResults store· serving DBprovisionaldashboardfast read · fenced
The board after this deep dive.
deep dive 2 30 days of clicks — how big is the log, and how does it cope?
The question

The log keeps every click for 30 days. Put a size on that, starting with one click. A click record carries the fields from the brief's contract: the click id (a long random string, the biggest field), the ad id, the campaign id, a device or user id, a timestamp, and a small location tag — each a handful to a few dozen bytes, plus the field names and record framing around them. Call it 200 B per click, a stated planning size. Now the stream: 10k clicks a second × 200 B ≈ 2 MB/s of writing. Is that a lot? No — one ordinary machine writes sequentially to disk at roughly 200 MB/s, about a hundred times more. Writing was never the problem; the increment died on lock contention, not bandwidth. What makes the log heavy is keeping the stream: 2 MB/s that never stops is about 5.2TB over 30 days, and every byte is stored 3 times so a dead machine loses nothing — about 15.6TB of disk. No single machine holds that while also serving one writer and, later, two readers. So the log must be split across machines. And why keep 30 days at all? Because the log is only replayable as far back as it is kept. Billing must be able to re-derive any number it might still have to defend — advertisers are invoiced monthly and dispute bills after that — so the raw clicks are kept for a full billing cycle. Past that, the counts survive in the Results store; the raw clicks age out. The window is a choice with a price. One day of clicks is about 170 GB, and every byte has 3 copies, so each extra day of retention costs about 510 GB of real disk. Double the window and the log's storage doubles — and all the extra buys is the ability to recompute further back.

The unit of splitting is the partition: a slice of the log that is its own append-only file, living on its own machine, with 3 copies kept on other machines. The log here is split into 32 partitions — a stated planning choice — so each holds about 160 GB of the window. Every click is appended to exactly one partition. Which partition should a click land in?

The tempting partition key is the ad id: all of one ad's clicks in one partition, neatly together and in order. It fails for the reason this board keeps teaching. Ad popularity is extremely uneven, so one viral ad's partition takes a huge share of the stream while the rest sit idle — the hot row again, one layer down. A partition, like a row, is one file on one machine, and no number of other partitions helps the one being hammered.

Spread clicks evenly — hash the click id to pick the partition. Hash the click id — effectively random — so every partition receives an even share of the stream, viral ad or not. Adding capacity means adding partitions and machines. And nothing is lost by scattering an ad's clicks: the Live counter already regroups clicks by ad as it counts, so the log does not need to keep an ad's clicks together.

Now the shape's full requirement list is on the table: partitioned append-only files, replicated 3 ways, retained 30 days, an even spread of writes, a retried send dropped instead of stored twice, and the whole history replayable from any point. That list is a description of Kafka — a system built as exactly this: partitioned, replicated, retained logs. The retried send is handled by its idempotent producer: when our own ingest service re-sends a batch the log did not acknowledge, the batch's sequence number lets the log drop the copy. Note what that covers and what it does not: it protects the short hop between our ingest and the log. A phone re-sending a click from out in the world is a different duplicate — it arrives as a fresh request — and a later use case catches it by click id. So the design adopts Kafka here rather than rebuilding it. The replication stakes are worth naming too: the log is the one piece of this board that cannot be rebuilt if lost — everything else recomputes from it.

One more sizing worth naming: 32 partitions is far more than the 2 MB/s write rate needs. The count is chosen for the readers, not the writer — the Live counter's workers will read these partitions in parallel, and more partitions means more parallel readers. How that reading side scales is the next deep dive.

deep dive 3 How the Live counter runs on more than one machine
The question

So far the Live counter has been one box, as if one machine counts everything. One machine can actually go a long way here — 10k events a second at about 1 ms of work each is a handful of busy cores, well within a single multi-core box's reach. But the box cannot stay one machine: peaks run several times the average, the ad system keeps growing, and a single counting machine is a single point of failure for every dashboard at once. How does the counter run on more than one machine, when its whole state is a set of running counts?

The tempting answer is to stay on one machine and buy a bigger one. It is simple, and for a while it works — the average load fits. It fails on three counts. Peaks run 3–5× the average — the brief's stated planning figure — and the biggest machine you can buy is a hard ceiling you meet at the worst moment. Growth compounds the same way. And one machine is one failure: when it dies mid-window, every ad's live count stops at once, and restarts are slow exactly when traffic is high. Scaling out needs an answer to one question first — who holds which ad's count — because two workers each holding half of one ad's count would answer wrong.

Parallel workers, split by ad id — each ad's count lives on exactly one worker. List what the counter actually needs. Run a small program — group clicks by ad, add to the current window's count — continuously over a stream, on several machines at once. Keep each ad's running count in exactly one place. Save that state regularly, and restore it when a machine dies. That list is the job description of a stream-processing engine, and Flink is the one this design adopts — the engine Uber's real ad pipeline runs. Flink runs the counting program as several identical workers and splits the stream between them by ad id: every click for a given ad is routed to the same worker, always. That worker alone holds that ad's running counts, in its own slice of state — what Flink calls keyed state. No count is ever split across two workers, so no merging is needed. The workers also share the reading: each reads some of the log's 32 partitions and routes each click onward to the worker that owns its ad. More partitions means more parallel readers — which is why the partition count was sized for readers.

Failure is handled the same way for one worker as for one machine before: every 2 minutes, each worker saves its slice of counts durably — the checkpoint. When a worker dies, its ads are reassigned to a replacement, which loads the last checkpoint and re-reads the log from that point. Nothing is lost, because the log kept the clicks; at most the dashboard's number stalls for a moment. A worker's state is small — its slice of the counts — so adding workers costs compute, not memory. One problem stays open. Splitting by ad id means one viral ad's entire stream still lands on the single worker that owns it, and adding workers does not help — the others cannot take clicks for an ad they do not own. That worker simply has to keep up, and past some click rate it cannot. The fix is to split that one ad's count into several sub-counts, so its clicks can spread across workers and be summed at the end. A later use case builds exactly that.

click sourceappends a clickEvent log ·partitioned durable logKafka · spread evenly32 partitions · 30d ≈ 5.2 TBidempotent producerLive counter ·stream aggregator1-min window · provisionalFlink · workers keyed by adResults store· serving DBprovisionaldashboardfast read · fenced
The board after this deep dive — “A click in from the click source” traced, hop by hop. The numbered steps below walk the same path.
A click in from the click source
  1. the click source appends the click to the Event log — an independent, durable append at firehose rate, no shared counter to fight over~10 ms
  2. the Live counter consumes the log off the hot path and adds the click to its one-minute windowed count for that ad~1 ms
  3. the Live counter writes the provisional windowed count to the Results store, where a dashboard reads it~1 ms
use case 2 The fast count isn't the billable count

The dashboard has its fast count now. But fast and billable are not the same thing. A number good enough to watch in real time is not a number you can safely put on an invoice. And once the exact path exists, it gets sized too: replaying terabytes has to finish in time.

deep dive 4 The fast count isn't the billable count
The question

The fast number is back. But it is approximate, for three ordinary reasons — and the first two come from how a click travels. A click is sent by the person's phone or browser, and on a bad network the send can fail. The device keeps the click and sends it again when the connection returns, sometimes minutes later. So a click can arrive after its minute was already counted. And when the first send actually got through but the reply was lost, the device re-sends anyway — so the same click can arrive twice. Both copies carry the same click id, the one the device stamped when the click happened; that id is how a second copy can be recognized later. The third reason is on our side: a crash can lose counts that lived only in memory. Billing cannot bill from a number like that. So where does the exact number come from?

The tempting answer is to fix all three on the live path, then bill from it: wait longer before closing each minute so late clicks are included, remember seen click ids so a retry is not counted twice, and save the counts so often that a crash loses nothing. One number, one path, no second system. It fails, and the reason is structural, not a matter of effort. To be exact, a minute's count must wait for every click that belongs to it — including ones retried minutes later. A path that waits that long is no longer real-time. Complete means waiting; instant means not waiting. And even after giving up the speed, a crash can still lose whatever was in flight at that moment. Fast-and-exact from one path is not a hard problem. It is a contradiction. So the exact number has to come from a second path, one that is allowed to be slow. (A later use case does add guards for late and duplicated clicks — they make the fast number better, but they cannot make it exact.)

Keep the fast provisional count, and add a batch Reconciliation job that replays the log for the exact one. That second path is the batch reconciliation fork, the signature of this board. A Reconciliation job runs on a cadence — hourly, nightly — and replays the durable log from the start of the period. It reads the whole history at rest: nothing is in flight, every late click has landed, every duplicate can be resolved against the full record. So it can be exact. When it finishes, it overrides the provisional number in the Results store — an upsert on a stable key, the same ad-and-window row replaced in place. The Results store, so far just 'a serving database', can now be named too: it is Pinot, a database built for holding small aggregate rows like (ad, minute, count) and answering dashboard queries over them in milliseconds — and its upsert guarantees the store never keeps two records for the same identifier, which is exactly what the override needs. That is the standing rule of the fork: everything the fast path computes is eventually overridden by the batch path, by design. Two small costs ride along. During the override, old provisional rows and new exact rows briefly coexist, so the store needs about double the space for that window. And the store itself is never a bottleneck: a handful of dashboard and billing queries read what the whole firehose wrote, over tiny aggregate rows.

The cost is lag. The recompute runs on a cadence and takes time to finish, so the exact number settles a few hours behind — call it 2 to 6 hours. During that window, the only number for the most recent clicks is the provisional one, and the two numbers can legitimately disagree. That is not a bug. The fast number is a live guess. The exact number is the recomputed truth. The guess being a little off is the whole reason the recompute exists.

click sourceappends a clickEvent log ·partitioned durable logKafka · spread evenly32 partitions · 30d ≈ 5.2 TBidempotent producerLive counter ·stream aggregator1-min window · provisionalFlink · workers keyed by adResults store· serving DBPinotprovisional + exact · upsertdashboardfast read · fencedReconciliation job· batch recomputereplay · exactupsert overridebilling systemexact read · fenced
The board after this deep dive.
deep dive 5 Replaying terabytes — how the exact recompute finishes in time
The question

The Reconciliation job replays the log. Put a size on that too. A normal nightly run replays one day: about 170 GB. But the batch layer's real promise is bigger than a day. If a counting bug is found — say the seen-click-id check was mistakenly dropping real clicks for a week — the fix is to correct the code and recompute every affected day from the log. That is the whole reason the log keeps 30 days. And the worst case is recomputing all of it: the full 5.2TB window. How does one job replay that much log in time?

The tempting shape is one machine: the nightly day is about 170 GB, and at 200 MB/s sequential that reads in a quarter of an hour — one machine genuinely suffices for the normal night. It fails on the abnormal one. The batch layer exists precisely so a found bug can be recomputed away, and that means replaying not one day but every affected day — up to the whole 30-day, 5.2TB window. On one machine that is 7+ hours of pure reading before any counting, blowing past the 2–6 hour lag the design promised, exactly on the day trust in the numbers is lowest. Sizing the replay for the normal night and not the bad morning misses what the layer is for.

A distributed batch replay — Spark splits the read, shuffles partial counts, sums. The job runs on Spark, a batch engine whose whole trick is splitting a big read. The log is already in 32 partitions, so the split is natural: each of many workers reads some partitions and counts the clicks it sees, by ad, over data at rest. Note the difference from the live path: there, every click was routed to its ad's one owner before counting, so no merge was ever needed. The batch job does it the other way around — count first, merge after. Because clicks were spread evenly, every worker ends up holding partial counts for the same ads, so the workers exchange them: partial counts for each ad are sent to one place, grouped, and summed. Spark calls this exchange the shuffle. It is cheap: partial counts per ad are far smaller than the raw clicks they summarize, so the reading, not the exchange, dominates the run. The summed exact counts are then upserted into the Results store, overriding the provisional ones.

The payoff is that replay time divides by the worker count. The 7-hour single-machine read becomes under an hour on 10 workers, and the workers exist only while the job runs — a batch job can borrow a fleet for an hour in a way a live, always-on path never can. That is the quiet asymmetry of the fork: the live path must be provisioned for its worst second, while the batch path can rent its way through its worst day. A failed run just re-runs — the replay-and-upsert was already idempotent.

click sourceappends a clickEvent log ·partitioned durable logKafka · spread evenly32 partitions · 30d ≈ 5.2 TBidempotent producerLive counter ·stream aggregator1-min window · provisionalFlink · workers keyed by adResults store· serving DBPinotprovisional + exact · upsertdashboardfast read · fencedReconciliation job· batch recomputereplay · exactupsert overrideSpark · distributed replaybilling systemexact read · fenced
The board after this deep dive — “The exact number, recomputed off the same log” traced, hop by hop. The numbered steps below walk the same path.
The exact number, recomputed off the same log
  1. the Reconciliation job replays the whole retained log for the period, off the hot path on a cadence~5 ms
  2. it recomputes each ad's exact count and upserts it over the provisional value on a stable key — the same ad-and-window row, replaced in place~5 ms
use case 3 Retries, late events, and hot ads

Both paths work now. But they leaned on three things nobody proved: a repeated click counts once, a late click has a rule, and a runaway ad does not jam one key. Each one quietly bills a wrong number. This use case settles all three.

deep dive 6 Retries, late events, and hot ads
The question

The two-path design works. But it left three problems open. A duplicated click from a re-sending phone still counts twice. A late click still has no rule for when a minute's count is done. And one viral ad still pins its one owning worker — the problem the counter's scaling left open. (The other cause of a skewed live count, a crash, the workers' checkpoints already cover.) The first two put a wrong number on the dashboard; the third stalls the counter. How do you close all three?

The first problem — duplicates — is the debate. A re-sent click reaches the Live counter as a second copy of the same click, carrying the same device-stamped click id, and counting it twice inflates the bill. So the counter keeps a set of recently seen click ids and skips any click whose id is already in it. (Flink stores this set the same way it stores the counts: in the job's own checkpointed state.) The question is the size of that set, because it is the biggest memory cost on this board. It must remember further back than the 1-minute window, because a retry can arrive minutes late — call it 1 hour, a stated planning choice. 1 hour at 10k clicks a second is about 36M ids. An exact set, at about 24 B an id, is about 864 MB, and it grows with the window and the rate. The alternative is a bloom filter: a probabilistic structure that answers definitely-never-seen or possibly-seen. At a 0.1% false-positive rate it holds the same window in about 65 MB — about 13× less. The cost: a false positive occasionally drops a real click as a duplicate.

A windowed bloom filter over the seen click ids — trade a tiny false-positive rate for far less memory. That dropped click is a small, bounded under-count, and the batch replay corrects it on the full log anyway — so the provisional live path can afford it. The exact set stays the fallback for when even a tunable error is unacceptable. Either way, the window is sized to the retry horizon and no wider: memory grows with the window, and a wider one only guards against very-late duplicates the batch replay already catches.

The second problem: late clicks. Every click carries the time it happened. But clicks do not arrive in that order — a click can be delayed by a slow network and show up after clicks that happened later. So when can the counter say a minute is finished and publish its count? It cannot wait forever. And it cannot close the minute the moment the clock passes it, because clicks from that minute are still on the way. Flink's rule for this is called a watermark. The counter tracks the newest click time it has seen so far, and subtracts a small waiting allowance — a few seconds. That moving mark is the watermark, and it means: everything up to this time has probably arrived. When the watermark passes the end of a minute, that minute's count is published. A click from that minute that arrives after publishing gets a grace period, called allowed-lateness: the count is updated and published again. Only a click later than the grace period is set aside — and even then the batch replay still counts it in the exact number. The allowance is a tuning knob: too tight drops real late clicks, too loose delays every minute's publish, and either way the batch replay is the backstop. One more rule: the watermark only moves when new clicks arrive. If one of the log's partitions goes quiet — a lull, a paused region — it would hold the whole watermark still. So a quiet partition is marked idle and skipped until it speaks again.

The third problem: the viral ad. All of its clicks go to the same counter key, on its one owning worker, so the update pressure lands on one key's state. A read hot key is fixed by serving copies from replicas. This is different — it is write skew. You cannot spread a write across replicas, because the point is to accumulate one count. So you salt the key: split the viral ad's counter into several sub-counters — the ad id plus a small salt — and sum them at the end. The clicks fan out, and no single key is pinned. This is forced, not optional: when one key takes a big share of the writes, adding machines cannot fix it, so you must spread the hot key itself.

One precision, because it is easy to conflate. Counting a click once is a chain of idempotency keys, not one guarantee: the log's producer drops a retried send at ingest, the dedup set drops a duplicate at aggregation, the store's upsert keeps the override from writing twice, and billing uses the click's own id so a click never bills twice. No single link is exactly-once by itself, which is why the Live counter needs its own dedup set on top of Kafka's. And this is count-once — count an event once inside an aggregate — not deliver-once, which is delivering a message once to a recipient. Similar keys, opposite failure domains.

click sourceappends a clickEvent log ·partitioned durable logKafka · spread evenly32 partitions · 30d ≈ 5.2 TBidempotent producerLive counter ·stream aggregator1-min window · provisionalFlink · workers keyed by addedup set (bloom)watermark · late-firesalt hot adResults store· serving DBPinotprovisional + exact · upsertdashboardfast read · fencedReconciliation job· batch recomputereplay · exactupsert overrideSpark · distributed replaybilling systemexact read · fenced
The board after this deep dive — “A click in from the click source” traced, hop by hop. The numbered steps below walk the same path.
A click in from the click source
  1. the click source appends the click to the Event log — an independent, durable append at firehose rate, no shared counter to fight over~10 ms
  2. the Live counter consumes the log off the hot path and adds the click to its one-minute windowed count for that ad~1 ms
  3. the Live counter writes the provisional windowed count to the Results store, where a dashboard reads it~1 ms
use case 4 When a fractional counting error became a $90M settlement

The happy path is complete. This last use case opens on a real production incident — and the failure strikes the billable number itself, the very thing the reconciliation layer was built to defend.

deep dive 7 When a fractional counting error became a $90M settlement
The question

Everything is built, and the happy path is complete. Now the premise arrives as a real incident — and it is not an off-to-the-side failure. It lands on the exact axis, the billable count, the one the whole reconciliation layer exists to protect.

The incident, plainly. Google's 2006 Lane's Gifts settlement paid up to $90M. $60M of it went back to advertisers as ad credits — about $4.50 refunded on every $1,000 spent, an overcharge of about 0.5%, across four years. And Google is not alone: in 2019, Facebook paid $40M over overstated video-watch metrics, $28M of it distributed to advertisers. Two dated, litigated incidents, both about a number that was supposed to be exact and was not. So — which number do you bill from, and is the batch layer worth its cost?

The tempting read is that the live count is close enough — it is usually within a fraction of a percent, so bill from it and save the second path. The incidents show why that is wrong. The provisional count drifts for ordinary reasons: a late click, a retry, a crash. Billed, each drift is money charged for clicks that did not happen. A fraction of a percent is not a rounding error once it touches a bill; it is a refund, a class action, a settlement. Billing cannot run on approximately right, because the cost of approximately is measured in litigation, not error bars.

Billing forbids a lossy path — keep the exact recompute, and never bill from the provisional count. The failure lives on the exact axis, exactly where the reconciliation fork was aimed. That fork is not an over-engineering luxury; it is what keeps a fractional error from becoming a settlement. It makes each piece load-bearing. The durable log must keep every click, because the recompute is only as complete as its record. The dedup must be exact or batch-corrected, so no click bills twice. And the exact number, never the provisional one, is what reaches billing. The fast number is for the dashboard, where close is fine. The exact number is for the bill, where close is a lawsuit.

click sourceappends a clickEvent log ·partitioned durable logKafka · spread evenly32 partitions · 30d ≈ 5.2 TBidempotent producerLive counter ·stream aggregator1-min window · provisionalFlink · workers keyed by addedup set (bloom)watermark · late-firesalt hot adResults store· serving DBPinotprovisional + exact · upsertdashboardfast read · fencedReconciliation job· batch recomputereplay · exactupsert overrideSpark · distributed replaybilling-exactbilling systemexact read · fenced
The board after this deep dive — “The exact number, recomputed off the same log” traced, hop by hop. The numbered steps below walk the same path.
The exact number, recomputed off the same log
  1. the Reconciliation job replays the whole retained log for the period, off the hot path on a cadence~5 ms
  2. it recomputes each ad's exact count and upserts it over the provisional value on a stable key — the same ad-and-window row, replaced in place~5 ms
Where this design sits on consistency (and CAP)

This design leans available over consistent for the number it serves right now, and buys back exactness later. The live count is served the instant a dashboard asks, even though a late click, a retry, or a crash may have left it a little off. That is the right trade for a dashboard: a close number now beats an exact number hours late. Nothing a dashboard reads is authoritative.

The billable number makes the other move, and it is the distinctive one. It is not merely eventually consistent — it is eventually exact. The batch replay reads the full history rather than a live stream, so it can be exact, and re-running it changes nothing. The cost is the reconciliation lag, during which the live and reconciled numbers can legitimately disagree. That disagreement is the design working.

There is a name for the underlying choice. When a network fault splits a system in two — the P, for partition — each operation has two honest options: answer anyway and risk being wrong (A, availability), or refuse until the halves agree (C, consistency). The useful way to apply CAP is per operation, by asking what a wrong answer costs. A slightly-off dashboard number costs almost nothing, so the live path takes availability. A wrong bill costs a refund and a lawsuit, so the billable number comes from a full replay of a complete log — exact, and a few hours late.

CAP theorem — the partition trade-off, per operation
The finished board

That is the whole machine: one durable log feeding two counting paths, and a store that serves both numbers. The click source appends every click to the Event log — partitioned, durable, retained for 30 days. The Live counter — parallel workers, each owning its share of ads — reads the log and keeps a fast 1-minute windowed count, deduping with a windowed set, closing windows with watermarks, and salting a hot ad's key. It writes that provisional count to the Results store, where the dashboard reads it. Beside it, the Reconciliation job replays the same log on a cadence as a distributed batch, recomputes the exact count, and overrides the provisional value in the store. Billing reads only the exact number. If you carry away one sentence, make it this: when one stream owes a fast rough answer and an exact slow answer, do not force them through one path. Keep a durable, replayable record of the events. Serve the fast answer off a live read of it. Recompute the exact answer off a full replay of it, and let the exact one override.

click sourceappends a clickEvent log ·partitioned durable logKafka · spread evenly32 partitions · 30d ≈ 5.2 TBidempotent producerLive counter ·stream aggregator1-min window · provisionalFlink · workers keyed by addedup set (bloom)watermark · late-firesalt hot adResults store· serving DBPinotprovisional + exact · upsertdashboardfast read · fencedReconciliation job· batch recomputereplay · exactupsert overrideSpark · distributed replaybilling-exactbilling systemexact read · fenced
The finished design — every box placed by one of the deep dives above.
What's on the diagram
click source
click source. The entry-side actor — a client that emits a click and appends it to the durable log the instant it happens. On a real ad firehose, ten thousand of these arrive a second, one viral ad taking a large share of them, and getting every click off the hot path and into a durable, replayable record is the move the whole design grows from.
Event log · partitioned durable log
Event log · partitioned durable log (Kafka). The board's durable hero and the one record both counting paths read: every click is appended here first, as its own independent record, so a viral ad cannot serialize the write path the way a shared-counter increment would. It is partitioned, so ingest scales by adding partitions, and durable and replicated, because it is the source of truth the exact recompute replays — losing it loses the ability to recompute the exact count. Its idempotent producer drops a retried send at ingest with negligible throughput cost. It is retained for thirty days — the reprocessing window — because the Reconciliation job reads the whole thing back; that is why it is a log, not a queue. It is sized by throughput — it absorbs the derived ten-thousand-a-second firehose across partitions — while its roughly five-point-two terabytes over thirty days is a modest slice of a petabyte-scale cluster, not the wall.
Live counter · stream aggregator
Live counter · stream aggregator (Flink). The fast path: it reads the log off the hot path and keeps a one-minute tumbling-window count per ad, writing a provisional number to the Results store within seconds, and never touching a hot row because a viral ad is just more records to consume. It holds a windowed dedup set of seen click ids so a retried click counts once (a bloom filter for about sixty-five megabytes versus an exact set's eight hundred sixty-four — the memory wall of this board), uses a watermark with allowed-lateness to decide when a window is done and re-fire on a late click, marks idle partitions so a cold ad does not stall the pipeline, and salts a viral ad's key into sub-counters so its writes fan out. It is sized by the dedup memory, checkpointed every two minutes so a crash restores from the last checkpoint, and provisional by design — a transient loss here is reconciled to exact by the batch layer, because the live path is allowed to be lossy and the billing path is not.
Results store · serving DB
Results store · serving DB (Pinot). The serving store that holds both numbers per ad and window — a provisional value written by the Live counter, overridden to exact by the Reconciliation job via an upsert on a stable key that keeps the store from ever holding two records for one identifier — and answers the dashboard's fast reads and billing's exact reads. It is sized by the dashboard's and billing's query rate, which sits far below the ingest rate; its per-row aggregates are tiny, sized by distinct-key cardinality and retention, so capacity is not the wall — the dedup set is. It is replicated so a node loss is covered, and a stale provisional row is never stuck, because the next batch override replaces it in place.
dashboard
dashboard. The exit-side read actor — an advertiser's live view that reads the fast, provisional number from the Results store and expects it to move within seconds. It is served the live count, not the billable one, and it is fine that the number is a little rough and corrected soon: on a dashboard, close and fast is worth far more than exact and hours late. It is an external reader on a fenced read edge, not a designed component of this board.
Reconciliation job · batch recompute
Reconciliation job · batch recompute (Spark). The marquee and the signature: on a cadence it replays the whole retained log at rest and recomputes each ad's exact count from the complete, ordered record of every click, then overrides the provisional value in the Results store via an upsert on a stable key. Because it reads the full history rather than a live stream with stragglers, it can be exact; because it replays a durable log and upserts on a stable key, a re-run changes nothing, so a failed job simply re-runs and only the freshness of the exact number degrades. Its working output temporarily needs about twice the storage while new and old results coexist during the upsert. It is what stands between a fractional counting error and a ninety-million-dollar settlement — the exact number it produces, never the provisional live count, is the one that reaches a bill.
billing system
billing system. The exit-side read actor — it reads the exact, reconciled number from the Results store when it charges an advertiser, and it is never served the provisional live count. The system hands it a correct exact number and stops there; how billing turns that count into a charge — authorizing a card, settling the money, updating a balance, handling a chargeback — is a separate problem with its own machinery, fenced off here and never designed by this board.
What this design never covered — raise it yourself
What count-once actually means here, and why it is not delivering once

If asked how exactly-once is guaranteed: it is a chain of idempotency keys, not one mechanism — the log's idempotent producer, the dedup set, the upsert on a stable key, and the click id at billing. And it is count-once (once inside an aggregate), not deliver-once (once to a recipient).

What the billing hand-off looks like without owning it

If asked about the money: this board produces the exact count and hands it to billing. Authorizing, settling, and chargebacks are a separate problem with its own machinery, fenced off here.

Why the reconciliation is not the same as a metrics rollup

A metrics rollup is approximate on purpose and forever, to save storage. This batch layer makes a number more exact, not smaller, because it is billed; its provisional counterpart is approximate only until the recompute reconciles it.

How the dedup window is sized, and why not bigger

The dedup window is sized to the retry horizon. Memory grows linearly with the window, and a wider one only protects against very-late duplicates the batch replay already catches.

Go deeper — the primitives this design leaned on
  • The bloom filter (the windowed dedup set)remember which click ids were seen, to count each once — a small tunable false-positive rate for about {{dedupReductionX}} less memory than an exact set; the one primitive here you can operate directly
  • The partitioned durable log (the event log)append every click to a partitioned, retained, replayable log, so ingest fans across partitions and the whole history can be read back for the exact recompute

Feedback on this problem →