Fight it like the real interview: each phase runs against its time budget, you write your answer in a scratchpad first, then reveal the model answer side by side and grade yourself.
The Interview Begins#
You walk into the interview room at a major streaming company. The interviewer, a Principal Engineer, greets you and gestures to the whiteboard.
"Thanks for coming in. Today we're going to design the analytics platform for our video streaming service. We have 150 million subscribers watching content across mobile, web, smart TVs, and gaming consoles. We need to track everything — what people watch, when they pause, what they search for — and turn that into actionable insights."
They write on the whiteboard:
Context
- 150M subscribers globally
- 10M peak concurrent viewers
- Content library: 50K titles (movies, series, documentaries)
- Devices: Mobile (40%), Smart TV (35%), Web (15%), Gaming (10%)
Requirements
- Track all viewing events (play, pause, seek, complete)
- Real-time "Trending Now" dashboard (updates every minute)
- Content performance analytics for creators
- A/B test analysis for product experiments
- Historical viewing patterns for recommendations
"Take a few minutes to think about this, ask clarifying questions, and then walk me through your design. We have about 45 minutes."
The capstone designs analytics for a video service with 150M subscribers and 10M peak concurrent viewers, needing a Trending Now under 60 seconds old, creator analytics, daily A/B results and 2 years of history. The estimate: one event every 10 seconds per viewer is 1M events/s, ~37B a day (plan for 50B, 70% heartbeats), 15TB a day raw or 3TB compressed. Regional collectors batch into Kafka (playback keyed by user_id, 7-day retention); Flink computes trending over 1-hour windows sliding every minute into ClickHouse, while Spark feeds BigQuery for daily ETL, late data and A/B analysis. The hard parts: late mobile events buffered in SQLite (5-minute watermark, then T+1 and T+7 jobs), trending by unique viewers normalized by category, rollups and caches for creator dashboards, and a 10x plan that samples heartbeats so 10x traffic costs about 3 to 4x.
Phase 1: Requirements Clarification (5 minutes)#
Before diving in, you take a breath and start asking questions.
Your Questions#
"This is a great problem. Before I start designing, I'd like to clarify a few requirements. First, what's the expected event volume? How many events per second should we plan for?"
"At peak, we see about 10 million concurrent viewers. Each viewer generates roughly 1 event every 10 seconds on average — heartbeats, seeks, quality changes. So peak is around 1 million events per second."
"That's significant scale. For the 'Trending Now' dashboard, what's the freshness requirement? You mentioned updates every minute — is that the business requirement or current state?"
"Business wants it under 1 minute. Ideally 30 seconds or less. We compete with social media for attention, so trending content needs to surface fast."
"Understood. For historical analytics and recommendations, what's the retention requirement? How far back do we need to query?"
"We need at least 2 years of viewing history for recommendations. For operational analytics, 90 days of detailed data is sufficient. Aggregated data we keep indefinitely."
"For the device mix you mentioned — mobile is 40%. Mobile apps often have connectivity issues. What's an acceptable lateness for events from mobile devices?"
"Good catch. We see mobile events arriving anywhere from real-time to several hours late when users were on a plane or subway. We need to handle that gracefully."
"One more question — for A/B testing, how many concurrent experiments do you typically run, and what's the analysis latency requirement?"
"We run 50-100 experiments at any time. For analysis, daily aggregates are fine — we don't need real-time experiment results."
"Perfect. Let me summarize the requirements as I understand them."
Functional Requirements#
1. Event tracking
- Play, pause, resume, seek, stop, complete events
- Quality change events (buffering, resolution switches)
- Search and browse events
- Heartbeat events (every 10 seconds during playback)
2. Real-time dashboards
- "Trending Now" — updated every 30-60 seconds
- Live viewership counts per title
- Geographic distribution of viewers
- Quality metrics (buffering rate, errors)
3. Content analytics (for creators/studios)
- Total views, unique viewers, completion rate
- Audience demographics and geography
- Comparison to similar content
- Performance over time (daily/weekly/monthly)
4. A/B test analysis
- Track experiment assignments
- Calculate metrics per variant
- Statistical significance computation
- Daily refresh acceptable
5. Historical analytics
- 2 years of viewing data for recommendations
- 90 days detailed, older aggregated
- Ad-hoc queries by analysts
Non-Functional Requirements#
1. Scale
- Peak: 1M events/second (10M concurrent viewers)
- Daily: ~37B events (plan for 50B with headroom)
- Storage: ~15TB/day raw (~3TB compressed), ~11GB/day aggregated
2. Latency
- Event ingestion: < 1 second acknowledgment
- Trending dashboard: < 60 second freshness
- Historical queries: < 30 seconds for 90-day range
3. Reliability
- No event loss (at-least-once delivery)
- Late data handling for mobile (most within 24 hours; accepted up to 7 days for final numbers)
- 99.9% availability for dashboards
4. Data quality
- Deduplication of replayed events
- Handle out-of-order events correctly
- Clear distinction between real-time and final numbers
Before the numbers: how big is this?
10 million viewers at peak each send an event every 10 seconds. Assume the peak lasts 4 hours, 8 hours run at half the peak rate and the other 12 at a fifth of it. How many events is that a second at peak, and how many a day? At about 300 bytes an event, how much raw data a day, compressed 5×, and with 3 replicas?
One strong answer
- Peak: 10 million ÷ 10 s = 1 million events/s.
- A day: 14.4 billion + 14.4 billion + 8.6 billion ≈ 37 billion; plan for 50 billion with headroom.
- Bytes: 50 billion × 300 B = 15 TB/day raw, 3 TB compressed, 9 TB on disk with 3 replicas.
- So heartbeats, at 70% of the events, are where most of the cost will be.
Phase 2: Back of the Envelope Estimation (5 minutes)#
"Let me work through the numbers to validate scale assumptions."
Traffic Estimation#
- Peak concurrent viewers: 10,000,000 viewers
- Events per viewer: 1 event / 10 seconds
- Peak event rate: 1,000,000 events/second
- Daily pattern — Peak hours (4 hours): 1M events/sec × 4 hours = 14.4B events
- Daily pattern — High hours (8 hours): 500K events/sec × 8 hours = 14.4B events
- Daily pattern — Normal hours (12 hours): 200K events/sec × 12 hours = 8.6B events
- Total daily: 14.4B + 14.4B + 8.6B ≈ 37B events; plan for 50B/day (headroom + growth)
- Event breakdown (of the 50B planning figure) — Heartbeats (70%): 35B/day (high volume, low value)
- Event breakdown — Playback events (20%): 10B/day (play, pause, seek, complete)
- Event breakdown — Browse/Search (8%): 4B/day
- Event breakdown — Quality events (2%): 1B/day
Storage Estimation#
- Event metadata: 100 bytes (event_id, type, timestamp)
- User context: 50 bytes (user_id, device_id, session_id)
- Content context: 50 bytes (content_id, episode_id)
- Playback state: 50 bytes (position, duration, quality)
- Device info: 50 bytes (device_type, os, app_version)
- Total per event: ~300 bytes
- Daily raw: 50B events × 300 bytes = 15TB uncompressed
- With compression (5x): 3TB/day
- With replication (3x): 9TB/day effective
- 90-day retention: 810TB
- Aggregated — Hourly aggregates: ~10GB/day
- Aggregated — Daily aggregates: ~1GB/day
- Aggregated — 2-year retention: 11GB/day × 730 days ≈ 8TB total aggregates
Key Metrics Summary#
| Area | Item | Value |
|---|---|---|
| Traffic | Peak events | 1,000,000 /second |
| Daily events | 50 billion | |
| Monthly events | 1.5 trillion | |
| Storage | Daily raw (compressed) | 3 TB |
| 90-day raw | 270 TB | |
| Aggregates per day | ~11 GB | |
| Total system | ~1 PB | |
| Infrastructure (rough) | Kafka brokers | 50+ (for 1M events/sec) |
| Flink workers | 100+ (for streaming) | |
| ClickHouse nodes | 20+ (for real-time OLAP) | |
| Spark cluster | 500+ cores (for batch) |
Requirements and estimation
Q1/3 Peak concurrent viewers double to 20M, and each still sends about one event every 10 seconds. What peak ingest rate should you plan for?
Answer & explanation
Answer: C
Peak rate is viewers divided by the 10-second interval: 20M / 10 = 2M events/second, double today's 1M. 20M/second would mean every viewer sends one event per second, which is 10x the stated heartbeat rate.
Q2/3 The storage section says 90 days of raw events take 810TB, but the summary box says 270TB. What explains the gap?
Answer & explanation
Answer: A
50B events x 300 bytes is 15TB/day, or 3TB/day after 5x compression. 90 days of that is 270TB, and three replicas makes it 810TB on disk. Both figures are already compressed; uncompressed raw would be 15TB/day, about 1.35PB for 90 days before replication.
Q3/3 The interviewer says A/B results only need daily aggregates. How does that shape the design?
Answer & explanation
Answer: B
A daily freshness requirement lets the heavy statistics run in batch (Spark/BigQuery), which keeps the Flink jobs focused on trending and live counts. Late events still matter: dropping them would bias A/B results toward users who are always connected.
Sketch it first: one stream, four consumers
Trending must be fresher than a minute, creators look at months of history, A/B results are needed daily, and recommendations need 2 years of viewing. Which processing (stream or batch) and which store serves each? And what key do playback events use in Kafka?
One strong answer
- Trending and live counts: Flink windows into ClickHouse, for sub-second reads at high concurrency.
- Creator history and A/B tests: daily Spark jobs into BigQuery, for ad-hoc SQL over long history.
- Raw events: Parquet in S3, for reprocessing and ML. Redis caches dashboard results.
- Kafka: topics by domain (playback, browse, quality, dead letter), with playback keyed by
user_idso each viewer's events stay in order for sessionization.
Phase 3: High-Level Design (10 minutes)#
"Now let me sketch out the high-level architecture."
System Architecture#
%% title: High-level architecture
flowchart TD
subgraph CD["Client devices"]
direction TB
MOB@{ icon: "sd:phone", label: "Mobile 40%" }
TV@{ icon: "sd:tv", label: "Smart TV 35%" }
WEB@{ icon: "sd:browser", label: "Web 15%" }
GAM@{ icon: "sd:gamepad", label: "Gaming 10%" }
OTH["Other"]:::client
end
subgraph ING["Ingestion layer"]
CDN@{ icon: "sd:cdn", label: "CDN/Edge (Regional)" } --> GW["API Gateway (Regional)"]:::edge
GW --> COL["Collectors (Regional)"]:::core
end
CD --> CDN
subgraph MQ["Message queue · Kafka cluster"]
direction TB
T1[["events.playback"]]:::queue
T2[["events.browse"]]:::queue
T3[["events.quality"]]:::queue
T4[["events.dead_letter"]]:::queue
end
COL --> MQ
subgraph STR["Streaming layer"]
FL@{ icon: "tech:flink", label: "Flink Cluster<br/>Aggregations · Trending calc<br/>Sessionization · Late handling" }
end
subgraph BAT["Batch layer"]
SP@{ icon: "tech:spark", label: "Spark Cluster<br/>Daily ETL · Late data<br/>A/B analysis · ML features" }
end
MQ --> FL
MQ --> SP
subgraph SRV["Serving layer"]
CH@{ icon: "tech:clickhouse", label: "ClickHouse (Real-time)<br/>Trending · Live counts · Quality" }
BQ@{ icon: "tech:bigquery", label: "BigQuery (Historical)<br/>Ad-hoc Analytics · A/B tests" }
RD@{ icon: "tech:redis", label: "Redis (Cache)<br/>Dashboard Results · Hot data" }
S3@{ icon: "tech:s3", label: "S3 (Data Lake)<br/>Raw events · Parquet · Archives" }
end
FL --> SRV
SP --> SRV
subgraph QL["Query layer"]
API["Analytics API<br/>/trending (ClickHouse, 30s cache)<br/>/content/{id} (BigQuery, 5min cache)<br/>/experiments (BigQuery, 1hr cache)<br/>/explore (BigQuery, no cache)"]:::edge
end
CH --> API
BQ --> API
RD --> API
class MOB,TV,WEB,GAM client
class CDN edge
class FL core
class SP service
class CH,BQ,S3 store
class RD cacheComponent Breakdown#
"Let me walk through each component..."
1. Ingestion Layer#
Purpose: Receive events from all client devices reliably at scale.
Key responsibilities:
- Accept events from 10M+ concurrent clients
- Validate event schema and reject malformed events
- Buffer events for reliability
- Route to appropriate Kafka topics
Technology choice: Regional API Gateways + Event Collectors because we need low-latency ingestion globally and can validate/enrich events close to users.
2. Message Queue (Kafka)#
Purpose: Durable, ordered event storage and distribution.
Key responsibilities:
- Buffer 1M+ events/second
- Retain events for replay (7 days)
- Distribute to streaming and batch consumers
- Handle backpressure gracefully
Topic design:
events.playback— Play, pause, seek, complete (partitioned by user_id)events.browse— Search, navigation eventsevents.quality— Buffering, errors, quality changesevents.dead_letter— Failed validations for inspection
3. Streaming Layer (Flink)#
Purpose: Real-time processing for dashboards and alerting.
Key responsibilities:
- Calculate trending content (1-minute windows)
- Maintain live viewer counts per title
- Detect quality issues in real-time
- Handle late data with watermarks
4. Batch Layer (Spark)#
Purpose: Accurate historical analytics and heavy computations.
Key responsibilities:
- Daily aggregation jobs (finalized numbers)
- Late data reprocessing
- A/B test statistical analysis
- ML feature generation
5. Serving Layer#
Purpose: Store processed data for different query patterns.
| Store | Use Case | Query Pattern |
|---|---|---|
| ClickHouse | Real-time dashboards | Sub-second, high concurrency |
| BigQuery | Historical analytics | Complex queries, ad-hoc |
| Redis | Dashboard caching | Millisecond lookups |
| S3 | Raw data lake | Batch processing, ML |
High-level architecture
Q1/3 Why does the serving layer use both ClickHouse and BigQuery instead of just one?
Answer & explanation
Answer: D
Each store is matched to an access pattern: trending and live counts need sub-second reads under heavy concurrency, while analysts and A/B work need flexible SQL over long history. Neither one backs up the other; raw events actually land in S3, not ClickHouse.
Q2/3
events.playbackis partitioned byuser_id. What does that buy you?Answer & explanation
Answer: A
Kafka only guarantees order within a partition. Keying by user keeps each viewer's sequence intact, which sessionization depends on. Grouping by title would need a
content_idkey, and it would create hot partitions for viral titles.Q3/3 The query API caches
/trendingfor 30 seconds and/experimentsfor an hour, but/exploregets no cache at all. Why?Answer & explanation
Answer: C
Caching pays off when many users repeat the same query. Each analyst's exploratory query is effectively unique, so storing results would cost memory for a near-zero hit rate. Freshness is not the reason: trending has the tightest freshness need, and it still gets a short cache.
Phase 4: Deep Dives (20 minutes)#
"Great high-level design. Let's dive deeper into a few areas. Start with the event schema and ingestion — how would you design the events?"
Deep Dive 1: Event Schema Design (Day 1 Concept)#
"Event schema design is critical. Bad schemas create debt that compounds over time. Let me show you what I'd propose."
The Problem#
Without careful design
- Different teams send different formats
- Schema changes break downstream consumers
- Missing fields cause analytics gaps
- No way to evolve schema safely
We need
- Consistent structure across all event types
- Forward and backward compatibility
- Rich enough for analytics, lean enough for volume
- Clear versioning for evolution
The Solution#
"I'd use an envelope pattern with a common header and type-specific payload."
{
// HEADER (common to all events)
"event_id": "evt_abc123", // UUID, idempotency key
"event_type": "playback.started", // domain.entity.action
"event_version": "2.1", // schema version
"event_time": "2024-01-15T14:30:00.123Z", // when it happened
"received_time": "2024-01-15T14:30:00.456Z", // when server got it
// CONTEXT (who/where)
"user_id": "usr_12345",
"device_id": "dev_67890",
"session_id": "sess_abcdef",
"device_type": "mobile", // mobile, tv, web, gaming
"app_version": "5.2.1",
"geo": {
"country": "US",
"region": "CA"
},
// PAYLOAD (event-specific)
"payload": {
"content_id": "mov_789",
"content_type": "movie",
"position_seconds": 0,
"duration_seconds": 7200,
"quality": "1080p",
"audio_language": "en",
"subtitle_language": "es"
},
// EXPERIMENTS (A/B test context)
"experiments": {
"new_player_ui": "variant_b",
"recommendation_algo": "control"
}
}Event Types#
# Event type hierarchy
EVENT_TYPES = {
# Playback events (highest volume)
"playback.started": "User started playing content",
"playback.paused": "User paused playback",
"playback.resumed": "User resumed from pause",
"playback.seeked": "User jumped to different position",
"playback.completed": "User finished content (>90% watched)",
"playback.abandoned": "User stopped before completion",
"playback.heartbeat": "Periodic signal during playback",
# Quality events
"quality.buffering_started": "Playback buffering began",
"quality.buffering_ended": "Playback resumed after buffer",
"quality.resolution_changed": "Video quality changed",
"quality.error": "Playback error occurred",
# Browse events
"browse.search": "User performed search",
"browse.page_viewed": "User viewed a page/row",
"browse.content_clicked": "User clicked on content tile",
"browse.content_added_list": "User added to My List",
# Session events
"session.started": "App opened / session began",
"session.ended": "App closed / session ended"
}Schema Evolution#
# Schema registry for safe evolution
class ViewingEventSchema:
"""
Schema evolution rules:
- SAFE: Add optional fields
- UNSAFE: Remove fields, change types, rename
"""
VERSION = "2.1"
# v1.0: Original schema
# v2.0: Added experiments field
# v2.1: Added subtitle_language to payload
REQUIRED_FIELDS = [
"event_id", "event_type", "event_time",
"user_id", "device_id", "payload"
]
OPTIONAL_FIELDS = [
"session_id", "geo", "experiments",
"received_time", "app_version"
]"How do you handle the 1 million events per second throughput?"
Deep Dive 2: High-Throughput Ingestion (Day 1 Concept)#
"At 1M events/second, we need a carefully designed ingestion pipeline."
Ingestion Architecture#
%% title: High-throughput ingestion · regional ingestion (5 regions)
flowchart TD
subgraph USE["US-EAST · each region handles ~200K events/sec"]
CDN@{ icon: "tech:cloudfront", label: "CDN (CloudFront)" }
GW["API Gateway (ALB + Lambda or ECS)<br/>TLS termination<br/>rate limiting per device<br/>request validation"]:::edge
EC["Event Collectors (20 instances)<br/>schema validation<br/>enrichment (geo, device info)<br/>batching (100 events or 100ms)<br/>async produce to Kafka"]:::core
RK@{ icon: "tech:kafka", label: "Regional Kafka Cluster<br/>50 partitions per topic<br/>replication factor 3<br/>7-day retention" }
CDN --> GW --> EC --> RK
end
RK -.->|"cross-region replication"| DL@{ icon: "sd:database", label: "Central data lake" }
class CDN edge
class RK queue
class DL storeImplementation#
# Ingestion service optimized for throughput
from dataclasses import dataclass
from typing import List, Dict
import asyncio
from datetime import datetime, timezone
@dataclass
class IngestionConfig:
batch_size: int = 100
batch_timeout_ms: int = 100
max_concurrent_batches: int = 50
kafka_linger_ms: int = 5
kafka_batch_size: int = 16384
class HighThroughputCollector:
"""
Event collector sized for ~10K events/second per instance
(20 instances × 5 regions ≈ 1M events/second at peak).
Key optimizations:
1. Async everywhere - no blocking
2. Batch events in the Kafka producer (linger_ms, batch_size)
3. Schema validation with compiled validators
4. Connection pooling for Kafka
"""
def __init__(self, config: IngestionConfig, kafka_producer, validator):
self.config = config
self.producer = kafka_producer
self.validator = validator
# Metrics
self.events_received = 0
self.events_validated = 0
self.events_produced = 0
self.validation_errors = 0
async def ingest(self, events: List[Dict]) -> Dict:
"""
Ingest a batch of events from client.
Acknowledges only after Kafka has the events (acks=all).
Acking before the produce completes would lose buffered events
if this collector crashed, breaking the "no event loss"
requirement; the client would never retry them.
"""
results = {"accepted": 0, "rejected": 0, "errors": []}
valid_events = []
for event in events:
self.events_received += 1
# Fast validation (compiled schema)
validation_result = self.validator.validate(event)
if not validation_result.is_valid:
results["rejected"] += 1
results["errors"].append({
"event_id": event.get("event_id"),
"error": validation_result.error
})
self.validation_errors += 1
continue
# Enrich event
valid_events.append(self._enrich(event))
self.events_validated += 1
# Produce and wait for broker acks before acknowledging the client
# (the Kafka producer still batches internally via linger_ms)
await self._produce_batch(valid_events)
results["accepted"] = len(valid_events)
return results
def _enrich(self, event: Dict) -> Dict:
"""Add server-side enrichments."""
event["received_time"] = datetime.now(timezone.utc).isoformat()
event["collector_id"] = self.collector_id
event["processing_region"] = self.region
# Add derived fields
if "geo" in event and "country" in event["geo"]:
event["geo"]["continent"] = self._country_to_continent(
event["geo"]["country"]
)
return event
async def _produce_batch(self, batch: List[Dict]):
"""Produce batch to Kafka and wait for the broker acks."""
futures = []
for event in batch:
topic = self._get_topic(event["event_type"])
# Partition by user_id for ordering
partition_key = event.get("user_id", event["event_id"])
# send() enqueues and returns a delivery future
futures.append(await self.producer.send(
topic=topic,
key=partition_key,
value=event
))
# Raises if any send failed, so the client gets an error and retries
await asyncio.gather(*futures)
self.events_produced += len(batch)
def _get_topic(self, event_type: str) -> str:
"""Route event to appropriate Kafka topic."""
if event_type.startswith("playback."):
return "events.playback"
elif event_type.startswith("quality."):
return "events.quality"
elif event_type.startswith("browse."):
return "events.browse"
else:
return "events.other"
def _country_to_continent(self, country: str) -> str:
"""Map country to continent."""
# Simplified mapping
mapping = {
"US": "NA", "CA": "NA", "MX": "NA",
"GB": "EU", "DE": "EU", "FR": "EU",
"JP": "AS", "KR": "AS", "IN": "AS",
# ... more mappings
}
return mapping.get(country, "OTHER")"Good. Now tell me about the streaming layer — how do you calculate trending content in real-time?"
Deep Dive 3: Real-Time Trending Calculation (Day 2 Concept)#
"The trending calculation is a classic streaming problem. We need to balance freshness with accuracy."
The Problem#
Naive approach (count plays in last hour)
- Favors long content (more heartbeats)
- Doesn't account for library size
- New releases always dominate
- Spikes from bots/fraud not detected
Better approach
- Weight by unique viewers, not events
- Normalize by content age and genre
- Compare to baseline (is this unusually popular?)
- Decay older activity (recent matters more)
Trending Algorithm#
# Streaming trending calculation with Flink
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
import math
@dataclass
class TrendingScore:
"""Trending score for a piece of content."""
content_id: str
score: float
unique_viewers_1h: int
unique_viewers_24h: int
velocity: float # Rate of change
percentile_rank: float
updated_at: datetime
class TrendingCalculator:
"""
Calculates trending scores using streaming data.
Algorithm:
1. Count unique viewers in sliding windows
2. Calculate velocity (change in viewership)
3. Normalize by content category baseline
4. Apply time decay for recency
5. Combine into final score
"""
def __init__(self):
# Viewer counts per content per window
self.viewers_1h: Dict[str, set] = {} # Last 1 hour
self.viewers_24h: Dict[str, set] = {} # Last 24 hours
# Historical baselines by category
self.category_baselines: Dict[str, float] = {}
# Previous scores for velocity calculation
self.previous_scores: Dict[str, float] = {}
def process_play_event(self, event: Dict):
"""
Process a playback event and update trending state.
"""
content_id = event["payload"]["content_id"]
user_id = event["user_id"]
# Add to viewer sets (HyperLogLog in production)
if content_id not in self.viewers_1h:
self.viewers_1h[content_id] = set()
self.viewers_24h[content_id] = set()
self.viewers_1h[content_id].add(user_id)
self.viewers_24h[content_id].add(user_id)
def calculate_trending_scores(
self,
content_metadata: Dict[str, Dict]
) -> list[TrendingScore]:
"""
Calculate trending scores for all content.
Called every 30-60 seconds.
"""
scores = []
for content_id, viewers_1h in self.viewers_1h.items():
if len(viewers_1h) < 10: # Minimum threshold
continue
metadata = content_metadata.get(content_id, {})
category = metadata.get("category", "unknown")
# Base score: unique viewers in last hour
base_score = len(viewers_1h)
# Velocity: how fast is viewership growing?
viewers_24h = len(self.viewers_24h.get(content_id, set()))
velocity = self._calculate_velocity(
content_id, base_score, viewers_24h
)
# Normalize by category baseline
baseline = self.category_baselines.get(category, 1000)
normalized_score = base_score / baseline
# Apply velocity boost (trending UP gets bonus)
velocity_multiplier = 1 + (velocity * 0.5) if velocity > 0 else 1
# Final score
final_score = normalized_score * velocity_multiplier
# Store for next velocity calculation
self.previous_scores[content_id] = base_score
scores.append(TrendingScore(
content_id=content_id,
score=final_score,
unique_viewers_1h=base_score,
unique_viewers_24h=viewers_24h,
velocity=velocity,
percentile_rank=0, # Calculated after sorting
updated_at=datetime.now(timezone.utc)
))
# Sort and assign percentile ranks
scores.sort(key=lambda x: x.score, reverse=True)
for i, score in enumerate(scores):
score.percentile_rank = 1 - (i / len(scores))
return scores
def _calculate_velocity(
self,
content_id: str,
current_1h: int,
current_24h: int
) -> float:
"""
Calculate velocity (rate of change) of viewership.
Positive = trending up, Negative = trending down
"""
previous = self.previous_scores.get(content_id, current_1h)
if previous == 0:
return 0
# Percentage change from previous calculation
change = (current_1h - previous) / previous
# Also factor in 1h vs 24h ratio
# If 1h viewers >> 24h/24 average, content is spiking
hourly_avg_24h = current_24h / 24
spike_factor = current_1h / hourly_avg_24h if hourly_avg_24h > 0 else 1
return change * spike_factor
def expire_old_data(self, window_cutoff: datetime):
"""
Remove old viewer data from windows.
In production, use Flink's windowing with watermarks.
"""
# Implementation would use event time windows
pass
# Flink job definition (pseudo-code)
FLINK_TRENDING_JOB = """
-- Flink SQL for trending calculation
-- Step 1: Deduplicate events by user per content per window
-- (1-hour window that slides every minute, so trending refreshes
-- every minute; a 1-hour TUMBLE would only emit once an hour)
CREATE VIEW unique_viewers AS
SELECT
content_id,
COUNT(DISTINCT user_id) as viewer_count,
HOP_END(event_time, INTERVAL '1' MINUTE, INTERVAL '1' HOUR) as window_end,
-- HOP_END is a plain timestamp, not a time attribute; HOP_ROWTIME is
HOP_ROWTIME(event_time, INTERVAL '1' MINUTE, INTERVAL '1' HOUR) as window_time
FROM playback_events
WHERE event_type = 'playback.started'
GROUP BY
content_id,
HOP(event_time, INTERVAL '1' MINUTE, INTERVAL '1' HOUR);
-- Step 2: Calculate velocity using LAG
CREATE VIEW trending_with_velocity AS
SELECT
content_id,
viewer_count,
window_end,
window_time,
viewer_count - LAG(viewer_count) OVER (
PARTITION BY content_id
ORDER BY window_time
) as velocity
FROM unique_viewers;
-- Step 3: Join with metadata and calculate final score
CREATE VIEW trending_scores AS
SELECT
t.content_id,
t.viewer_count,
t.velocity,
t.viewer_count / m.category_baseline *
(1 + GREATEST(t.velocity, 0) * 0.5) as trending_score,
t.window_end
FROM trending_with_velocity t
JOIN content_metadata m ON t.content_id = m.content_id;
"""Output to ClickHouse#
# Sink trending scores to ClickHouse for dashboards
class TrendingClickHouseSink:
"""
Writes trending scores to ClickHouse for dashboard queries.
"""
async def write_scores(self, scores: list[TrendingScore]):
"""
Write batch of trending scores.
Uses ReplacingMergeTree to handle updates.
"""
values = [
(
s.content_id,
s.score,
s.unique_viewers_1h,
s.unique_viewers_24h,
s.velocity,
s.percentile_rank,
s.updated_at
)
for s in scores
]
await self.client.execute(
"""
INSERT INTO trending_scores
(content_id, score, viewers_1h, viewers_24h,
velocity, percentile_rank, updated_at)
VALUES
""",
values
)
CLICKHOUSE_SCHEMA = """
CREATE TABLE trending_scores (
content_id String,
score Float64,
viewers_1h UInt32,
viewers_24h UInt32,
velocity Float64,
percentile_rank Float64,
updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY content_id;
-- Top 100 trending: a plain VIEW, evaluated at query time.
-- (A materialized view only sees each INSERT block, so ORDER BY ...
-- LIMIT 100 there would keep a per-batch top 100, not a global one.)
CREATE VIEW top_trending AS
SELECT *
FROM trending_scores FINAL -- collapse rows not yet merged by ReplacingMergeTree
ORDER BY score DESC
LIMIT 100;
""""How do you handle late data from mobile devices that were offline?"
Schema, ingestion and trending
Q1/3 The event envelope carries both
event_timeandreceived_time. What is each one for?Answer & explanation
Answer: B
Analytics should reflect when the viewing happened (event time). The gap between the two timestamps is what the late-data logic uses to route and track events. Windowing on
received_timewould put a flight's worth of offline viewing into the hour the phone reconnected.Q2/3 Under the schema evolution rules, which change can ship without breaking downstream consumers?
Answer & explanation
Answer: D
Adding optional fields is the safe move: old consumers ignore the new field and new consumers handle its absence. That is how v2.1 added
subtitle_language. Renames, type changes and removals all break readers that expect the old shape.Q3/3 Why does the trending score start from unique viewers in the last hour instead of a raw count of events?
Answer & explanation
Answer: C
A two-hour movie emits far more heartbeats than a short episode, and one bot can send many events. Counting distinct viewers measures how many people are actually watching. Unique counts are actually more expensive, which is why production uses HyperLogLog instead of exact sets.
Deep Dive 4: Late Data Handling (Day 4 Concept)#
"Late data is particularly challenging for a streaming service because mobile users often watch offline on planes or subways."
The Problem#
Scenario 1: Airplane mode viewing
- User downloads movie at airport (10:00 AM)
- Watches on 3-hour flight
- Events buffered on device
- Lands and syncs at 1:00 PM
- Events arrive 3 hours late
Scenario 2: Subway commute
- User starts episode at 8:00 AM
- Goes underground, loses connectivity
- Watches 30 minutes, events buffer
- Emerges at 8:45 AM, events sync
- Events arrive 45 minutes late
Scenario 3: App in background
- User pauses and switches apps
- App is killed by OS
- Events in memory lost?
- Need client-side persistence
Impact without handling
- Trending misses popular content
- Completion rates underreported
- Geographic data skewed to connected areas
- A/B test results biased toward always-connected users
Solution Architecture#
%% title: Late data handling architecture
flowchart TD
subgraph CS["Client-side"]
EB@{ icon: "tech:sqlite", label: "Event Buffer (SQLite on device)<br/>persist events immediately on generation<br/>retry sending on connectivity<br/>include original event_time (not send time)<br/>delete only after server acknowledgment" }
end
subgraph SS["Server-side"]
IL["Ingestion Layer<br/>accept events regardless of age<br/>tag with received_time for lateness tracking"]:::edge
FL@{ icon: "tech:flink", label: "Streaming (Flink)<br/>5-min watermark<br/>on-time results<br/>#quot;Provisional#quot;" }
LQ[["Late Events Queue<br/>batch process<br/>daily catchup<br/>#quot;Final#quot; numbers"]]:::queue
CH@{ icon: "tech:clickhouse", label: "ClickHouse<br/>(Real-time view)" }
BQ@{ icon: "tech:bigquery", label: "BigQuery<br/>(Final view)" }
end
EB --> IL
IL -->|"recent events → normal topics"| FL
IL -->|"late events (>5 min) → late_events topic"| LQ
FL --> CH
LQ --> BQ
class EB client
class FL core
class CH,BQ storeTrending counts unique viewers in a 1-hour window that slides every minute. The watermark trails the newest event time by 5 minutes, so window 10:00–11:00 closes once an 11:05 event is seen; an 11:02 event arriving then still counts, while a 10:52 event arriving after 11:05 is over 5 minutes late and goes to late_events. Events under 5 minutes late update the provisional real-time numbers in ClickHouse; events 45 minutes or 3 hours late wait for the T+1 preliminary job at 6 AM; events 24 hours or more late wait for the T+7 final job, and dashboards say which version each number is.
Implementation#
# Late data handling with versioned results
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from enum import Enum
class DataFreshness(Enum):
REALTIME = "realtime" # Streaming result, may change
PRELIMINARY = "preliminary" # T+1, most late data included
FINAL = "final" # T+7, official number
@dataclass
class ViewingMetric:
"""A metric with versioning for late data."""
content_id: str
date: str
metric_name: str
value: float
freshness: DataFreshness
computed_at: datetime
event_count: int
late_event_count: int
class LateDataAwareAggregator:
"""
Aggregates viewing metrics with late data handling.
Maintains multiple versions:
- Real-time: Updated every minute, provisional
- Preliminary: Updated T+1, includes most late data
- Final: Updated T+7, official numbers
"""
def __init__(self, metrics_store):
self.store = metrics_store
# Late data tracking
self.lateness_histogram: Dict[str, int] = {}
async def process_event(self, event: Dict):
"""
Process event and update appropriate metrics version.
"""
event_time = datetime.fromisoformat(event["event_time"])
received_time = datetime.fromisoformat(event["received_time"])
lateness = received_time - event_time
lateness_bucket = self._get_lateness_bucket(lateness)
self.lateness_histogram[lateness_bucket] = \
self.lateness_histogram.get(lateness_bucket, 0) + 1
event_date = event_time.date().isoformat()
# Determine which version to update
if lateness < timedelta(minutes=5):
# On-time: update real-time
await self._update_realtime(event, event_date)
elif lateness < timedelta(hours=24):
# Moderately late: queue for preliminary
await self._queue_for_preliminary(event, event_date)
else:
# Very late: queue for final processing
await self._queue_for_final(event, event_date)
async def _update_realtime(self, event: Dict, event_date: str):
"""Update real-time metrics (streaming)."""
content_id = event["payload"]["content_id"]
# Increment view count (starts only; heartbeats are not views,
# matching _compute_daily_metrics)
if event["event_type"] == "playback.started":
await self.store.increment(
content_id=content_id,
date=event_date,
metric="views",
freshness=DataFreshness.REALTIME
)
# Update completion if applicable
if event["event_type"] == "playback.completed":
await self.store.increment(
content_id=content_id,
date=event_date,
metric="completions",
freshness=DataFreshness.REALTIME
)
async def _queue_for_preliminary(self, event: Dict, event_date: str):
"""Queue late event for preliminary processing."""
await self.store.queue_late_event(
event=event,
target_freshness=DataFreshness.PRELIMINARY
)
async def _queue_for_final(self, event: Dict, event_date: str):
"""Queue very late event for final processing."""
await self.store.queue_late_event(
event=event,
target_freshness=DataFreshness.FINAL
)
async def run_preliminary_job(self, target_date: str):
"""
Run T+1 job to produce preliminary numbers.
Scheduled to run at 6 AM for previous day.
"""
# Read all events for target_date from data lake
events = await self.store.read_events_for_date(target_date)
# Also read late events that arrived after streaming cutoff
late_events = await self.store.read_late_events(
target_date=target_date,
max_lateness=timedelta(hours=30)
)
all_events = events + late_events
# Compute aggregates
metrics = self._compute_daily_metrics(all_events, target_date)
# Store as preliminary
for metric in metrics:
metric.freshness = DataFreshness.PRELIMINARY
await self.store.save_metric(metric)
async def run_final_job(self, target_date: str):
"""
Run T+7 job to produce final numbers.
Scheduled to run 7 days after target_date.
"""
# Read ALL events including very late ones
events = await self.store.read_events_for_date(
target_date,
include_very_late=True
)
# Compute final aggregates
metrics = self._compute_daily_metrics(events, target_date)
# Store as final (immutable after this)
for metric in metrics:
metric.freshness = DataFreshness.FINAL
await self.store.save_metric(metric)
def _compute_daily_metrics(
self,
events: List[Dict],
date: str
) -> List[ViewingMetric]:
"""Compute all metrics for a day."""
by_content: Dict[str, Dict] = {}
for event in events:
content_id = event["payload"]["content_id"]
if content_id not in by_content:
by_content[content_id] = {
"views": 0,
"unique_viewers": set(),
"completions": 0,
"total_watch_time": 0,
"event_count": 0,
"late_event_count": 0
}
stats = by_content[content_id]
stats["event_count"] += 1
# Check if late
event_time = datetime.fromisoformat(event["event_time"])
received_time = datetime.fromisoformat(event["received_time"])
if (received_time - event_time) > timedelta(minutes=5):
stats["late_event_count"] += 1
if event["event_type"] == "playback.started":
stats["views"] += 1
stats["unique_viewers"].add(event["user_id"])
if event["event_type"] == "playback.completed":
stats["completions"] += 1
if event["event_type"] == "playback.heartbeat":
stats["total_watch_time"] += 10 # 10 second heartbeats
# Convert to metrics
metrics = []
for content_id, stats in by_content.items():
metrics.extend([
ViewingMetric(
content_id=content_id,
date=date,
metric_name="views",
value=stats["views"],
freshness=DataFreshness.PRELIMINARY,
computed_at=datetime.now(timezone.utc),
event_count=stats["event_count"],
late_event_count=stats["late_event_count"]
),
ViewingMetric(
content_id=content_id,
date=date,
metric_name="unique_viewers",
value=len(stats["unique_viewers"]),
freshness=DataFreshness.PRELIMINARY,
computed_at=datetime.now(timezone.utc),
event_count=stats["event_count"],
late_event_count=stats["late_event_count"]
),
ViewingMetric(
content_id=content_id,
date=date,
metric_name="completion_rate",
value=stats["completions"] / stats["views"] if stats["views"] > 0 else 0,
freshness=DataFreshness.PRELIMINARY,
computed_at=datetime.now(timezone.utc),
event_count=stats["event_count"],
late_event_count=stats["late_event_count"]
)
])
return metrics
def _get_lateness_bucket(self, lateness: timedelta) -> str:
"""Bucket lateness for histogram."""
seconds = lateness.total_seconds()
if seconds < 60:
return "<1min"
elif seconds < 300:
return "1-5min"
elif seconds < 3600:
return "5-60min"
elif seconds < 86400:
return "1-24hr"
else:
return ">24hr""How would you design the data model for the content analytics that creators see?"
Deep Dive 5: Star Schema for Content Analytics (Day 3 Concept)#
"For content analytics, I'd use a classic star schema optimized for the queries creators care about."
Data Model#
%% title: Content analytics star schema
flowchart TD
FV[("fact_views<br/>───────────<br/>view_key · date_key · content_key<br/>viewer_key · device_key · geo_key<br/>views · unique_vwrs · watch_mins<br/>completions · avg_pct")]:::core
DC[("dim_content<br/>───────────<br/>content_key (PK) · content_id · title<br/>type (movie/series) · genre · rating<br/>release_date · duration_minutes<br/>studio · creator_id")]:::store
DD[("dim_date<br/>───────────<br/>date_key (PK) · date · day_of_week<br/>month · quarter · year<br/>is_weekend · is_holiday")]:::store
DV[("dim_viewer<br/>───────────<br/>viewer_key (PK) · age_group · gender<br/>country · subscription<br/>tenure_months · device_pref")]:::store
DDV[("dim_device<br/>───────────<br/>device_key (PK) · device_type · os<br/>app_version · screen_size")]:::store
DG[("dim_geo<br/>───────────<br/>geo_key (PK) · country · region<br/>city · timezone")]:::store
FV -->|"content_key"| DC
FV -->|"date_key"| DD
FV -->|"viewer_key"| DV
FV -->|"device_key"| DDV
FV -->|"geo_key"| DGSchema Implementation#
# Content analytics data model
BIGQUERY_SCHEMA = """
-- =============================================================================
-- Dimension Tables
-- =============================================================================
-- Content dimension (SCD Type 2 for metadata changes)
CREATE TABLE dim_content (
content_key INT64 NOT NULL,
content_id STRING NOT NULL,
title STRING NOT NULL,
content_type STRING NOT NULL, -- 'movie', 'series', 'documentary'
genre STRING,
maturity_rating STRING,
release_date DATE,
duration_minutes INT64,
studio STRING,
creator_id STRING,
-- SCD Type 2 fields
valid_from DATE NOT NULL,
valid_to DATE,
is_current BOOL NOT NULL,
-- Metadata
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- Date dimension (generated)
CREATE TABLE dim_date (
date_key INT64 NOT NULL, -- YYYYMMDD
date DATE NOT NULL,
day_of_week INT64,
day_name STRING,
month INT64,
month_name STRING,
quarter INT64,
year INT64,
is_weekend BOOL,
is_holiday BOOL
);
-- Viewer dimension (anonymized/bucketed for privacy)
CREATE TABLE dim_viewer (
viewer_key INT64 NOT NULL,
age_group STRING, -- '18-24', '25-34', etc.
gender STRING,
country STRING,
subscription_type STRING, -- 'basic', 'standard', 'premium'
tenure_months INT64,
preferred_device STRING
);
-- Device dimension
CREATE TABLE dim_device (
device_key INT64 NOT NULL,
device_type STRING, -- 'mobile', 'tv', 'web', 'gaming'
operating_system STRING,
app_version_major INT64,
screen_category STRING -- 'small', 'medium', 'large'
);
-- Geography dimension
CREATE TABLE dim_geo (
geo_key INT64 NOT NULL,
country STRING,
region STRING,
timezone STRING
);
-- =============================================================================
-- Fact Tables
-- =============================================================================
-- Daily viewing facts (grain: content × date × viewer_segment × device × geo)
CREATE TABLE fact_daily_views (
date_key INT64 NOT NULL,
content_key INT64 NOT NULL,
viewer_key INT64 NOT NULL,
device_key INT64 NOT NULL,
geo_key INT64 NOT NULL,
-- Measures
view_count INT64,
unique_viewers INT64, -- Approximate, using HLL
watch_minutes INT64,
completion_count INT64,
-- Derived measures
avg_completion_pct FLOAT64,
avg_watch_minutes FLOAT64,
-- Data quality
freshness STRING, -- 'realtime', 'preliminary', 'final'
late_event_pct FLOAT64,
-- Partition column: the viewing date (event time), not load time,
-- so late events land in the right partition
view_date DATE NOT NULL
)
PARTITION BY view_date
CLUSTER BY content_key, geo_key;
-- =============================================================================
-- Pre-Aggregated Rollup Tables
-- =============================================================================
-- Daily content summary (most common query)
CREATE TABLE agg_daily_content (
date DATE NOT NULL,
content_id STRING NOT NULL,
total_views INT64,
unique_viewers INT64, -- that day only
viewers_hll BYTES, -- HLL sketch: mergeable across days
watch_hours FLOAT64,
completions INT64,
completion_rate FLOAT64,
-- Breakdown by device
mobile_views INT64,
tv_views INT64,
web_views INT64,
-- Breakdown by top geos
us_views INT64,
uk_views INT64,
other_views INT64,
freshness STRING,
updated_at TIMESTAMP
)
PARTITION BY date
CLUSTER BY content_id;
-- Weekly content summary
CREATE TABLE agg_weekly_content (
week_start DATE NOT NULL,
content_id STRING NOT NULL,
total_views INT64,
unique_viewers INT64,
watch_hours FLOAT64,
avg_daily_views FLOAT64,
peak_day_views INT64,
freshness STRING,
updated_at TIMESTAMP
);
"""
# Query examples for creator dashboard
CREATOR_DASHBOARD_QUERIES = """
-- Query 1: Content performance summary (last 30 days)
-- Uses agg_daily_content rollup for speed
SELECT
c.title,
c.content_type,
SUM(a.total_views) as total_views,
-- Summing daily uniques would count a 5-day viewer 5 times
HLL_COUNT.MERGE(a.viewers_hll) as unique_viewers,
ROUND(SUM(a.watch_hours), 1) as watch_hours,
ROUND(SAFE_DIVIDE(SUM(a.completions), SUM(a.total_views)) * 100, 1) as completion_pct,
SUM(a.mobile_views) / SUM(a.total_views) * 100 as mobile_pct
FROM agg_daily_content a
JOIN dim_content c ON a.content_id = c.content_id
WHERE a.date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
AND c.creator_id = @creator_id
AND c.is_current = TRUE
GROUP BY c.title, c.content_type
ORDER BY total_views DESC;
-- Query 2: Daily trend for specific content
SELECT
a.date,
a.total_views,
a.unique_viewers,
a.completion_rate,
a.freshness
FROM agg_daily_content a
WHERE a.content_id = @content_id
AND a.date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
ORDER BY a.date;
-- Query 3: Audience breakdown (uses fact table for detail)
SELECT
v.age_group,
v.gender,
g.country,
-- Sum of daily uniques: a viewer active on several days counts
-- once per day (use HLL sketches for true uniques)
SUM(f.unique_viewers) as viewer_days,
AVG(f.avg_completion_pct) as avg_completion
FROM fact_daily_views f
JOIN dim_viewer v ON f.viewer_key = v.viewer_key
JOIN dim_geo g ON f.geo_key = g.geo_key
JOIN dim_content c ON f.content_key = c.content_key
WHERE c.content_id = @content_id
AND f.date_key >= @start_date_key
AND f.date_key <= @end_date_key
GROUP BY v.age_group, v.gender, g.country
ORDER BY viewer_days DESC;
-- Query 4: Comparison to similar content
WITH my_content AS (
SELECT
SUM(total_views) as views,
SAFE_DIVIDE(SUM(completions), SUM(total_views)) as completion_rate
FROM agg_daily_content
WHERE content_id = @content_id
AND date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
),
similar_content AS (
-- Average of per-title 30-day totals (same basis as my_content)
SELECT
AVG(views) as avg_views,
AVG(completion_rate) as avg_completion_rate
FROM (
SELECT
a.content_id,
SUM(a.total_views) as views,
SAFE_DIVIDE(SUM(a.completions), SUM(a.total_views)) as completion_rate
FROM agg_daily_content a
JOIN dim_content c ON a.content_id = c.content_id AND c.is_current
WHERE c.genre = @genre
AND c.content_type = @content_type
AND a.date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
GROUP BY a.content_id
)
)
SELECT
m.views,
m.completion_rate,
s.avg_views as category_avg_views,
s.avg_completion_rate as category_avg_completion,
m.views / s.avg_views as views_vs_category,
m.completion_rate / s.avg_completion_rate as completion_vs_category
FROM my_content m, similar_content s;
""""Good. How do you keep the queries fast for creators when there's so much data?"
Deep Dive 6: Query Optimization (Day 5 Concept)#
"Query optimization for creator analytics requires a multi-layered approach."
Optimization Strategy#
Layer 1: Pre-aggregation
agg_daily_content: Most common queries hit thisagg_weekly_content: Week-over-week comparisons- Refreshed: Daily at 6 AM (after preliminary job)
- Result: 90% of queries use rollups, not fact tables
Layer 2: Caching
- Redis: Dashboard results (5-minute TTL)
- CDN: Static dashboard assets
- Query cache: Repeated queries (content_id specific)
- Result: 80% cache hit rate on dashboards
Layer 3: Partitioning
- Partition by date (most queries filter by date range)
- Cluster by content_id (most queries filter by content)
- Result: Queries scan <1% of data
Layer 4: Materialized views
top_100_by_genre: Pre-computed rankingscreator_summary: Aggregated metrics per creator- Refreshed: Hourly
- Result: Instant response for common questions
Caching Implementation#
# Multi-layer caching for creator dashboard
from datetime import timedelta
class CreatorDashboardCache:
"""
Caching strategy for creator dashboard queries.
"""
def __init__(self, redis_client, bigquery_client):
self.redis = redis_client
self.bq = bigquery_client
# TTL configuration by query type
self.ttl_config = {
"summary_30d": timedelta(minutes=5), # Updates frequently
"daily_trend": timedelta(minutes=15), # Less frequent
"audience_breakdown": timedelta(hours=1), # Slow-changing
"comparison": timedelta(hours=1), # Slow-changing
}
async def get_content_summary(
self,
creator_id: str,
days: int = 30
) -> dict:
"""
Get content performance summary with caching.
"""
cache_key = f"creator:{creator_id}:summary:{days}d"
# Check cache
cached = await self.redis.get(cache_key)
if cached:
return {
"data": cached,
"cache_hit": True,
"freshness": "cached"
}
# Query BigQuery
result = await self._query_content_summary(creator_id, days)
# Cache result
ttl = self.ttl_config["summary_30d"]
await self.redis.setex(cache_key, int(ttl.total_seconds()), result)
return {
"data": result,
"cache_hit": False,
"freshness": result.get("freshness", "unknown")
}
async def get_daily_trend(
self,
content_id: str,
days: int = 90
) -> dict:
"""
Get daily viewing trend for content.
"""
cache_key = f"content:{content_id}:trend:{days}d"
cached = await self.redis.get(cache_key)
if cached:
return {"data": cached, "cache_hit": True}
result = await self._query_daily_trend(content_id, days)
ttl = self.ttl_config["daily_trend"]
await self.redis.setex(cache_key, int(ttl.total_seconds()), result)
return {"data": result, "cache_hit": False}
async def invalidate_for_content(self, content_id: str):
"""
Invalidate all caches for a content item.
Called when new data arrives.
"""
pattern = f"content:{content_id}:*"
# SCAN, not KEYS: KEYS is O(N) and blocks Redis while it runs
keys = [k async for k in self.redis.scan_iter(match=pattern)]
if keys:
await self.redis.unlink(*keys) # UNLINK frees memory off the main thread
async def warm_cache_for_creator(self, creator_id: str):
"""
Pre-warm cache for a creator's common queries.
Called when creator logs into dashboard.
"""
# Fire off all common queries in parallel
await asyncio.gather(
self.get_content_summary(creator_id, 30),
self.get_content_summary(creator_id, 7),
# Don't pre-warm content-specific queries
# (too many possible content_ids)
)Late data, star schema and query layer
Q1/3 A viewer watches offline on a 3-hour flight, and the events sync when the phone lands. How does
LateDataAwareAggregatortreat them?Answer & explanation
Answer: A
Lateness of 3 hours falls between the 5-minute streaming cutoff and 24 hours, so the events are queued for the preliminary T+1 job (and are also included in the final T+7 recompute). Nothing is dropped: the whole point of versioned results is to fold late data in later rather than lose it.
Q2/3 Why do
fact_daily_viewsandagg_daily_contenteach carry afreshnesscolumn ('realtime', 'preliminary', 'final')?Answer & explanation
Answer: C
The non-functional requirements call for a clear distinction between real-time and final numbers. Tagging each row lets a creator see that today's count may still go up. Partitioning is by date, and TTLs are set per query type in the cache layer, so neither depends on this column.
Q3/3 Most creator queries hit the
agg_daily_contentrollup, but the audience breakdown (age, gender, country) goes tofact_daily_views. Why?Answer & explanation
Answer: D
A rollup can only answer questions about the dimensions it kept.
agg_daily_contentkeeps just a few device and geo columns, so viewer demographics require the fact table'sviewer_keyjoin. The rollup is the faster path whenever it has the needed columns; that is why around 90% of queries use it.
At 10×, which lever matters most?
Traffic grows to 10 million events a second, 10× today. Collectors, partitions and Flink workers can all scale out. What one change cuts the most volume, and roughly how many events a day are left?
One strong answer
- Scale out the stateless parts: about 10× the collectors, partitions and workers.
- Sample heartbeats: at 10×, a day is ~500 billion events, 350 billion of them heartbeats. A 10% sample keeps 35 billion.
- What's left: 35 billion + 150 billion of other events ≈ 185 billion a day, about 3.7× today's 50 billion. That is why 10× the traffic can cost only 3-4× as much.
Phase 5: Scaling and Edge Cases (5 minutes)#
"How would this system scale to 10x the current load?"
Scaling Strategy#
"Let me walk through scaling each layer."
| Layer | Current | 10x |
|---|---|---|
| Ingestion layer | 20 collectors per region | 200 collectors per region OR use Kinesis/Kafka as first hop (auto-scaling) |
| Kafka | 50 partitions per topic | 500 partitions per topic |
| Streaming (Flink) | 100 workers | 1000 workers |
| Serving (ClickHouse) | 20 nodes | 100+ nodes OR switch to managed service |
Ingestion layer — strategy
- Horizontal scaling (stateless collectors)
- Regional Kafka clusters (reduce cross-region traffic)
- Client-side sampling for heartbeats (10% sample at 10x)
- Dedicated clusters for high-volume event types
Kafka — strategy
- More partitions = more parallelism
- Tiered storage (hot in Kafka, warm in S3)
- Consider separating heartbeats to dedicated cluster
- MirrorMaker 2 for cross-region replication
Streaming (Flink) — strategy
- Scale horizontally (Flink scales well)
- Increase parallelism per operator
- State backend: RocksDB with incremental checkpoints
- Consider sampling for trending (don't need every event)
- Key-based scaling (partition by user_id or content_id)
Serving (ClickHouse) — strategy
- Add more replicas for read scaling
- Sharding by content_id for write scaling
- More aggressive pre-aggregation
- Consider Druid for highest concurrency
Cost consideration at 10x scale
- Sampling heartbeats: 90% cost reduction on that event type
- Tiered storage: 50% storage cost reduction
- Pre-aggregation: 80% query cost reduction
- Regional processing: 30% egress cost reduction
- Estimated 10x traffic = 3-4x cost (with optimizations)
Edge Cases#
"What edge cases should we handle?"
"Several important ones..."
| # | Edge case | Problem | Solution |
|---|---|---|---|
| 1 | Viral content | Single title gets 10x normal traffic | Dedicated Kafka partition for hot content · Pre-computed trending (don't recalculate on every query) · Aggressive caching (1-second TTL for hot content) · Circuit breaker on per-content queries |
| 2 | New release spike | Major release causes traffic spike at specific time | Pre-scale infrastructure before release · Warm caches before release (predicted queries) · Feature flags to disable non-critical analytics · Graceful degradation (show cached, mark as stale) |
| 3 | Device clock skew | Device sends events with wrong timestamps | Keep client event_time for analytics, but correct it by the device's clock offset (client send time vs server received_time) when skew is detected · Reject events with future timestamps > 5 minutes · Accept late events up to 7 days old · Flag suspicious patterns for investigation |
| 4 | Bot/fraud traffic | Fake views to inflate metrics | Device fingerprinting · Behavioral analysis (impossible view patterns) · Rate limiting per device/account · Separate raw and validated metrics |
| 5 | GDPR deletion | User requests data deletion | Aggregated data is anonymized (OK to keep) · Raw events must be deleted · Daily deletion job processes requests · 30-day SLA for completion |
Scaling and edge cases
Q1/3 At 10x load, the design samples heartbeats at 10% on the client. Why pick heartbeats to sample?
Answer & explanation
Answer: B
Heartbeats dominate volume (35B of about 50B daily events) while each one carries little information, so sampling cuts the biggest cost with a small loss of accuracy. Play, complete and search events are rarer and each one matters, so those are kept in full.
Q2/3 A user files a GDPR deletion request. What does the design delete?
Answer & explanation
Answer: D
Raw events carry the user's identity and must go (via a daily deletion job within a 30-day SLA). Aggregates are already anonymized, so they can stay. Rebuilding every aggregate after each deletion would be ruinously expensive, and it isn't required for anonymized data.
Q3/3 A major premiere overloads the query layer. Which response matches the lesson's "graceful degradation"?
Answer & explanation
Answer: A
Degrading gracefully means keeping core views up with slightly stale data, labeled honestly, while shedding non-essential work. Bypassing the cache adds load at exactly the wrong moment, and pausing ingestion would stall the whole pipeline when the business most wants live numbers.
Phase 6: Monitoring and Operations#
"How would you monitor this system in production?"
Key Metrics#
"I'd monitor at multiple levels..."
| Area | Metric | Threshold |
|---|---|---|
| Ingestion health | Events/second (by region) | Target: 200K/region |
| Validation error rate | Alert if > 1% | |
| P99 ingestion latency | Alert if > 500ms | |
| Dead letter queue depth | Alert if > 10K | |
| Kafka health | Consumer lag (by topic) | Alert if > 1M events |
| Under-replicated partitions | Alert if > 0 | |
| Produce latency P99 | Alert if > 100ms | |
| Disk usage | Alert if > 80% | |
| Streaming health | Flink checkpoint duration | Alert if > 60s |
| Watermark lag | Alert if > 5 minutes | |
| Backpressure ratio | Alert if > 0.5 | |
| State size | Track growth rate | |
| Query health | Dashboard P95 latency | Target < 1s |
| Cache hit rate | Target > 80% | |
| Query error rate | Alert if > 0.1% | |
| Bytes scanned/query | Track for cost | |
| Data quality | Late data percentage | Track by device type |
| Missing events (gap detection) | Alert on gaps | |
| Schema validation failures | Alert if > 0.1% | |
| Duplicate event rate | Track for dedup health | |
| Business metrics | Trending freshness | Target < 60s |
| Real-time vs Final delta | Track accuracy | |
| Creator dashboard usage | Engagement tracking | |
| A/B test data completeness | Compare to expected |
Alerting Strategy#
CRITICAL — PagerDuty, wake someone up
- Event ingestion completely down
- Kafka cluster unhealthy
- Flink job failed and not recovering
- Dashboard error rate > 5%
- Data loss detected
HIGH — PagerDuty, business hours
- Consumer lag > 10 minutes
- Late data rate > 20%
- Query latency P99 > 10s
- Trending not updating > 5 minutes
MEDIUM — Slack alert
- Validation error rate elevated
- Cache hit rate dropped
- Single region ingestion issues
- Schema registry alerts
LOW — Dashboard only
- Cost anomalies
- Traffic pattern changes
- Performance trends
Operating the pipeline
Q1/3 Watermark lag climbs past 5 minutes while ingestion looks normal. What does that mean for dashboards?
Answer & explanation
Answer: B
The watermark advances only as fast as the slowest input. Until it passes a window's end, that window can't be finalised, so "real-time" views go stale.
Q2/3 Why does "under-replicated partitions > 0" deserve an alert?
Answer & explanation
Answer: B
Replication is the durability guarantee behind every acknowledged event. Running with fewer copies is a risk you want to fix before the next broker goes down.
Q3/3 What does tracking the "real-time vs final delta" tell you?
Answer & explanation
Answer: B
Streaming gives a fast estimate; the final numbers include late events and corrections. The gap is a direct measure of how accurate the real-time view is.
Interview Conclusion#
"Excellent work. You've covered event ingestion, streaming processing, data modeling, late data handling, and query optimization. You clearly understand the trade-offs involved. Any questions for me?"
"Thank you! I'd love to hear about the biggest operational challenges you've faced with the current analytics platform, and whether there are any aspects of my design that you think wouldn't work in your environment."
Summary: Week 8 Concepts Applied#
Day 1: Event Ingestion at Scale#
| Concept | Application |
|---|---|
| Event schema design | Envelope pattern with versioned payloads |
| Schema evolution | Backward-compatible changes with version field |
| Validation pipeline | Fast validation before Kafka produce |
| Throughput optimization | Batching, async produces, regional collectors |
| Dead letter queues | Route failed events for analysis |
Day 2: Streaming vs Batch Processing#
| Concept | Application |
|---|---|
| Lambda architecture | Streaming (Flink) for real-time, Batch (Spark) for accuracy |
| Watermarks | 5-minute out-of-orderness bound for streaming |
| Window aggregations | 1-hour windows sliding every minute for trending |
| Stateful processing | HyperLogLog for unique viewer counts |
| Kappa consideration | Could simplify if reprocessing time acceptable |
Day 3: Data Modeling for Analytics#
| Concept | Application |
|---|---|
| Star schema | fact_daily_views with 5 dimension tables |
| SCD Type 2 | Content dimension for metadata changes |
| Pre-aggregation | agg_daily_content, agg_weekly_content rollups |
| Partitioning | By date with clustering on content_id |
| Grain definition | Daily × content × viewer_segment × device × geo |
Day 4: Late-Arriving Data#
| Concept | Application |
|---|---|
| Event time vs processing time | Client event_time for analytics, server received_time for lateness |
| Watermark strategies | 5-minute streaming, 24-hour preliminary, 7-day final |
| Versioned results | Real-time, Preliminary (T+1), Final (T+7) |
| Reprocessing jobs | Daily preliminary job, weekly final job |
| Client-side buffering | SQLite persistence on mobile devices |
Day 5: Query Layer and Optimization#
| Concept | Application |
|---|---|
| OLAP database selection | ClickHouse for real-time, BigQuery for historical |
| Multi-layer caching | Redis for dashboard results, CDN for assets |
| Pre-aggregation strategy | Daily/weekly rollups for common queries |
| Multi-tenancy | Creator-specific caches, query isolation |
| Cost optimization | Sampling heartbeats, tiered storage, rollups |
Day 6: Counting at Scale#
| Concept | Application |
|---|---|
| HyperLogLog | Approximate unique_viewers per title and window, from sketches that merge across partitions |
| Top-K over a window | "Trending Now" ranked by unique viewers in the last hour |
Code Patterns Demonstrated#
- 1. High-throughput ingestion
- Async batched produces
- Schema validation with compiled validators
- Acknowledge only after Kafka acks (no fire-and-forget)
- 2. Streaming aggregation
- Windowed unique counts (HyperLogLog)
- Velocity calculation for trending
- State management with cleanup
- 3. Late data versioning
- Multiple freshness levels
- Scheduled reprocessing jobs
- Lateness histogram tracking
- 4. Star schema modeling
- Fact and dimension tables
- Pre-aggregated rollups
- Efficient query patterns
- 5. Query caching
- TTL based on query type
- Cache warming on login
- Invalidation on data update
Self-Assessment Checklist#
After studying this capstone, you should be able to:
- Design event schemas that evolve safely at billion-event scale
- Build high-throughput ingestion pipelines with validation and routing
- Implement real-time aggregations with windowing and watermarks
- Calculate trending scores with velocity and normalization
- Handle late-arriving data from offline mobile devices
- Implement versioned results (real-time, preliminary, final)
- Design star schemas for analytics workloads
- Create pre-aggregation strategies for common query patterns
- Implement multi-layer caching for dashboards
- Scale analytics systems 10x with cost efficiency
- Monitor data pipelines with appropriate alerting
- Discuss trade-offs between freshness, accuracy, and cost
This capstone integrates all concepts from Week 8: Analytics Pipeline of the System Design Mastery Series.
Total Week 8 Content: Preview + 5 Daily Documents + Capstone = ~14,000 lines
this lesson in shorts · 2
play them all →One idea from this lesson in about a minute each, made for your phone.
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
Keystone Real-time Stream Processing Platform
2018- What
- Netflix's platform for producing, routing and processing event streams.
- Key idea
- Keystone offers a Kafka-based data pipeline that routes events to sinks per user config plus self-serve stream processing, handling trillions of events a day.
- Why here
- The capstone's ingestion and processing layers at the scale of the service the capstone imitates.
Building Scalable Real Time Analytics, Alerting and Anomaly Detection Architecture at Dream11
2019- What
- How Dream11 tracks contest joins, payment gateway health and campaigns in real time.
- Key idea
- Debezium and Kafka Connect pull data from transactional databases into Kafka, KSQL enriches and joins streams, and sinks feed real-time stores and alerts.
- Why here
- The capstone's real-time trending and alerting path, built from CDC plus stream SQL.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

