Data Engineering · lesson 15 of 23
Beginner
8- 1 Data Engineering Fundamentals
- 2 File Formats: CSV, JSON, Parquet
- 3 Idempotency and Safe Retries
- 4 Batch Pipelines and Time Windows
- 5 Partitioning and Physical Layout
- 6 Batch vs. Streaming: The Real Trade-off
- 7 Data Lake vs. Data Warehouse vs. Lakehouse
- 8 Data Modeling Basics: Star Schemas and Denormalization
Intermediate
4Advanced
11- 13 Streaming: Exactly-Once Semantics
- 14 Schema Evolution and Data Contracts
- 15 Streaming Architecture: Lambda and Kappa
- 16 Data Lineage: Tracing a Column Back to Its Source
- 17 Data Governance: Catalogs, Classification, and Access Policy
- 18 Data Security and PII: Hashing, Masking, and Tokenization
- 19 Pipeline Observability: Tracing, Anomaly Detection, and Alerting
- 20 Cost Optimization: Measuring What Partition and Column Pruning Actually Save
- 21 CI/CD for Data: Gating Promotion with Real Tests
- 22 Disaster Recovery: RPO, RTO, and What a Backup Actually Loses
- 23 Data Platform Architecture: Wiring the Pieces Together
Also part of Data Engineering
9- + Your First Airflow DAG from Airflow
- + Kafka Topics, Partitions, and Offsets from Kafka
- + Spark Architecture: Driver, Executors, and Cluster Managers from Apache Spark
- + Models, ref, and the DAG from dbt
- + Delta Lake: The Transaction Log from Databricks
- + The Medallion Architecture from Databricks
- + File I/O, pathlib, and JSON in Python from Python
- + Error Handling in Python from Python
- + Terraform Fundamentals (IaC) from Terraform
Streaming Architecture: Lambda and Kappa
Build a lambda architecture's batch and speed layers by hand, watch a late event slip past both, then see why kappa makes reconciliation unnecessary.
By SkillByExample · Published
Build a lambda architecture’s two layers by hand, feed them a late-arriving event, and watch the merged view be wrong until the next batch run corrects it.
The event log, including one late arrival
from datetime import datetime, timedelta
events = [
(datetime(2026, 1, 4, 0, 5), 12.0), (datetime(2026, 1, 4, 3, 0), 8.0),
(datetime(2026, 1, 4, 9, 0), 20.0), (datetime(2026, 1, 4, 14, 0), 15.0),
(datetime(2026, 1, 4, 22, 0), 9.0),
# arrives at 23:50 -- after the batch run below already fired
(datetime(2026, 1, 4, 23, 50), 30.0),
]
batch_cutoff = datetime(2026, 1, 5, 0, 0)
Batch layer: complete, but only as of its last run
events_at_batch_time = events[:5] # the late event has not arrived yet
batch_total = sum(amount for ts, amount in events_at_batch_time if ts < batch_cutoff)
print(f"batch layer total for 2026-01-04: ${batch_total:.2f} (5 events seen)")
$ python streaming_architecture.py
== lambda architecture ==
-- batch layer: reprocesses everything up to the cutoff, run once a day --
batch layer total for 2026-01-04: $64.00 (5 events seen)
Speed layer: live, but only sees what arrives after the cutoff
speed_layer_events = [(ts, amt) for ts, amt in events if ts >= batch_cutoff]
speed_total = sum(amt for ts, amt in speed_layer_events)
print(f"speed layer total since cutoff: ${speed_total:.2f} ({len(speed_layer_events)} events)")
-- speed layer: only events since the batch cutoff, updated live --
speed layer total since cutoff: $0.00 (0 events)
The merged view a dashboard actually shows
print(f"batch (${batch_total:.2f}) + speed (${speed_total:.2f}) = ${batch_total + speed_total:.2f}")
-- merged view: what a dashboard shows right now --
batch ($64.00) + speed ($0.00) = $64.00
but the late event (arrived 23:50, after the batch already ran) is invisible to both layers
The $30 order that arrived at 23:50, timestamped for the 4th, is invisible to both layers —
too late for the batch run that already fired, and its own timestamp is before the speed
layer’s cutoff so the speed layer does not count it as “since cutoff” either. This is exactly
the late-event problem the streaming-exactly-once lesson’s watermarks section covers, showing
up one layer higher, in the architecture itself.
Reconciliation: the next batch run fixes it
corrected_total = sum(amount for ts, amount in events if ts < batch_cutoff)
print(f"corrected batch total for 2026-01-04: ${corrected_total:.2f} (6 events, late one now included)")
print(f"yesterday's merged view was off by ${corrected_total - (batch_total + speed_total):.2f}")
-- next day's batch run reprocesses the FULL log, including the late arrival --
corrected batch total for 2026-01-04: $94.00 (6 events, late one now included)
yesterday's merged view was off by $30.00
The batch layer is self-healing — every run reprocesses everything, so a late event is simply included next time. But there was a real, wrong number shown for up to a full day, and nothing told anyone it would correct itself.
Kappa: replay the same code instead of maintaining two
running_total = 0.0
for ts, amount in sorted(events):
if ts.date() == batch_cutoff.date() - timedelta(days=1):
running_total += amount
print(f"kappa: single replay of the log gives ${running_total:.2f} directly - no reconciliation step needed")
== kappa architecture: one stream, no separate batch layer ==
kappa: single replay of the log gives $94.00 directly - no reconciliation step needed
Same correct answer, but there was never a “wrong answer” window — the same stream-processing logic that handles new events also handles a full historical replay. What lambda solved with a second codebase, kappa solves by making the one codebase replayable, at the cost of needing an event log (like Kafka) that retains enough history to replay from.
Practice
1. Add a second late event arriving at 23:55 for $5, and recompute the corrected batch total.
corrected batch total for 2026-01-04: $99.00 (7 events, both late ones now included)
Every late arrival before the next batch run is silently absorbed the same way — the architecture does not distinguish “one late event” from “several.”
2. Change the speed layer to also pick up events from the *previous* calendar day, not just after the cutoff timestamp.
This is the “backfill window” trick real speed layers use — instead of trusting event timestamps blindly, re-scan a small trailing window (say, the last 2 hours before cutoff) on every speed-layer update, accepting some double-counting risk against the batch layer in exchange for catching moderately-late events sooner. It does not fully solve the problem (a sufficiently late event still slips through), just narrows the window where it can happen.
3. Classify each processing step in this lesson as stateful or stateless.
Stateless: filtering events by timestamp (ts < batch_cutoff) — each event is judged
independently. Stateful: sum(...) and running_total — both need to remember an
accumulated value across multiple events, which is what makes them harder to make
exactly-once and harder to scale across parallel workers than a stateless filter.
4. Why can't the speed layer just always recompute the full total instead of only "since cutoff"?
It could, but then it is reimplementing the batch layer’s full reprocessing on every update — exactly the cost kappa architecture accepts deliberately (a fast enough, cheap enough replay) and exactly what lambda’s two-layer split was designed to avoid (the speed layer trades completeness for being cheap enough to run continuously).
Next: data lineage — tracking where every value in a pipeline’s output actually came from.