Think of it like a two-piece puzzle:
Ad Server Side Client Device Side
───────────────── ──────────────────
Knows EVERYTHING: Knows almost NOTHING:
• Campaign details • Opaque token
• Creative info • When to send events
• Price • "Ad was watched"
• Tracking URLs
Enrichment = reuniting these two pieces so downstream systems (billing, reporting, frequency capping) get complete information.
Imagine ordering food delivery. The restaurant (ad server) knows every detail about your order. Your doorbell ring (client event) only signals arrival. Enrichment matches the doorbell ring to the specific order details.
Ad Server ──writes──► Metadata Registry (Key-Value Store)
│
Client Event ──reads──────────┘
│
▼
Enriched Ad Event
Live events create extreme traffic spikes:
Normal SVOD Traffic: ────────────────────────
Live Event Traffic: ─────────▲▲▲▲▲──────────
(100M+ viewers
simultaneously)
The math problem:
| Scale | Impact |
|---|---|
| 100M+ concurrent viewers | Millions of ad events per second |
| Each event needs metadata written first | Millions of DB writes/second on critical path |
| DB write must succeed before ad serves | Single point of failure |
Three core problems created:
"The ad server already logs everything. Do we really need to write to a database BEFORE serving? Can we match streams AFTER the fact?"
OLD APPROACH (synchronous):
Ad Server → Write to DB → Serve Ad → Client fires event → Read from DB → Enrich
NEW APPROACH (asynchronous):
Ad Server → Serve Ad → Log response to stream ──┐
├──► Join in Flink → Enrich
Client fires event → arrives on stream ──────────┘
Key benefit: The database write is completely off the critical path
Apache Flink is a framework built specifically for stateful stream processing — processing continuous data streams while remembering state between events.
Ad Serving Response Stream ──────────────────────┐
▼
[Flink Streaming Job]
│
Client Event Stream ──────────────────────────────┘
│
▼
Enriched Event Stream
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Billing Reporting Frequency Cap
Flink holds serving responses in memory for 60 minutes, waiting for matching client events:
Timeline:
0:00 ── Ad served, response logged ──────────────────────────────────────►
0:03 ── Client fires "impression" event → MATCHED immediately ✓
0:15 ── Client fires "first quartile" event → MATCHED ✓
0:45 ── Client fires "completion" event → MATCHED ✓
1:05 ── Late event arrives → MISSED (goes to recovery path)
Client Event Arrives
│
▼
Find matching
serving response?
│
┌────┴────┐
YES NO
│ │
▼ ▼
Enrich Still within YES → Hold, wait for response
immediately 60 min? ──►
│
NO
▼
Send to recovery
(Spark batch job)
Netflix built a KeyedCoProcessFunction (custom operator) because:
| Built-in Windowed Join | Custom Operator |
|---|---|
| Enforces time ordering | Not needed (downstream handles ordering) |
| Fixed semantics | Full control over matching logic |
| Hard to tune retention | Tune per stream type |
| Cannot easily extend | Can add features without rewriting |
Region A (us-east-2) Region B (us-east-1)
──────────────────── ────────────────────
Ad served here Member switches from
Response stored here wifi → mobile data
Event arrives HERE
❌ No matching response!
Rejected approach:
❌ Replicate ALL responses to ALL regions
→ Wasteful: 99%+ of responses never used cross-region
→ High cost and compute
Chosen approach:
✅ Encode serving region INTO the ad token
Token contains: [ad_id | serving_region | other_data]
│
▼
Event arrives in wrong region → Read token → Forward to correct region
us-east-1 receives event → reads token → "belongs to us-east-2" → forwards it
Flow:
Device ──event──► us-east-1
│
│ reads token: "served in us-east-2"
│
▼
us-east-2 ──► matches response ──► enriched event
Only cross-region events pay the extra hop cost — the majority are handled locally.
Some events arrive very late due to:
┌─────────────────────────────────┐
│ Flink Streaming Job │
│ (handles ~99.999% of events) │
│ Window: 60 minutes │
└─────────────────────────────────┘
│
Unmatched after 60 min
│
▼
┌─────────────────────────────────┐
│ Dead Letter Queue / Storage │
└─────────────────────────────────┘
│
Hourly trigger
│
▼
┌─────────────────────────────────┐
│ Spark Batch Job (Maestro) │
│ Matches against data warehouse │
│ (much longer retention) │
└─────────────────────────────────┘
│
▼
Same downstream sinks as streaming path
This batch job also handles:
Client device retries event ──────────────────────┐
├──► Same event arrives twice
Upstream at-least-once delivery ───────────────────┘
For billing, counting the same event twice = charging advertisers incorrectly.
Event fields:
┌─────────────────────────────────────────────┐
│ ad_id + event_type + timestamp/offset │
└─────────────────────────────────────────────┘
│
▼ hash function
stable_event_id
Same underlying event → always same ID → second copy is dropped
| Aspect | Old (External KV Store) | New (Flink Local State) |
|---|---|---|
| Where IDs stored | External database | Local to Flink job |
| Network hop | Yes, every event | No |
| Race conditions | Possible | Eliminated (keyed routing) |
| Speed | Slower | Faster |
Netflix's guarantee:
✅ At-least-once delivery + best-effort deduplication
❌ NOT exactly-once (too expensive, not necessary)
Why acceptable:
• In-stream dedup removes vast majority
• Event ID travels with enriched event
• Billing systems run their own dedup over longer windows
Migrating a live, revenue-critical pipeline = like replacing an airplane engine mid-flight
All Events
│
├──────────────────────────────────────────────┐
│ │
▼ ▼
Legacy Pipeline Flink Pipeline
│ │
▼ ▼
Output A Output B
│ │
└──────────────────┬───────────────────────────┘
│
▼
Automated Audit
(alerts on drift)
Shared enrichment logic module = both pipelines use identical enrichment code → any difference in audit = real pipeline bug, not logic difference
❌ Random split:
Event X → Legacy (published)
Event X → Flink (also published)
= DOUBLE COUNTED downstream
✅ Deterministic split using stable key:
Event X → stable_key → always routes to ONE pipeline
= Published exactly once
Cutover dial:
0% ──────────────────────────────────────────► 100%
[Legacy handles all] [Flink handles all]
│
└── Raise gradually, monitor, can roll back instantly
Agreement rate: >99.99% ✓
Region-by-region rollout ✓
Legacy service decommissioned ✓
Metadata Registry deleted ✓
Single point of failure: GONE ✓
❌ Tempting: Add extra lookups, side processing to Flink job
✅ Correct: Job's only job = matching events
Everything else → off the hot path
| Concern | Solution |
|---|---|
| Checkpoint performance | Trim state, work with platform team |
| Deployment safety | Canary deployments, keep old job until new one healthy |
| Observability | Custom canary metrics, CI/CD guardrails |
Spark batch job serves multiple purposes:
├── Long-tail event recovery (>60 min)
├── Dead-letter queue replay
├── Historical backfill
└── Operational incident recovery
BEFORE: AFTER:
Ad Server Ad Server
│ │
▼ ▼
Write to ──────────────────► Log to Kafka stream
Metadata Registry (❌ critical path) │
│ │
▼ ▼
Client Event ──► Read Registry ┌─── Flink Job ───┐
│ │ 60-min window │
▼ │ Stateful join │◄── Client Events
Enriched Event │ Local dedup │
└─────────────────┘
│
┌───────┴────────┐
│ │
▼ ▼
Enriched Event Unmatched →
(real-time) Spark Recovery
What was gained: