The diff
Send what changed, not what is
The same stable audience, the same nights, two modes. Night one is identical in both, because the list has to be loaded once. Every night after that is the argument.
↳ Drag back to 1: on the first night the two modes are identical, because the list has to be loaded once.
- A full replace resets optimisation. Destinations treat a wholesale rewrite as a new population and re-enter their learning phase. You pay the exploration cost again every single night, and the campaign never gets past it.
- Quota is a shared, finite budget. Every audience on the brand draws from the same per-account allowance. Four hundred thousand rows spent at two in the morning are four hundred thousand you cannot spend on the urgent re-sync at four in the afternoon on the busiest day of the year.
- Replace is proportional to the audience; diff is proportional to the change. A stable four-hundred-thousand-member audience typically churns a few thousand a night. One of those numbers grows with your success and one does not.
- Replace destroys the why. After a wholesale rewrite, membership has no history — nobody entered and nobody left, the list simply is. The moment a marketer asks why this person is receiving this campaign, you have nothing to answer with.
So the sync engine writes a snapshot per run, diffs it against the previous run, and emits adds and removes.
The membership record carries the moment the person entered and the reason they qualified, and both survive re-entry: if someone leaves on the twelfth and returns on the nineteenth, the entry date is the nineteenth and the ledger still shows the exit. That is what turns “why is this person getting this email” from an archaeology project into a lookup.
The adapter
Every destination has its own shape, and none of it is your engine's business
The wrong shape here is a class per destination with the batching baked in — one syncer per platform, each with its own loop, its own retry, its own quiet divergence. Six months later one of them has a bug the others do not. Instead, every adapter declares a capability descriptor and one generic engine reads it.
- Capabilities are data, not code. Adding a destination is a descriptor plus a transport function; the batching, pacing, checkpointing and dead-lettering are inherited.
- Accepted keys are a projection, not a validation. An email-only destination gets email only. A member with a phone hash and no address is not an error — they are a miss, counted against key coverage, which is a match-rate line item.
- Required consent is applied at compile, not at send. Post 4’s rule holds: consent is a join key. The descriptor tells the compiler which fields to join on, and different destinations legitimately require different ones.
- The rate limit paces the engine, not the retries. A token bucket sized from the descriptor means backoff is for genuine failures, not for a limit you knew about in advance.
- A destination that cannot remove gets a compensating path. Its removes route to a suppression list rather than being silently discarded — which is what a special-cased implementation does when nobody is watching.
The checkpoint is written after a batch is accepted, so a crash between batch 41 and 42 resumes at 42 rather than replaying four hundred thousand rows. Idempotence is the destination’s job at the row level and the checkpoint’s job at the batch level; you need both.
Partial failure is the normal case, not the exception. A ten-thousand-row batch comes back accepted with thirty-seven rows rejected — a malformed hash, a key type the destination will not take today, a validation you will never see documented. There are two tempting responses and both are wrong. Failing the sync throws away 9,963 good rows over 37 bad ones. Dropping the 37 makes the dashboard green and the number wrong. You dead-letter each row with its reason, and you surface the dead-letter rate as a first-class metric next to the sync’s success flag.
A sync that reports success while dropping rows is worse than one that fails, because it does not wake anyone up.
Match rate
The one number that tells you whether any of this worked
Match rate is keys submitted versus keys matched on the far side. It is the product metric, and it is also the series’ scoreboard: every earlier post shows up here as a line item. Read one night as a funnel.
Report key coverage separately from match rate. Losing ten and a half thousand members for having no email at all is a collection problem; losing three thousand at the destination is a normalisation problem. One alert for both tells you nothing about which one moved.
A sloppy normaliser from post 2 costs you match rate. An over-merged component from post 2 costs you match rate — one profile, three people, one key submitted. A stale trait from post 3 puts the wrong people in the audience entirely. An unconsented row from post 4 is not a loss at all, it is a correct rejection, and confusing the two is how teams end up “fixing” their consent gate.
- Instrument per audience, per destination, per sync. A brand-level average hides the one audience that fell off a cliff.
- Alert on a relative drop against that pair’s own trailing baseline. A rate that is healthy for a cold prospecting audience is alarming for a repeat-purchaser one. An absolute floor either screams constantly or never fires.
- Baseline on a trailing median, not a mean. One catastrophic night should not lower the bar you compare tomorrow against.
- Report key coverage separately from match rate. One alert for both tells you nothing about which one moved.
The silent killer
An audience that stopped refreshing three weeks ago
The access token expires on a Tuesday. The sync is rejected. The error is caught, logged at warning level, and the job exits successfully — because it did run, it just did not accomplish anything. The nightly dashboard is green. The audience sits frozen at whatever it was on the fourth while spend continues against a three-week-old list, and the only symptom is performance getting slowly, unremarkably worse. Somebody notices in a month, and by then the campaign’s optimisation has been trained against a stale population.
The fix is not a better try-catch. It is structural: every audience-and-destination pair carries a freshness target and a last-successful-sync timestamp, and the alert fires on the absence of a success rather than the presence of an error. A caught exception can be swallowed. A timestamp that fails to advance cannot be.
- The timestamp advances on completion only. Not on start, not on “the job ran”. A partially failed sync does not advance it.
- Never-synced is stale, not exempt. The most common form of this bug is a binding that never worked once and was therefore never compared to anything.
- The freshness checker is a separate process. A checker that runs inside the sync job dies with the sync job.
- The alert names the binding, not the job. “This brand, this audience, this destination, twenty-one days stale” is actionable; “sync-engine warnings elevated” is not.
Monitor for the thing that should have happened, not just the thing that went wrong. The same table gives you the sync ledger — every profile, every destination, every sync id, appended as batches are accepted. It costs one table and a little write volume, and it is the thing that makes post 4’s deletion fan-out possible at all.
Teaching-grade reference implementation, not a production customer data platform. It reproduces the ideas and the streaming/warehouse integration shape; bring your own data and destination credentials. Destination adapters run against a local mock by default. MIT-licensed. View the repo →
Explain it back
Reveal a model answer
Not at the definition. A green dashboard reporting completion is exactly the signature of the silent failures, so start with the two metrics a “completed” job cannot fake. First, the last-successful-sync timestamp for that binding: if it is three weeks old, the job has been catching an auth rejection and exiting zero, and spend has been running against a frozen list — that alone explains the curve. Second, the match-rate trend against the audience’s own trailing median: a drop with a normalisation or over-merge cause behind it means you are submitting keys the destination cannot resolve, and the campaign is optimising against whoever it did match. Third, the dead-letter rate — a sync that accepts ten thousand and quietly discards three thousand reports success. Only then look at the definition-version history, and only to correlate a version bump with the day the curve turned.
The bonus consequence is the one people miss: if you have been running in replace mode, the destination re-entered its learning phase every night for weeks, and some of that decline is a campaign that was never allowed to finish exploring. That is a cost you paid with your sync architecture, not your targeting.
The audience is synced and the spend is real — now the harder question of whether any of those conversions would have happened anyway.
Compared to what? →