~learn.
?

Day 0241 minPreface+60 XP

Streaming vs Batch Processing.

System Design Mastery Series — Analytics Pipeline Week

4 stages18 code

watch it first · newThe whole lesson in a 20-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Yesterday, we built an event ingestion pipeline that reliably captures 100M+ events per day into Kafka. Now we have a firehose of data.

The processing dilemma
Events are flowing into Kafka at 1,000+/second (100M a day averages ~1,150/second)
  • Product views
  • Searches
  • Cart updates
  • Orders
  • Payments
Business asks three questions
Question Need Tolerance
1: "What's our revenue RIGHT NOW?" Real-time, updates every second Can be 1-2% off
2: "What was our revenue YESTERDAY?" Exact number for finance Must be 100% accurate
3: "What's trending THIS HOUR?" Near real-time, updates every minute Some lag is okay

One architecture cannot optimally serve all three. This is the streaming vs batch dilemma.

Today, we'll learn when to stream, when to batch, and how to do both.

ExplainerThe whole lesson: streaming vs batch processing

Events reach Kafka at over 1,000 a second, and the business asks for revenue right now (1-2% off is fine), this hour, and yesterday (100% exact). Batch (Spark) processes large chunks: hours of latency but exact and cheaper; streaming (Flink) processes every event: seconds of latency but approximate and always on. Lambda runs both and merges them at query time, with the same logic in two codebases; Kappa streams only and reprocesses by replay (1B events at 100K/s, about 3 hours; a year of this 100M-a-day pipeline, about 4 days); petabytes and batch ML favour Lambda. The pitfalls: watermarks (latest event time minus 5 minutes) decide when a window fires, late events need allowed lateness or a side output, state needs a TTL, jobs need checkpoints, batch appends duplicate on retry, and skewed keys need salting.

Part I · Stage 1 of 4 · 15 min

Part I: Foundations#

Chapter 1: The Two Worlds of Data Processing#

1.1 Batch Processing: The Original Approach#

Process data in large, discrete chunks:

DiagramBatch processing timeline
%% title: Batch processing timeline
flowchart TD
  EV[["Events: ●──●──●──●──●──●──●──●"]]:::queue
  EV -->|"Batch 1"| PR["Process (Spark)"]:::core
  PR --> OUT[("Output (Table)")]:::store
  • Latency: Hours (wait for batch to complete)
  • Accuracy: High (all data processed together)
  • Complexity: Lower (simpler programming model)
When to use batch
  • Historical analysis ("last year's trends")
  • Machine learning training
  • End-of-day financial reconciliation
  • Data warehouse loading
  • Complex aggregations across large datasets

1.2 Stream Processing: The Real-Time Approach#

Process data continuously as it arrives:

DiagramStream processing timeline
%% title: Stream processing timeline
flowchart TD
  EV[["Events: ●──●──●──●──●──●──●──●"]]:::queue
  EV -->|"every event, as it arrives"| SP
  subgraph SP["Stream Processor (Flink)"]
    direction TB
    S1[("State")]:::cache
    S2[("State")]:::cache
    S3[("State")]:::cache
    S4[("State")]:::cache
    PK["(per-key state)"]:::note
  end
  SP -->|"continuous output"| RV[("Real-time Views")]:::store
  • Latency: Milliseconds to seconds
  • Accuracy: Approximate (may miss late data)
  • Complexity: Higher (state management, exactly-once)
When to use streaming
  • Real-time dashboards
  • Fraud detection (immediate action needed)
  • Live recommendations
  • Alerting and monitoring
  • Session analytics
Your turn · checkpoint 1not answered

Three questions, three answers?

The business asks for revenue right now (can be 1-2% off), yesterday's exact revenue for finance, and what's trending this hour (updated every minute). For each, would you stream or batch, and why can't one choice serve all three?

Think it through, or jot it down. Then compare.

One strong answer

  • Revenue right now: streaming. Seconds of latency, and being slightly off is acceptable.
  • Yesterday for finance: batch. It processes the whole day together, late events included, so it can be exact.
  • Trending this hour: streaming with one-minute windows. Some lag is fine, an hours-long batch delay is not.
  • So one system would be either too slow for the first and third or too approximate for the second. That is why most systems end up with both.

1.3 The Trade-offs#

Streaming vs batch: the fundamental trade-off
Streaming Batch
Latency Seconds Hours
Throughput Lower Higher
Accuracy Approximate* Exact
Late data Complex Natural
State In-memory / local RocksDB Disk-based
Cost Higher (always-on) Lower (periodic)
Complexity Higher Lower
Recovery Checkpoints Re-run job

* Approximate because late-arriving events may be missed

The truth: Most systems need BOTH streaming AND batch. The question is: how do you combine them?

Chapter 2: Lambda Architecture#

2.1 The Original Hybrid Approach#

Nathan Marz's original design (2011): "Have both batch and streaming, merge results at query time"

DiagramLambda architecture
%% title: Lambda architecture
%% steps:
%% EV --> SJ, EV --> FS: Every event from Kafka goes to both layers
%% SJ --> BV: The batch layer's Spark job builds the daily batch views
%% FS --> RV: The speed layer's Flink stream keeps real-time views fresh
%% BV --> SV, RV --> SV: The serving layer merges batch and real-time views to answer queries
flowchart TD
  EV@{ icon: "tech:kafka", label: "Events (Kafka)" }
  subgraph BL["Batch layer"]
    SJ@{ icon: "tech:spark", label: "Spark Job" } --> BV@{ icon: "sd:database", label: "Batch Views (daily)" }
  end
  subgraph SPL["Speed layer"]
    FS@{ icon: "tech:flink", label: "Flink Stream" } --> RV@{ icon: "sd:cache", label: "Real-time Views" }
  end
  EV --> SJ
  EV --> FS
  BV --> SV["Serving layer<br/>(Merge batch + real)"]:::hot
  RV --> SV
  class EV queue
  class SJ service
  class FS core
  class BV store
  class RV cache
  1. Every event from Kafka goes to both layers
  2. The batch layer's Spark job builds the daily batch views
  3. The speed layer's Flink stream keeps real-time views fresh
  4. The serving layer merges batch and real-time views to answer queries

How it works:

  1. Batch layer: Processes all historical data, produces accurate views
    • Runs periodically (hourly/daily)
    • Overwrites previous batch views
    • Source of truth for accuracy
  2. Speed layer: Processes only recent data in real-time
    • Fills the gap since last batch
    • Approximate but current
    • Discarded when batch catches up
  3. Serving layer: Merges both at query time
    • Batch view (yesterday's totals) + Speed view (today so far)
    • Query: batch_result + realtime_result

2.2 Lambda Implementation#

pythonMetricResult · 205 lines
# architectures/lambda_architecture.py

"""
Lambda Architecture implementation.

Combines batch accuracy with streaming speed.
"""

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from abc import ABC, abstractmethod
from pyspark.sql import functions as F


@dataclass
class MetricResult:
    """Result from either batch or speed layer."""
    value: float
    timestamp: datetime
    is_batch: bool
    coverage_start: datetime
    coverage_end: datetime


class BatchLayer:
    """
    Batch processing layer.
    
    Processes all historical data with Spark.
    Runs periodically (e.g., hourly).
    """
    
    def __init__(self, spark_session, data_lake_path: str):
        self.spark = spark_session
        self.data_path = data_lake_path
        self.last_batch_time: Optional[datetime] = None
    
    def run_batch_job(self, end_time: datetime) -> Dict[str, MetricResult]:
        """
        Run batch aggregation up to end_time.
        
        This processes ALL data from the beginning,
        producing the authoritative "batch views".
        """
        
        # Read all raw events from data lake
        events_df = self.spark.read.parquet(
            f"{self.data_path}/events/"
        ).filter(
            f"event_time < '{end_time.isoformat()}'"
        )
        
        # Compute aggregations
        metrics = {}
        
        # Daily revenue
        daily_revenue = events_df.filter(
            "event_type = 'order.placed'"
        ).groupBy(
            F.to_date("event_time").alias("date")
        ).agg(
            F.sum("payload.total_amount").alias("revenue"),
            F.count("*").alias("order_count")
        ).collect()
        
        for row in daily_revenue:
            metrics[f"revenue_{row.date}"] = MetricResult(
                value=row.revenue,
                timestamp=end_time,
                is_batch=True,
                coverage_start=datetime.combine(row.date, datetime.min.time(), tzinfo=timezone.utc),
                # Today's batch only covers data up to end_time
                coverage_end=min(
                    datetime.combine(row.date, datetime.max.time(), tzinfo=timezone.utc), end_time
                )
            )
        
        # DAU (Daily Active Users)
        dau = events_df.groupBy(
            F.to_date("event_time").alias("date")
        ).agg(
            F.countDistinct("user_id").alias("dau")
        ).collect()
        
        for row in dau:
            metrics[f"dau_{row.date}"] = MetricResult(
                value=row.dau,
                timestamp=end_time,
                is_batch=True,
                coverage_start=datetime.combine(row.date, datetime.min.time(), tzinfo=timezone.utc),
                coverage_end=min(
                    datetime.combine(row.date, datetime.max.time(), tzinfo=timezone.utc), end_time
                )
            )
        
        self.last_batch_time = end_time
        
        return metrics
    
    def get_batch_view(self, metric_name: str) -> Optional[MetricResult]:
        """Get the latest batch view for a metric."""
        # In practice, read from batch view storage (e.g., Cassandra, Redis)
        pass


class SpeedLayer:
    """
    Speed/Streaming layer.
    
    Processes only events since last batch.
    Provides real-time updates.
    """
    
    def __init__(self, flink_env, kafka_source: str):
        self.env = flink_env
        self.kafka_source = kafka_source
        self.realtime_state: Dict[str, MetricResult] = {}
    
    def start_streaming(self, batch_watermark: datetime):
        """
        Start streaming from batch watermark.
        
        Only processes events AFTER the last batch completed.
        """
        
        # In Flink, this would be a DataStream job:
        # 
        # stream = env.from_source(KafkaSource(...), watermark_strategy, "events")
        #     .filter(lambda e: e.event_time > batch_watermark)
        #     .key_by(lambda e: e.event_type)
        #     .window(TumblingEventTimeWindows.of(Time.minutes(1)))
        #     .aggregate(MetricAggregator())
        #     .add_sink(RealtimeViewSink())
        
        pass
    
    def get_realtime_view(
        self,
        metric_name: str,
        since: datetime
    ) -> Optional[MetricResult]:
        """
        Get real-time aggregation since a timestamp.
        
        This fills the gap between last batch and now.
        """
        
        # In practice, query from real-time store (e.g., Druid, Redis)
        return self.realtime_state.get(metric_name)


class ServingLayer:
    """
    Serving layer that merges batch and speed results.
    """
    
    def __init__(self, batch_layer: BatchLayer, speed_layer: SpeedLayer):
        self.batch = batch_layer
        self.speed = speed_layer
    
    def query_metric(self, metric_name: str, date: datetime.date) -> float:
        """
        Query a metric by merging batch and real-time.
        
        Logic:
        1. Get batch view (covers up to last batch time)
        2. Get speed view (covers batch time to now)
        3. Combine appropriately
        """
        
        today = datetime.now(timezone.utc).date()
        
        if date < today:
            # Historical date: batch only (complete)
            batch_result = self.batch.get_batch_view(f"{metric_name}_{date}")
            return batch_result.value if batch_result else 0
        
        elif date == today:
            # Today: might need batch + realtime
            batch_result = self.batch.get_batch_view(f"{metric_name}_{date}")
            
            if batch_result:
                # Batch has partial day, add realtime since batch
                realtime_result = self.speed.get_realtime_view(
                    f"{metric_name}_{date}",
                    since=batch_result.coverage_end
                )
                
                batch_value = batch_result.value
                realtime_value = realtime_result.value if realtime_result else 0
                
                return batch_value + realtime_value
            
            else:
                # No batch yet today, realtime only
                realtime_result = self.speed.get_realtime_view(
                    f"{metric_name}_{date}",
                    since=datetime.combine(date, datetime.min.time(), tzinfo=timezone.utc)
                )
                return realtime_result.value if realtime_result else 0
        
        else:
            # Future date: no data
            return 0
Lambda architecture queries#
WorksheetQuery: "What's today's revenue?"
  • Batch View (ran at 12:00, covers 00:00 → 12:00): Revenue = $50,000
  • Speed View (12:00 to Now, 14:30): Revenue = $12,000
  • Total Revenue = $50,000 + $12,000 = $62,000
WorksheetQuery: "What was yesterday's revenue?"
  • Batch View for Yesterday: $150,000 (complete, authoritative)
  • Speed View: Not needed (batch has full day)
  • Total Revenue = $150,000
Your turn · checkpoint 2not answered

The refund rule that changed in one place

A Lambda team changes how refunds count toward revenue in the Spark batch job, and forgets to change the Flink speed job. What does the dashboard show today and tomorrow?

Think it through, or jot it down. Then compare.

One strong answer

  • Today: the serving layer adds the batch view (new rule) to the speed view (old rule), so one number mixes two definitions.
  • When the batch catches up: the speed results for that span are discarded and replaced, so the number jumps with no new data behind it.
  • The root cause is Lambda's big problem: the same logic lives in two codebases, and every change has to be made twice and kept identical.

2.3 Lambda Pros and Cons#

Lambda architecture: trade-offs
Pros Cons
Accuracy: Batch layer is source of truth Complexity: Two codebases (batch + streaming)
Latency: Speed layer provides real-time Consistency: Different results from same logic
Fault tolerance: Batch can recompute everything Maintenance: Two systems to maintain and debug
Late data: Handled in next batch run Divergence: Batch and streaming logic can drift
Mature: Well-understood, battle-tested Cost: Running two processing systems
The big problem

"Now you have two codebases doing the same thing."

  • Write aggregation in Spark (batch)
  • Write same aggregation in Flink (stream)
  • Hope they produce the same results
  • They often don't.
Quick check3 questions · +5 XP each

Batch, streaming and Lambda

  1. Q1/3 The live dashboard showed yesterday's revenue slightly lower than the batch number finance received this morning. What is the most likely reason?

    Answer & explanation

    Answer: C

    Streaming is approximate because late-arriving events may be missed after a window has been emitted, while batch processes all the data together. That is why batch is the source of truth for exact numbers like finance's.

  2. Q2/3 In a Lambda setup, the batch job ran at 12:00 and the speed layer has covered 12:00 onward. At 18:00 a new batch run completes covering everything up to 18:00. What happens to the speed layer's 12:00 to 18:00 results?

    Answer & explanation

    Answer: A

    The speed layer only fills the gap since the last batch and is discarded once batch catches up. Adding it on top of the new batch view would double count 12:00 to 18:00.

  3. Q3/3 Your Spark and Flink jobs both compute "revenue per hour", but they disagree even for hours with no late data. What is the root cause Lambda is known for?

    Answer & explanation

    Answer: D

    Lambda's big problem is two codebases doing the same thing: one in Spark, one in Flink. Every change must be made twice, and small differences in the implementations make the results diverge.

Chapter 3: Kappa Architecture#

3.1 Streaming Only#

Jay Kreps' simplification (2014): "Everything is a stream. Replay for reprocessing."

DiagramKappa architecture
%% title: Kappa architecture
flowchart TD
  LOG@{ icon: "tech:kafka", label: "Immutable Event Log (Kafka)<br/>all events stored<br/>with long retention" }
  LOG --> SP@{ icon: "tech:flink", label: "Stream Processor (Flink)<br/>single codebase<br/>for all processing" }
  SP --> SL@{ icon: "sd:database", label: "Serving Layer<br/>(Druid/ClickHouse)" }
  class LOG queue
  class SP core
  class SL store
  1. Start new stream processor reading from beginning
  2. Process all historical events
  3. Switch to new output when caught up
  4. Delete old output
DiagramReprocessing in Kappa
%% title: Reprocessing in Kappa
flowchart TD
  subgraph V1["V1 (current)"]
    P1["Processing live"]:::core --> O1[("Output V1")]:::store
    O1 --> D1["Delete V1"]:::bad
  end
  subgraph V2["V2 (new logic)"]
    P2["Start from beginning"]:::service -->|"Catching up..."| O2[("Output V2")]:::ok
  end
  P1 --> P2
  O2 -.->|"Switch (when caught up)"| D1

3.2 Kappa Implementation#

pythonProcessorVersion · 240 lines
# architectures/kappa_architecture.py

"""
Kappa Architecture implementation.

Single streaming codebase for all processing.
Reprocessing via replay from event log.
"""

from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Any, Optional, Callable
from enum import Enum
import asyncio


class ProcessorVersion(Enum):
    """Processor version for blue-green deployments."""
    V1 = "v1"
    V2 = "v2"


@dataclass
class ProcessorConfig:
    """Configuration for stream processor."""
    version: ProcessorVersion
    kafka_brokers: list
    input_topic: str
    output_table: str
    consumer_group: str
    start_offset: str = "earliest"  # or "latest" or timestamp


class KappaProcessor:
    """
    Unified stream processor for Kappa architecture.
    
    Key principle: One codebase, used for both
    real-time processing and historical reprocessing.
    """
    
    def __init__(self, config: ProcessorConfig):
        self.config = config
        self.aggregators: Dict[str, Callable] = {}
        self.state: Dict[str, Any] = {}
    
    def register_aggregation(
        self,
        name: str,
        aggregator: Callable
    ):
        """Register an aggregation function."""
        self.aggregators[name] = aggregator
    
    async def process_event(self, event: Dict) -> Dict[str, Any]:
        """
        Process a single event through all aggregators.
        
        This same code handles:
        1. Live events (normal operation)
        2. Historical events (reprocessing)
        """
        
        results = {}
        
        for name, aggregator in self.aggregators.items():
            result = await aggregator(event, self.state)
            if result:
                results[name] = result
        
        return results
    
    async def run(self):
        """
        Main processing loop.
        
        Consumes from Kafka, processes events, outputs to serving layer.
        """
        
        # In practice, use Flink/Kafka Streams
        # This is simplified pseudocode
        
        consumer = self._create_consumer()
        
        async for event in consumer:
            try:
                results = await self.process_event(event)
                
                for metric_name, value in results.items():
                    await self._write_to_serving_layer(
                        metric_name,
                        value,
                        event.get("event_time")
                    )
                    
            except Exception as e:
                await self._handle_error(event, e)
    
    def _create_consumer(self):
        """Create Kafka consumer with appropriate offset."""
        # Start from earliest for reprocessing
        # Start from latest for caught-up live processing
        pass
    
    async def _write_to_serving_layer(
        self,
        metric: str,
        value: Any,
        timestamp: datetime
    ):
        """Write result to serving layer (e.g., Druid, ClickHouse)."""
        pass
    
    async def _handle_error(self, event: Dict, error: Exception):
        """Handle processing error."""
        # Log error, send to DLQ, continue
        pass


class KappaOrchestrator:
    """
    Manages Kappa processor deployments and reprocessing.
    """
    
    def __init__(self):
        self.active_version: Optional[ProcessorVersion] = None
        self.processors: Dict[ProcessorVersion, KappaProcessor] = {}
    
    async def deploy_new_version(
        self,
        new_version: ProcessorVersion,
        processor: KappaProcessor
    ):
        """
        Deploy new processor version with reprocessing.
        
        Steps:
        1. Start new processor from beginning of log
        2. Wait for it to catch up to live
        3. Switch traffic to new version
        4. Stop old version
        """
        
        # Start new processor (reads from earliest)
        self.processors[new_version] = processor
        asyncio.create_task(processor.run())
        
        # Wait for catch-up
        await self._wait_for_catchup(processor)
        
        # Atomic switch
        old_version = self.active_version
        self.active_version = new_version
        
        # Stop old processor
        if old_version:
            await self._stop_processor(old_version)
    
    async def _wait_for_catchup(self, processor: KappaProcessor):
        """Wait for processor to catch up to live data."""
        
        while True:
            lag = await self._get_consumer_lag(processor)
            
            if lag < 1000:  # Less than 1000 events behind
                break
            
            await asyncio.sleep(10)
    
    async def _get_consumer_lag(self, processor: KappaProcessor) -> int:
        """Get consumer lag (how far behind live)."""
        # Query Kafka for consumer group lag
        pass
    
    async def _stop_processor(self, version: ProcessorVersion):
        """Gracefully stop a processor version."""
        pass


# =============================================================================
# Aggregation Functions
# =============================================================================

async def revenue_aggregator(
    event: Dict,
    state: Dict
) -> Optional[Dict]:
    """
    Aggregate revenue by day.
    
    Same code works for:
    - Live events: Updates today's revenue
    - Reprocessing: Recomputes historical revenue
    """
    
    if event.get("event_type") != "order.placed":
        return None
    
    event_time = datetime.fromisoformat(event["event_time"])
    date_key = event_time.strftime("%Y-%m-%d")
    amount = event.get("payload", {}).get("total_amount", 0)
    
    # Update state
    if date_key not in state:
        state[date_key] = {"revenue": 0, "orders": 0}
    
    state[date_key]["revenue"] += amount
    state[date_key]["orders"] += 1
    
    return {
        "date": date_key,
        "revenue": state[date_key]["revenue"],
        "orders": state[date_key]["orders"]
    }


async def active_users_aggregator(
    event: Dict,
    state: Dict
) -> Optional[Dict]:
    """Aggregate unique active users by day."""
    
    event_time = datetime.fromisoformat(event["event_time"])
    date_key = event_time.strftime("%Y-%m-%d")
    user_id = event.get("user_id")
    
    if not user_id:
        return None
    
    # Track unique users
    users_key = f"users_{date_key}"
    if users_key not in state:
        state[users_key] = set()
    
    state[users_key].add(user_id)
    
    return {
        "date": date_key,
        "dau": len(state[users_key])
    }
Your turn · checkpoint 3not answered

How long is a Kappa replay of this pipeline?

Yesterday's pipeline captures 100 million events a day. Under Kappa you keep a year of them in Kafka and replay at 100K events a second after a bug fix. How long does the replay take, and what would it take to finish in about 3 hours?

Think it through, or jot it down. Then compare.

One strong answer

  • History: 100 million × 365 ≈ 36.5 billion events.
  • Replay: 36.5 billion ÷ 100,000/s = 365,000 s ≈ 4.2 days.
  • To finish in ~3 hours (10,800 s) you would need about 34× the throughput, ~3.4 million events a second, or much less history.
  • So replay time grows linearly with history. "Can you reprocess fast enough?" is the question that decides between Kappa and Lambda.

3.3 Kappa Pros and Cons#

Kappa architecture: trade-offs
Pros Cons
Simplicity: One codebase for everything Reprocessing time: Slow for large history
Consistency: Same logic, same results Storage: Must keep all events in Kafka
Maintenance: One system to maintain Ordering: Must handle out-of-order carefully
Flexibility: Easy to reprocess with new logic State: Managing large state is complex
Modern: Fits streaming-first world Maturity: Fewer battle-tested examples
The big question: "Can you reprocess years of data fast enough?"
  • A smaller system: 1 year of data, 1B events in total
  • Reprocessing at 100K/sec
  • Reprocessing time: 10,000 seconds ≈ 3 hours
  • This week's pipeline: 100M events a day is 36.5B in a year, so the same replay takes ≈ 4.2 days (the checkpoint above)

For many systems, this is acceptable. For some, it's not.

ExplainerLambda vs Kappa architecture

Lambda answers today's revenue at 14:30 as the 12:00 batch view ($50,000) plus the speed layer ($12,000) = $62,000, and discards the speed results once the 18:00 batch run covers them, at the cost of two codebases. Kappa keeps one stream codebase and reprocesses by replaying the log: 1B events at 100K/s take about 3 hours before switching to the new output.

Chapter 4: Choosing Your Architecture#

4.1 Decision Framework#

Lambda vs Kappa: decision framework

Choose Lambda when

  • Historical data is huge (PB scale)
  • Batch processing is significantly cheaper
  • You already have Spark/Hadoop infrastructure
  • Complex ML training that's inherently batch
  • Regulatory requirements for batch reconciliation
  • Team is more experienced with batch

Choose Kappa when

  • Real-time is primary use case
  • Data volume allows reasonable reprocessing
  • Simplicity is valued over optimization
  • Team is experienced with streaming
  • Starting fresh (no legacy batch)
  • Events naturally fit streaming model

Consider hybrid when

  • Different use cases need different approaches
  • Some metrics need exact accuracy (batch)
  • Some metrics need real-time (stream)
  • Can afford complexity for optimization

4.2 Modern Reality: The Unified Approach#

The modern trend: unified batch + stream
Tools that unify both
  • Apache Beam: Write once, run on Flink or Spark
  • Apache Flink: Batch is just bounded streaming
  • Databricks: Delta Lake with streaming + batch
  • Snowflake: Snowpipe Streaming (rows) and Snowpipe (continuous file loading) + batch loading
The idea

Write your processing logic ONCE. The engine runs it as stream or batch as needed.

Example with Apache Beam:

python11 lines
# Same code for batch and stream (simplified)
events = pipeline | ReadFromKafka(consumer_config=cfg, topics=[topic])  # or ReadFromText(path)

revenue = (
    events
    | Filter(lambda e: e.type == 'order.placed')
    | WindowInto(FixedWindows(60 * 60))  # 1-hour windows (seconds)
    | CombineGlobally(sum_revenue)
)

revenue | WriteToBigQuery(table)
  • Run as streaming: continuous processing
  • Run as batch: process all files, terminate

This is where the industry is heading.

Quick check3 questions · +5 XP each

Kappa and choosing an architecture

  1. Q1/3 You run Kappa and discover a bug in the revenue logic that affected the last six months. How do you correct the historical numbers?

    Answer & explanation

    Answer: B

    In Kappa, reprocessing means replaying the immutable log through the new code and switching once it has caught up. Restarting from the latest offset only fixes new events, and a separate Spark job would bring back the second codebase Kappa exists to avoid.

  2. Q2/3 You keep 1 year of events, about 1B in total, in Kafka, and your stream processor can replay at 100K events/second. Roughly how long does a full reprocessing take?

    Answer & explanation

    Answer: C

    1B / 100K per second = 10,000 seconds, which is just under 3 hours. That is acceptable for many systems, but the time grows linearly with history, which is why the big Kappa question is whether you can replay fast enough.

  3. Q3/3 Your team has petabytes of history, existing Spark infrastructure, batch ML training, and a finance requirement for batch reconciliation. Which architecture fits best?

    Answer & explanation

    Answer: D

    Each of these signals is on the "choose Lambda" list: PB-scale history, existing Spark, batch ML, and regulatory reconciliation. Kappa's simplicity is real, but replaying petabytes would take far too long to be practical.

Part II · Stage 2 of 4 · 18 min

Part II: Implementation#

pythonEvent · 245 lines
# streaming/flink_processor.py

"""
Apache Flink stream processing implementation.

Flink concepts:
- DataStream: Unbounded stream of records
- Operators: Transformations (map, filter, keyBy, window)
- State: Per-key state for aggregations
- Checkpoints: Fault tolerance via periodic snapshots
- Watermarks: Tracking event time progress
"""

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, Iterator
from abc import ABC, abstractmethod


# =============================================================================
# Core Abstractions (Simplified Flink-like API)
# =============================================================================

@dataclass
class Event:
    """An event in the stream."""
    event_id: str
    event_type: str
    event_time: datetime
    user_id: Optional[str]
    payload: Dict[str, Any]


class DataStream:
    """
    Represents an unbounded stream of events.
    """
    
    def __init__(self, source: Iterator[Event]):
        self._source = source
        self._transformations = []
    
    def filter(self, predicate) -> 'DataStream':
        """Filter events based on predicate."""
        self._transformations.append(('filter', predicate))
        return self
    
    def map(self, mapper) -> 'DataStream':
        """Transform each event."""
        self._transformations.append(('map', mapper))
        return self
    
    def key_by(self, key_selector) -> 'KeyedStream':
        """Partition stream by key for stateful processing."""
        return KeyedStream(self, key_selector)


class KeyedStream:
    """
    Stream partitioned by key.
    
    Enables:
    - Stateful processing per key
    - Windowed aggregations per key
    - Exactly-once per-key guarantees
    """
    
    def __init__(self, stream: DataStream, key_selector):
        self.stream = stream
        self.key_selector = key_selector
    
    def window(self, window_assigner: 'WindowAssigner') -> 'WindowedStream':
        """Apply windowing to the keyed stream."""
        return WindowedStream(self, window_assigner)
    
    def process(self, processor: 'KeyedProcessFunction') -> DataStream:
        """Apply stateful processing function."""
        # Execute processor with per-key state
        pass


class WindowAssigner(ABC):
    """Assigns events to windows."""
    
    @abstractmethod
    def assign_windows(self, event: Event) -> list:
        pass


class TumblingEventTimeWindows(WindowAssigner):
    """
    Fixed-size, non-overlapping windows based on event time.
    
    Example: 1-hour windows
    [00:00-01:00), [01:00-02:00), [02:00-03:00), ...
    """
    
    def __init__(self, size: timedelta):
        self.size = size
    
    def assign_windows(self, event: Event) -> list:
        window_start = self._get_window_start(event.event_time)
        window_end = window_start + self.size
        return [(window_start, window_end)]
    
    def _get_window_start(self, timestamp: datetime) -> datetime:
        """Get the start of the window containing timestamp."""
        epoch = datetime(1970, 1, 1)
        elapsed = (timestamp - epoch).total_seconds()
        window_seconds = self.size.total_seconds()
        window_start_seconds = (elapsed // window_seconds) * window_seconds
        return epoch + timedelta(seconds=window_start_seconds)


class SlidingEventTimeWindows(WindowAssigner):
    """
    Overlapping windows that slide.
    
    Example: 1-hour windows, sliding every 15 minutes
    [00:00-01:00), [00:15-01:15), [00:30-01:30), ...
    """
    
    def __init__(self, size: timedelta, slide: timedelta):
        self.size = size
        self.slide = slide
    
    def assign_windows(self, event: Event) -> list:
        windows = []
        timestamp = event.event_time
        
        # Event belongs to multiple overlapping windows
        earliest_start = self._get_earliest_window_start(timestamp)
        
        start = earliest_start
        while start + self.size > timestamp:
            windows.append((start, start + self.size))
            start = start - self.slide
        
        return windows
    
    def _get_earliest_window_start(self, timestamp: datetime) -> datetime:
        """Get earliest window that contains this timestamp."""
        epoch = datetime(1970, 1, 1)
        elapsed = (timestamp - epoch).total_seconds()
        slide_seconds = self.slide.total_seconds()
        
        # Align to slide boundaries
        aligned = (elapsed // slide_seconds) * slide_seconds
        return epoch + timedelta(seconds=aligned)


class SessionWindows(WindowAssigner):
    """
    Windows based on activity sessions.
    
    A session ends when there's a gap > threshold.
    """
    
    def __init__(self, gap: timedelta):
        self.gap = gap
    
    def assign_windows(self, event: Event) -> list:
        # Sessions are merged dynamically as events arrive
        # Initial window is [event_time, event_time + gap)
        return [(event.event_time, event.event_time + self.gap)]


# =============================================================================
# Windowed Aggregation
# =============================================================================

class WindowedStream:
    """
    Keyed stream with windowing applied.
    """
    
    def __init__(self, keyed_stream: KeyedStream, assigner: WindowAssigner):
        self.keyed_stream = keyed_stream
        self.assigner = assigner
    
    def aggregate(self, aggregator: 'AggregateFunction') -> DataStream:
        """
        Apply aggregation function to each window.
        
        Flink incrementally aggregates as events arrive,
        not waiting for window to complete.
        """
        pass
    
    def reduce(self, reducer) -> DataStream:
        """Reduce events in window to single value."""
        pass


class AggregateFunction(ABC):
    """
    Incremental aggregation function.
    
    Maintains an accumulator that's updated per event.
    """
    
    @abstractmethod
    def create_accumulator(self) -> Any:
        """Create initial accumulator."""
        pass
    
    @abstractmethod
    def add(self, value: Any, accumulator: Any) -> Any:
        """Add value to accumulator."""
        pass
    
    @abstractmethod
    def get_result(self, accumulator: Any) -> Any:
        """Get result from accumulator."""
        pass
    
    @abstractmethod
    def merge(self, acc1: Any, acc2: Any) -> Any:
        """Merge two accumulators (for session windows)."""
        pass


class RevenueAggregator(AggregateFunction):
    """Aggregate revenue in a window."""
    
    def create_accumulator(self) -> Dict:
        return {"total": 0, "count": 0}
    
    def add(self, order: Dict, acc: Dict) -> Dict:
        acc["total"] += order.get("amount", 0)
        acc["count"] += 1
        return acc
    
    def get_result(self, acc: Dict) -> Dict:
        return {
            "revenue": acc["total"],
            "orders": acc["count"],
            "avg_order": acc["total"] / acc["count"] if acc["count"] > 0 else 0
        }
    
    def merge(self, acc1: Dict, acc2: Dict) -> Dict:
        return {
            "total": acc1["total"] + acc2["total"],
            "count": acc1["count"] + acc2["count"]
        }
Your turn · checkpoint 4not answered

When may the 9:00 window close?

Events arrive with event times 9:58, 9:55, 10:01, 9:59, 10:02. You aggregate into one-hour windows by event time. When may the [9:00-10:00) window emit its result, and what do you do with a 9:50 event that turns up afterwards?

Think it through, or jot it down. Then compare.

One strong answer

  • Not by the wall clock: a 9:xx event can arrive at any time, so reaching 10:00 proves nothing.
  • Use a watermark: max event time seen minus the out-of-orderness you allow. With 5 minutes, the window fires once an event stamped 10:05 or later arrives.
  • The 9:50 straggler is late: drop it, send it to a side output, keep the window open longer with allowed lateness, or update the result if the engine supports it.

5.2 Watermarks and Late Data#

pythonWatermark · 111 lines
# streaming/watermarks.py

"""
Watermarks for handling event time in streaming.

The Problem:
Events arrive out of order. When can we close a window?

Example:
  Real time    →  10:00  10:01  10:02  10:03  10:04
  Events arrive:  [A-9:58] [B-9:55] [C-10:01] [D-9:59] [E-10:02]
  
  When can we emit the [9:00-10:00) window?
  We might receive a 9:xx event at any time!

Solution: WATERMARKS
  "I believe all events with time <= W have arrived"
  When watermark passes window end, window can fire.
"""

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional


@dataclass
class Watermark:
    """
    Represents progress of event time.
    
    Watermark(t) means: "No more events with event_time <= t expected"
    """
    timestamp: datetime


class WatermarkStrategy:
    """
    Strategy for generating watermarks.
    """
    
    def __init__(self, max_out_of_orderness: timedelta):
        """
        Args:
            max_out_of_orderness: How late events can be.
                If events can be up to 5 minutes late,
                watermark = max_event_time - 5 minutes
        """
        self.max_out_of_orderness = max_out_of_orderness
        self.current_max_timestamp: Optional[datetime] = None
    
    def on_event(self, event_time: datetime) -> Optional[Watermark]:
        """Update watermark based on new event."""
        
        if self.current_max_timestamp is None:
            self.current_max_timestamp = event_time
        else:
            self.current_max_timestamp = max(
                self.current_max_timestamp,
                event_time
            )
        
        # Watermark = max seen - max out-of-orderness
        watermark_time = self.current_max_timestamp - self.max_out_of_orderness
        
        return Watermark(watermark_time)
    
    def get_current_watermark(self) -> Optional[Watermark]:
        """Get current watermark."""
        if self.current_max_timestamp:
            return Watermark(
                self.current_max_timestamp - self.max_out_of_orderness
            )
        return None


class LateDataHandler:
    """
    Handles events that arrive after their window closed.
    """
    
    def __init__(
        self,
        allowed_lateness: timedelta,
        late_output_tag: str = "late-data"
    ):
        """
        Args:
            allowed_lateness: How long to keep window state after closing
            late_output_tag: Tag for side output of late events
        """
        self.allowed_lateness = allowed_lateness
        self.late_output_tag = late_output_tag
    
    def is_late(
        self,
        event_time: datetime,
        watermark: datetime,
        window_end: datetime
    ) -> bool:
        """Check if event is late for its window."""
        
        # Window hasn't closed yet
        if watermark < window_end:
            return False
        
        # Window closed, but within allowed lateness
        if watermark < window_end + self.allowed_lateness:
            return False  # Can still update window
        
        # Too late, drop or side output
        return True
Watermarks visualized

Events arriving (real time on X, event time shown). Max lateness: 5 minutes.

Real time Event Event time Watermark after it
10:00 A 9:58 WM = 9:58 - 5min = 9:53
10:01 B 9:55 WM = 9:58 - 5min = 9:53 (B is old, doesn't advance)
10:02 C 10:01 WM = 10:01 - 5min = 9:56
10:03 D 9:59 WM = 10:01 - 5min = 9:56 (D is old)
10:04 E 10:02 WM = 10:02 - 5min = 9:57
10:05 F 10:04 WM = 10:04 - 5min = 9:59

Window [9:00-10:00) can fire when watermark >= 10:00. This happens... never in this example!

Only when we see an event with time >= 10:05 will the watermark reach 10:00 and the window fire.

Late data

If window [9:00-10:00) already fired, and then event with time 9:50 arrives, it's LATE.

Options:

  1. DROP: Ignore it (simplest, loses data)
  2. SIDE OUTPUT: Send to separate stream for special handling
  3. REFIRE: Update window output (complex, not always supported)
  4. ALLOWED LATENESS: Keep window state longer

5.3 State Management#

pythonStateBackend · 250 lines
# streaming/state_management.py

"""
State management in stream processing.

State is the memory of past events needed for current processing.

Examples:
- Running sum: Need previous sum
- Distinct count: Need set of seen IDs
- Session windows: Need events in current session
- Joins: Need buffer of events from both streams
"""

from dataclasses import dataclass
from typing import Dict, Any, Optional, Generic, TypeVar
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
import pickle


T = TypeVar('T')


class StateBackend(ABC):
    """
    Backend for storing stream processing state.
    
    Options:
    - Memory: Fast, limited by heap (restored from checkpoints)
    - RocksDB: Larger-than-memory, persistent locally
    - Remote (Redis, etc.): Shared across nodes
    """
    
    @abstractmethod
    def get(self, key: str) -> Optional[Any]:
        pass
    
    @abstractmethod
    def put(self, key: str, value: Any):
        pass
    
    @abstractmethod
    def delete(self, key: str):
        pass
    
    @abstractmethod
    def checkpoint(self) -> bytes:
        """Serialize all state for checkpointing."""
        pass
    
    @abstractmethod
    def restore(self, data: bytes):
        """Restore state from checkpoint."""
        pass


class InMemoryStateBackend(StateBackend):
    """Simple in-memory state backend."""
    
    def __init__(self):
        self._state: Dict[str, Any] = {}
    
    def get(self, key: str) -> Optional[Any]:
        return self._state.get(key)
    
    def put(self, key: str, value: Any):
        self._state[key] = value
    
    def delete(self, key: str):
        self._state.pop(key, None)
    
    def checkpoint(self) -> bytes:
        return pickle.dumps(self._state)
    
    def restore(self, data: bytes):
        self._state = pickle.loads(data)


class RocksDBStateBackend(StateBackend):
    """
    RocksDB-backed state for larger-than-memory state.
    
    Used in production Flink deployments.
    """
    
    def __init__(self, db_path: str):
        # In practice: import rocksdb; self.db = rocksdb.DB(db_path)
        self.db_path = db_path
        self._db = {}  # Simplified
    
    def get(self, key: str) -> Optional[Any]:
        value_bytes = self._db.get(key.encode())
        if value_bytes:
            return pickle.loads(value_bytes)
        return None
    
    def put(self, key: str, value: Any):
        self._db[key.encode()] = pickle.dumps(value)
    
    def delete(self, key: str):
        self._db.pop(key.encode(), None)
    
    def checkpoint(self) -> bytes:
        # In practice: Use RocksDB snapshots
        return pickle.dumps(dict(self._db))
    
    def restore(self, data: bytes):
        self._db = pickle.loads(data)


class ValueState(Generic[T]):
    """
    State holding a single value per key.
    
    Example: Current sum for a user
    """
    
    def __init__(self, backend: StateBackend, name: str, key: str):
        self.backend = backend
        self.state_key = f"{name}:{key}"
    
    def value(self) -> Optional[T]:
        return self.backend.get(self.state_key)
    
    def update(self, value: T):
        self.backend.put(self.state_key, value)
    
    def clear(self):
        self.backend.delete(self.state_key)


class ListState(Generic[T]):
    """
    State holding a list of values per key.
    
    Example: All events in a session
    """
    
    def __init__(self, backend: StateBackend, name: str, key: str):
        self.backend = backend
        self.state_key = f"{name}:{key}"
    
    def get(self) -> list:
        return self.backend.get(self.state_key) or []
    
    def add(self, value: T):
        current = self.get()
        current.append(value)
        self.backend.put(self.state_key, current)
    
    def clear(self):
        self.backend.delete(self.state_key)


class MapState(Generic[T]):
    """
    State holding a map of values per key.
    
    Example: Count per category for a user
    """
    
    def __init__(self, backend: StateBackend, name: str, key: str):
        self.backend = backend
        self.state_key = f"{name}:{key}"
    
    def get(self, map_key: str) -> Optional[T]:
        current = self.backend.get(self.state_key) or {}
        return current.get(map_key)
    
    def put(self, map_key: str, value: T):
        current = self.backend.get(self.state_key) or {}
        current[map_key] = value
        self.backend.put(self.state_key, current)
    
    def keys(self) -> list:
        current = self.backend.get(self.state_key) or {}
        return list(current.keys())
    
    def clear(self):
        self.backend.delete(self.state_key)


# =============================================================================
# Stateful Processing Example
# =============================================================================

class UserSessionProcessor:
    """
    Stateful processor that tracks user sessions.
    
    A session ends after 30 minutes of inactivity.
    """
    
    SESSION_TIMEOUT = timedelta(minutes=30)
    
    def __init__(self, state_backend: StateBackend):
        self.backend = state_backend
    
    def process_event(self, user_id: str, event: Dict) -> Optional[Dict]:
        """
        Process event, maintain session state, emit session on close.
        """
        
        # Get session state for this user
        session_state = ValueState[Dict](self.backend, "session", user_id)
        timer_state = ValueState[datetime](self.backend, "timer", user_id)
        
        event_time = datetime.fromisoformat(event["event_time"])
        current_session = session_state.value()
        
        result = None
        
        if current_session is None:
            # New session
            current_session = {
                "user_id": user_id,
                "start_time": event_time.isoformat(),
                "events": [],
                "event_count": 0
            }
        
        else:
            # Check if session timed out
            last_timer = timer_state.value()
            if last_timer and event_time > last_timer:
                # Session closed, emit it
                result = {
                    "type": "session_complete",
                    "session": current_session
                }
                
                # Start new session
                current_session = {
                    "user_id": user_id,
                    "start_time": event_time.isoformat(),
                    "events": [],
                    "event_count": 0
                }
        
        # Add event to session
        current_session["events"].append(event)
        current_session["event_count"] += 1
        current_session["last_event_time"] = event_time.isoformat()
        
        # Update state
        session_state.update(current_session)
        timer_state.update(event_time + self.SESSION_TIMEOUT)
        
        return result
Quick check3 questions · +5 XP each

Watermarks, windows and state

  1. Q1/3 Max out-of-orderness is 5 minutes, and the latest event time seen so far is 10:02. Has the [9:00-10:00) window fired yet?

    Answer & explanation

    Answer: A

    Watermark = max event time seen minus max out-of-orderness, so 10:02 - 5 min = 9:57. The window fires when the watermark reaches 10:00, which needs an event at 10:05 or later. Wall-clock time plays no part in event-time windows.

  2. Q2/3 You need "orders in the last hour", refreshed every 15 minutes. Which window assigner fits?

    Answer & explanation

    Answer: B

    Sliding windows overlap, so each event lands in several 1-hour windows and a new result is produced every 15 minutes. Tumbling 1-hour windows update only once per hour, and session windows group by user activity gaps, not fixed time spans.

  3. Q3/3 A job tracks distinct users per key, and its state has grown larger than the memory available on each node. Which state backend suits it?

    Answer & explanation

    Answer: D

    RocksDB is the production choice for state that does not fit in RAM, because it spills to local disk. Checkpointing more often does not help: in-memory state still has to fit in memory between checkpoints.

Chapter 6: Batch Processing with Spark#

6.1 Spark for Analytics#

pythonSparkConfig · 222 lines
# batch/spark_processor.py

"""
Apache Spark batch processing for analytics.

Spark concepts:
- DataFrame: Distributed table of data
- Transformations: Lazy operations (filter, map, join)
- Actions: Trigger computation (collect, write)
- Partitioning: How data is distributed
- Caching: Keep intermediate results in memory
"""

from dataclasses import dataclass
from datetime import datetime, date, timedelta, timezone
from typing import Dict, Any, List, Optional
import json
from pyspark.sql import functions as F


@dataclass
class SparkConfig:
    """Spark application configuration."""
    app_name: str
    master: str = "local[*]"  # or "yarn", "k8s://..."
    executor_memory: str = "4g"
    executor_cores: int = 4
    num_executors: int = 10


class AnalyticsBatchJob:
    """
    Batch analytics job using Spark.
    
    Processes historical data to compute accurate metrics.
    """
    
    def __init__(self, spark_session):
        self.spark = spark_session
    
    def compute_daily_metrics(
        self,
        events_path: str,
        output_path: str,
        target_date: date
    ) -> Dict[str, Any]:
        """
        Compute all daily metrics for a specific date.
        
        This is the batch layer of Lambda architecture.
        """
        
        # Read events for the day
        events = self.spark.read.parquet(events_path).filter(
            f"date(event_time) = '{target_date}'"
        )
        
        # Cache for multiple aggregations
        events.cache()
        
        metrics = {}
        
        # Revenue metrics
        revenue_row = events.filter(
            "event_type = 'order.placed'"
        ).agg(
            F.sum("payload.total_amount").alias("revenue"),
            F.count("*").alias("order_count")
        ).collect()[0]
        
        metrics["revenue"] = revenue_row["revenue"] or 0
        metrics["order_count"] = revenue_row["order_count"] or 0
        
        # User metrics
        user_metrics = events.agg(
            F.countDistinct("user_id").alias("dau")
        ).collect()[0]
        
        metrics["dau"] = user_metrics["dau"] or 0
        
        # Funnel metrics
        metrics["funnel"] = self._compute_funnel(events)
        
        # Product metrics
        metrics["top_products"] = self._compute_top_products(events)
        
        # Write results
        self._write_metrics(output_path, target_date, metrics)
        
        return metrics
    
    def _compute_funnel(self, events) -> Dict[str, int]:
        """Compute conversion funnel."""
        
        funnel = {}
        
        # View → Cart → Purchase funnel
        funnel["product_views"] = events.filter(
            "event_type = 'product.viewed'"
        ).select("user_id").distinct().count()
        
        funnel["add_to_cart"] = events.filter(
            "event_type = 'cart.updated' AND payload.action = 'add'"
        ).select("user_id").distinct().count()
        
        funnel["purchases"] = events.filter(
            "event_type = 'order.placed'"
        ).select("user_id").distinct().count()
        
        # Conversion rates
        if funnel["product_views"] > 0:
            funnel["view_to_cart_rate"] = (
                funnel["add_to_cart"] / funnel["product_views"]
            )
            funnel["view_to_purchase_rate"] = (
                funnel["purchases"] / funnel["product_views"]
            )
        
        if funnel["add_to_cart"] > 0:
            funnel["cart_to_purchase_rate"] = (
                funnel["purchases"] / funnel["add_to_cart"]
            )
        
        return funnel
    
    def _compute_top_products(self, events, limit: int = 100) -> List[Dict]:
        """Compute top products by various metrics."""
        
        is_view = F.col("event_type") == "product.viewed"
        is_order = F.col("event_type") == "order.placed"
        
        product_stats = events.filter(
            "event_type IN ('product.viewed', 'order.placed')"
        ).groupBy(
            F.col("payload.product_id").alias("product_id")
        ).agg(
            F.count(F.when(is_view, 1)).alias("view_count"),
            F.sum(F.when(is_order, F.col("payload.total_amount"))).alias("revenue")
        ).orderBy(
            F.desc("view_count")
        ).limit(limit).collect()
        
        return [
            {
                "product_id": row["product_id"],
                "view_count": row["view_count"],
                "revenue": row["revenue"] or 0
            }
            for row in product_stats
        ]
    
    def _write_metrics(
        self,
        output_path: str,
        target_date: date,
        metrics: Dict
    ):
        """Write metrics to output storage."""
        
        # Write to partitioned parquet
        self.spark.createDataFrame([{
            "date": target_date.isoformat(),
            "metrics": json.dumps(metrics),  # Mixed nested types → JSON
            "computed_at": datetime.now(timezone.utc).isoformat()
        }]).write.mode("overwrite").parquet(
            f"{output_path}/date={target_date}"
        )


class IncrementalBatchJob:
    """
    Incremental batch job that processes only new data.
    
    More efficient than full recomputation.
    """
    
    def __init__(self, spark_session):
        self.spark = spark_session
    
    def process_incremental(
        self,
        events_path: str,
        state_path: str,
        next_state_path: str,
        output_path: str,
        watermark: datetime
    ):
        """
        Process only events since last watermark.
        
        Merge with existing state for complete picture. The merged state
        goes to next_state_path: Spark can't overwrite state_path while
        it is still (lazily) reading from it.
        """
        
        # Read new events only
        new_events = self.spark.read.parquet(events_path).filter(
            f"event_time > '{watermark.isoformat()}'"
        )
        
        # Read existing aggregates
        existing = self.spark.read.parquet(state_path)
        
        # Compute incremental aggregates
        incremental = new_events.groupBy(
            F.to_date("event_time").alias("date")
        ).agg(
            F.sum("payload.total_amount").alias("revenue"),
            F.count("*").alias("event_count")
        )
        
        # Merge: Add incremental to existing (same column names)
        merged = existing.unionByName(incremental).groupBy("date").agg(
            F.sum("revenue").alias("revenue"),
            F.sum("event_count").alias("event_count")
        )
        
        # Write updated state to the next version (not the path being read)
        merged.write.mode("overwrite").parquet(next_state_path)
        
        # Also write to serving layer
        merged.write.mode("overwrite").parquet(output_path)

6.2 Spark Streaming#

pythonUnifiedAnalyticsProcessor · 125 lines
# batch/spark_streaming.py

"""
Spark Structured Streaming for unified batch + stream.

Structured Streaming treats streams as unbounded tables,
using the same DataFrame API as batch.
"""

from pyspark.sql import SparkSession
from pyspark.sql.functions import *
from pyspark.sql.types import *


class UnifiedAnalyticsProcessor:
    """
    Unified processor that works for both batch and streaming.
    
    Same code, different execution modes.
    """
    
    def __init__(self, spark: SparkSession):
        self.spark = spark
    
    def create_streaming_source(self, kafka_brokers: str, topic: str):
        """Create streaming DataFrame from Kafka."""
        
        return self.spark.readStream \
            .format("kafka") \
            .option("kafka.bootstrap.servers", kafka_brokers) \
            .option("subscribe", topic) \
            .option("startingOffsets", "latest") \
            .load() \
            .select(
                from_json(
                    col("value").cast("string"),
                    self._get_event_schema()
                ).alias("event")
            ).select("event.*")
    
    def create_batch_source(self, path: str):
        """Create batch DataFrame from files."""
        
        return self.spark.read.parquet(path)
    
    def compute_revenue_metrics(self, events_df):
        """
        Compute revenue metrics.
        
        Works identically for batch and streaming DataFrames!
        """
        
        return events_df \
            .filter(col("event_type") == "order.placed") \
            .withWatermark("event_time", "1 hour") \
            .groupBy(
                window("event_time", "1 hour"),
                "payload.category"
            ) \
            .agg(
                sum("payload.total_amount").alias("revenue"),
                count("*").alias("order_count"),
                avg("payload.total_amount").alias("avg_order_value")
            )
    
    def write_streaming(self, df, output_path: str, checkpoint_path: str):
        """Write streaming DataFrame with checkpointing."""
        
        return df.writeStream \
            .format("parquet") \
            .option("path", output_path) \
            .option("checkpointLocation", checkpoint_path) \
            .outputMode("append") \
            .trigger(processingTime="1 minute") \
            .start()
    
    def write_batch(self, df, output_path: str):
        """Write batch DataFrame."""
        
        df.withColumn("date", to_date(col("window.start"))) \
            .write \
            .mode("overwrite") \
            .partitionBy("date") \
            .parquet(output_path)
    
    def _get_event_schema(self):
        """Define event schema."""
        
        return StructType([
            StructField("event_id", StringType()),
            StructField("event_type", StringType()),
            StructField("event_time", TimestampType()),
            StructField("user_id", StringType()),
            StructField("payload", MapType(StringType(), StringType()))
        ])


# =============================================================================
# Usage Examples
# =============================================================================

USAGE_EXAMPLES = """
UNIFIED BATCH + STREAMING

# Same processing logic for both!

processor = UnifiedAnalyticsProcessor(spark)

# STREAMING MODE
stream_df = processor.create_streaming_source("kafka:9092", "events")
revenue = processor.compute_revenue_metrics(stream_df)
query = processor.write_streaming(revenue, "/output/stream", "/checkpoint")

# BATCH MODE  
batch_df = processor.create_batch_source("/data/events")
revenue = processor.compute_revenue_metrics(batch_df)
processor.write_batch(revenue, "/output/batch")


THE POWER:
- Same aggregation code
- Same business logic
- Different execution modes
- Consistent results
"""
Quick check3 questions · +5 XP each

Batch with Spark and unified APIs

  1. Q1/3 A mobile event arrives late, with an event_time earlier than the watermark used by the last IncrementalBatchJob run. What happens on the next run?

    Answer & explanation

    Answer: A

    The incremental job saves work by filtering on event_time > watermark, so a late event stamped before that point slips past every later run. A full recompute of that date with compute_daily_metrics would include it.

  2. Q2/3 compute_daily_metrics calls events.cache() right after reading the day's events. Why?

    Answer & explanation

    Answer: C

    Spark transformations are lazy, and every action (each collect or count) re-runs the lineage from the source. Caching keeps the filtered events in memory for the revenue, DAU, funnel and top-product aggregations.

  3. Q3/3 With Structured Streaming, compute_revenue_metrics runs unchanged on a streaming DataFrame and on a batch DataFrame. What problem does that solve?

    Answer & explanation

    Answer: D

    The same business logic runs in both execution modes, so batch and real-time results cannot drift apart the way separate Spark and Flink implementations do. Cost and late-data handling still differ between the modes.

Part III · Stage 3 of 4 · 3 min

Part III: Real-World Application#

Chapter 7: Case Studies#

7.1 Lambda to Kappa Transition (illustrative)#

A typical Lambda → Kappa migration

BEFORE (Lambda-ish)

  • Batch: Spark on HDFS, daily jobs
  • Streaming: Flink for real-time
  • Problem: Two codebases, divergent results
  • Pain: "Why is real-time different from batch?"

AFTER (Kappa-ish)

  • Single: Flink for everything
  • Kafka: Long retention for replay
  • Result: One codebase, consistent
  • Reprocess: Replay from Kafka

Typical learnings:

  1. Fewer "batch vs real-time" divergence bugs
  2. Simpler onboarding: one engine to learn
  3. Schema changes deploy once, not twice
  4. Cost can be slightly higher, often worth the simplicity

7.2 Lambda at Scale (illustrative)#

Why very large platforms keep a batch layer
Teams keep Lambda because
  • Historical data: Petabytes
  • Reprocessing: Would take days in Flink
  • ML training: Inherently batch
  • Cost: Batch on spot instances is cheaper
A typical approach
  • Streaming: Apache Flink for real-time
  • Batch: Apache Spark on YARN/Kubernetes
  • Storage: A table format such as Apache Hudi (which Uber created to make streamed data batch-readable)
Key insight

Lambda is kept not because teams want two codebases, but because the alternatives are worse at that scale.

At trillions of events:

  • Full Kappa replay: can take days
  • Batch recompute of a day: hours, on cheap spot capacity
  • Decision: Lambda (or a hybrid) wins on practicality

Chapter 8: Common Mistakes#

Your turn · checkpoint 5not answered

A month without cleanup, then a crash

A session job keeps every user's session in a map and never removes one, and nobody turned on checkpointing. What happens after a month of traffic, and what happens when the job then crashes?

Think it through, or jot it down. Then compare.

One strong answer

  • A month in: state grows with every user ever seen, until the job runs out of memory.
  • After the crash: with no checkpoint, it restarts from scratch, so it either loses data or reprocesses everything.
  • Fix: expire state with a TTL or cleanup timers, checkpoint to durable storage (every minute, for example), and use RocksDB when state is larger than memory.

8.1 Streaming Mistakes#

Streaming anti-patterns
Mistake 1: Ignoring Late Data

Wrong:

python2 lines
# Just drop late events
events.filter("event_time > watermark")

Problem:

  • Mobile events routinely arrive late
  • Dropping them = wrong metrics

Right:

python12 lines
# PyFlink: keep windows open for late events, and send anything
# later still to a side output instead of dropping it silently
late_tag = OutputTag("late-events", Types.PICKLED_BYTE_ARRAY())
windowed = (events
  .key_by(lambda e: e.product_id)
  .window(TumblingEventTimeWindows.of(Time.hours(1)))
  .allowed_lateness(Time.hours(1).to_milliseconds())
  .side_output_late_data(late_tag)
  .reduce(sum_revenue))
late_events = windowed.get_side_output(late_tag)  # Separate path
# (Spark Structured Streaming has no late-data side output: events
#  behind its watermark may be dropped)
Mistake 2: Unbounded State

Wrong:

python2 lines
# Keep all sessions forever
state[user_id] = session

Problem:

  • State grows forever
  • Eventually OOM

Right:

python5 lines
# Use state TTL
state.set_ttl(timedelta(days=7))

# Or use timers to clean up
timer.register_cleanup(session_end + timedelta(hours=1))
Mistake 3: Not Checkpointing

Wrong:

python2 lines
# No checkpoints configured
stream.process(handler)

Problem:

  • Crash = start from scratch
  • Data loss or reprocessing

Right:

python2 lines
env.enable_checkpointing(60_000)  # Every minute
env.get_checkpoint_config().set_checkpoint_storage_dir("s3://checkpoints/")

8.2 Batch Mistakes#

Batch anti-patterns
Mistake 1: No Idempotency

Wrong:

python4 lines
# Append to output on every run
df.write.mode("append").parquet(output)

# Re-run = duplicates!

Problem:

  • Job fails mid-write, retry creates duplicates

Right:

python11 lines
# Overwrite only the partitions being written (dynamic mode;
# the default "static" mode would wipe the whole output)
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
df.write.mode("overwrite").partitionBy("date").parquet(output)

# Or upsert atomically with Delta Lake (MERGE, not a write mode)
(DeltaTable.forPath(spark, output).alias("t")
   .merge(df.alias("s"), "t.event_id = s.event_id")
   .whenMatchedUpdateAll()
   .whenNotMatchedInsertAll()
   .execute())
Mistake 2: Ignoring Skew

Wrong:

python4 lines
# Group by a skewed key
events.groupBy("celebrity_user_id").count()

# One executor gets 90% of data!

Problem:

  • One task takes hours, others finish in minutes (worst for joins and non-combinable aggregations; simple counts are partly pre-aggregated map-side)

Right:

python6 lines
# Salt the key: spread the hot key over 10 tasks, then merge
(events
  .withColumn("salt", (rand() * 10).cast("int"))   # 0-9
  .groupBy("user_id", "salt").count()              # partial counts
  .groupBy("user_id")                              # merge the salts
  .agg(sum("count").alias("count")))
Quick check3 questions · +5 XP each

Case studies and common mistakes

  1. Q1/3 A platform with petabytes of history keeps Lambda even though smaller teams move toward Kappa. What makes Lambda the practical choice at that scale?

    Answer & explanation

    Answer: C

    At trillions of events a full Kappa replay can take days, and batch also suits ML training and cheap spot instances. The platform still runs Flink for real-time, so "no real-time needs" is wrong. Lambda is kept despite its complexity, not because anyone wants it.

  2. Q2/3 A nightly Spark job writes with mode("append"). It fails halfway through writing and the scheduler retries it. What do you get?

    Answer & explanation

    Answer: A

    Append is not idempotent: whatever the failed run wrote stays there, and the retry adds a full copy on top. Overwriting the date partition makes reruns safe.

  3. Q3/3 groupBy("celebrity_user_id") leaves one task running for hours while the rest finish in minutes. What is the fix described in the lesson?

    Answer & explanation

    Answer: B

    All rows for one key go to one task, so more executors or partitions still leave that key on one task. Salting splits the hot key across several tasks, and a second aggregation merges the partial counts.

Part IV · Stage 4 of 4 · 4 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips#

9.1 When to Discuss Stream vs Batch#

Topic triggers
Interview prompt Approach Discuss
"Design a real-time dashboard" Primarily streaming Windowing, approximate vs exact
"Design an analytics platform" Both streaming and batch Lambda vs Kappa trade-offs
"Design a recommendation system" ML training is batch Serving might use streaming for freshness
"Design fraud detection" Streaming for real-time alerts Batch for model training
"Design a data warehouse" Primarily batch Maybe streaming for near-real-time
❯ Say this

9.2 Key Phrases#

Talking about stream vs batch
  • "For this use case, I'd consider whether we need real-time results or if hourly/daily is acceptable. Real-time adds complexity with state management, exactly-once, and late data."
  • "I'd use a Lambda architecture here because we have both real-time needs and accuracy requirements. The batch layer handles late data and corrections, while streaming provides immediate visibility."
  • "For this volume, I'd lean toward Kappa since reprocessing 100GB from Kafka is fast enough. The single codebase is worth more than the slight efficiency of batch-only."
  • "The key trade-off is latency vs accuracy vs cost. Streaming gives seconds of latency but higher cost. Batch gives hours of latency but lower cost and easier handling of corrections."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: Real-Time Leaderboard#

Setup: Design a gaming leaderboard that shows:

  • Top 100 players globally (updates every second)
  • Your rank (updates every action)
  • Historical rankings (end of day snapshots)

Questions:

  1. Stream, batch, or both? Why?
  2. How do you handle millions of score updates/second?
  3. How do you efficiently compute "your rank" among millions?
Click for hints
  • Streaming for real-time updates
  • Redis sorted sets for O(log N) rank lookup
  • Batch for historical snapshots
  • Sample or approximate for global leaderboard

Practice · problem 2

Problem 2: Ad Attribution#

Setup: Track which ad led to a purchase:

  • User sees ad at time T1
  • User purchases at time T2 (within 7 days)
  • Attribute purchase to ad

Questions:

  1. How do you join ad views with purchases?
  2. What if the purchase event arrives before the ad view?
  3. Streaming or batch for attribution?
Click for hints
  • Stream-stream join with state
  • Watermarks to handle out-of-order
  • Buffering needed for 7-day window
  • Maybe batch for accuracy, stream for estimates

Chapter 11: Sample Interview Dialogue#

Transcript
Interviewer

"Design an analytics system for our e-commerce platform."

You

"Let me start by understanding the requirements. What types of analytics are needed, and what's the latency requirement?"

Interviewer

"We need real-time revenue dashboards for executives, and daily reports for finance. Also, conversion funnel analysis."

You

"Got it. This is a classic case where we need both real-time and batch processing. Let me propose an architecture.

For the streaming layer, I'd use Flink consuming from Kafka. It would compute real-time aggregates like current revenue, active users, and approximate conversion funnels. These feed into Druid for real-time dashboards.

For the batch layer, I'd use Spark running hourly. It would compute exact metrics including complete funnel analysis with proper session handling, accurate revenue with all late events included, and the data that finance needs.

The key insight is that some metrics need real-time approximation, while others need daily accuracy. Revenue for executives can be 'roughly right now' — they don't need penny-perfect accuracy at 2 PM. But the end-of-day report must be exact."

Interviewer

"Why not just use streaming for everything?"

You

"Great question. We could, but there are trade-offs:

First, late data. E-commerce has refunds, corrections, and late mobile events. In streaming, we'd need to handle allowed lateness, retractions, and updates — adding complexity.

Second, cost. Streaming is always-on. Batch can use spot instances and run only when needed.

Third, accuracy vs speed. For the finance report, we want to wait for all late data. With streaming, we'd need to recompute or issue corrections.

However, if simplicity is paramount and the scale is manageable, Kappa with long Kafka retention could work. We could reprocess nightly to handle corrections. It depends on whether the team is more comfortable with Spark or Flink."

Summary#

Day 2 summary: Streaming vs batch
  • Batch processing
    • Process data in large chunks
    • High throughput, high latency
    • Accurate (all data processed together)
    • Cheaper (periodic, uses spot instances)
    • Simpler programming model
  • Stream processing
    • Process data continuously
    • Lower throughput, low latency
    • Approximate (late data challenges)
    • More expensive (always on)
    • Complex (state, exactly-once, watermarks)
  • Lambda architecture
    • Batch layer + Speed layer + Serving layer
    • Batch for accuracy, streaming for speed
    • Two codebases (main disadvantage)
    • Best when: Huge history, mixed requirements
  • Kappa architecture
    • Streaming only + Replay for reprocessing
    • Single codebase
    • Replay must be fast enough
    • Best when: Moderate history, simplicity valued
  • Modern trend
    • Unified frameworks (Beam, Flink, Spark Structured Streaming)
    • Write once, run as batch or stream
    • Industry moving toward unified APIs
◆ Key takeaways

Key Takeaways#

Streaming vs batch key takeaways
  1. THEY SOLVE DIFFERENT PROBLEMS — Stream: "What's happening NOW?" · Batch: "What happened EXACTLY?"
  2. LATE DATA IS THE KILLER — Streaming makes late data hard · Batch handles it naturally
  3. SIMPLICITY HAS VALUE — Kappa's single codebase often wins · Unless scale forces Lambda
  4. COST MATTERS — Streaming: Always on = always paying · Batch: Pay only when running
  5. THE INDUSTRY IS CONVERGING — Unified APIs blur the line · Same code for both modes
Golden rules
  • Start with batch (simpler)
  • Add streaming only when needed
  • Use watermarks for late data
  • Checkpoint everything
  • One codebase when possible

What's Next#

Tomorrow, we'll dive into Data Modeling for Analytics — designing schemas that make queries fast over billions of rows.

Tomorrow's preview: Data modeling
Questions we'll answer
  • What is a star schema?
  • Fact tables vs dimension tables?
  • How to handle slowly changing dimensions?
  • When to pre-aggregate?
  • Partitioning for analytics performance?

We'll design the data warehouse that our streaming and batch jobs write to.

Next: Day 3 — Data Modeling for Analytics

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 02 · Streaming vs Batch Processing
Finish Day 2Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~8 more min of reading.

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.

ConfluentBlog post · ~14 min

Questioning the Lambda Architecture

Jay Kreps · 2014
What
The essay that proposed the Kappa architecture as an alternative to Lambda.
Key idea
Keeping batch and streaming code in sync is the real cost of Lambda; instead keep the log and reprocess history with the stream processor when logic changes.
Why here
The Lambda vs Kappa choice of this lesson, argued by Kafka's co-creator.
taught here: Kappa architecture
GoogleBlog post · ~33 min

Streaming 101: The world beyond batch

Tyler Akidau · 2015
What
A Google engineer's introduction to stream processing terms and patterns.
Key idea
Streaming is processing unbounded data, and done right it can be as correct as batch; event time and processing time must be kept apart.
Why here
Reframes this lesson's 'two worlds' as one: batch is a special case of streaming over bounded data.
taught here: Streaming vs batch
GooglePaper · ~35 min

MapReduce: Simplified Data Processing on Large Clusters

Jeffrey Dean, Sanjay Ghemawat · 2004
What
The paper that introduced MapReduce.
Key idea
Express a job as map and reduce functions and the framework handles partitioning, scheduling, shuffling and re-running failed tasks across thousands of machines.
Why here
The ancestor of the Spark batch layer in this lesson, and still the clearest account of why batch is simple to reason about.
taught here: Batch processing

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…