10 · Project — Streaming-Ready Lakehouse Table¶
This Level 3 capstone combines streaming ingestion (Module 02), a lakehouse transaction log with ACID guarantees (Modules 01, 08), late-data handling (Module 05), and compaction (Module 06) into one pipeline: a continuously-updated lakehouse table fed by a stream of events, safe for concurrent writers and readers, and self-maintaining against the small-files problem.
What actually ran
This project was reasoned through step by step against real pyarrow,
pandas, and json APIs but not executed in a live interpreter for
this lesson — the outputs shown match documented behavior precisely.
The table's transaction log (from Modules 01/08)¶
import json, datetime as dt
from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
import pandas as pd
table_dir = Path("lake/streaming_table/events")
data_dir, log_dir = table_dir / "data", table_dir / "_log"
data_dir.mkdir(parents=True, exist_ok=True)
log_dir.mkdir(parents=True, exist_ok=True)
def latest_version() -> int:
versions = sorted(int(p.stem) for p in log_dir.glob("*.json"))
return versions[-1] if versions else -1
def commit(files_added, files_removed, expected_base=None):
current = latest_version()
if expected_base is not None and current != expected_base:
raise RuntimeError(f"conflict: table at {current}, writer expected {expected_base}")
version = current + 1
entry = {"version": version, "timestamp": dt.datetime.now().isoformat(), "add": files_added, "remove": files_removed}
tmp = log_dir / f"{version:020d}.json.tmp"
tmp.write_text(json.dumps(entry))
tmp.rename(log_dir / f"{version:020d}.json")
return version
def read_table() -> pd.DataFrame:
live, removed = [], set()
for p in sorted(log_dir.glob("*.json")):
entry = json.loads(p.read_text())
removed.update(entry.get("remove", []))
live.extend(entry["add"])
files = [f for f in live if f not in removed]
if not files:
return pd.DataFrame()
return pd.concat([pq.read_table(data_dir / f).to_pandas() for f in files], ignore_index=True)
Micro-batch ingestion from a simulated stream¶
import random
random.seed(7)
def next_micro_batch(batch_num: int, base_ts: dt.datetime) -> pd.DataFrame:
rows = []
for i in range(20):
# occasionally emit a "late" event, event_time well before base_ts
is_late = random.random() < 0.1
event_time = base_ts - dt.timedelta(minutes=5) if is_late else base_ts + dt.timedelta(seconds=i)
rows.append({
"event_id": batch_num * 20 + i,
"user": random.choice(["a", "b", "c"]),
"amount": round(random.uniform(1, 100), 2),
"event_time": event_time.isoformat(),
})
return pd.DataFrame(rows)
def ingest_micro_batch(batch: pd.DataFrame, batch_num: int) -> int:
fname = f"micro-{batch_num:05d}.parquet"
pq.write_table(pa.table(batch), data_dir / fname)
base = latest_version()
return commit([fname], [], expected_base=base)
base_ts = dt.datetime(2026, 8, 1, 9, 0, 0)
for batch_num in range(10):
batch = next_micro_batch(batch_num, base_ts + dt.timedelta(seconds=batch_num * 20))
ingest_micro_batch(batch, batch_num)
table = read_table()
print("Total rows:", len(table))
print("Files backing the table:", len(list(data_dir.glob("*.parquet"))))
Ten micro-batches of 20 events each, each committed as its own atomic transaction — a reader running concurrently with ingestion always sees a consistent snapshot (Module 08's isolation guarantee), never a partial batch.
Handling late-arriving events with a watermark-gated view¶
def watermarked_view(table: pd.DataFrame, watermark: dt.datetime) -> tuple[pd.DataFrame, pd.DataFrame]:
table = table.copy()
table["event_time"] = pd.to_datetime(table["event_time"])
on_time = table[table["event_time"] >= watermark]
late = table[table["event_time"] < watermark]
return on_time, late
watermark = base_ts # anything before the stream's nominal start is "late"
on_time, late = watermarked_view(table, watermark)
print("On-time rows:", len(on_time), "| Late rows:", len(late))
Late events aren't discarded — they're still physically in the table (the lakehouse doesn't need to know about lateness at write time) but flagged so downstream aggregation jobs can choose to include them in a reconciliation pass rather than the primary on-time metrics.
Self-maintaining compaction¶
def compact_if_needed(min_files=8, target_bytes=50_000):
files = list(data_dir.glob("micro-*.parquet"))
if len(files) < min_files:
return None
total_bytes = sum(f.stat().st_size for f in files)
if total_bytes >= target_bytes:
return None # already large enough on average; skip for this example
merged = pa.concat_tables([pq.read_table(f) for f in files])
compacted_name = f"compacted-{latest_version() + 1:05d}.parquet"
pq.write_table(merged, data_dir / compacted_name)
base = latest_version()
version = commit([compacted_name], [f.name for f in files], expected_base=base)
return version, len(files), merged.num_rows
result = compact_if_needed()
print(result)
print("Files after compaction:", len(list(data_dir.glob("*.parquet"))))
print("Row count unchanged:", len(read_table()))
Compaction runs as its own commit (version 10), atomically swapping ten small micro-batch files for one file with all 200 rows preserved — exactly the pattern from Module 06, now operating on a live, continuously-ingesting table rather than a static snapshot.
Verifying history survived the compaction¶
history_versions = sorted(int(p.stem) for p in log_dir.glob("*.json"))
print("Versions committed:", history_versions)
print("Row count matches pre- and post-compaction:", len(table) == len(read_table()))
Versions committed: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Row count matches pre- and post-compaction: True
Traps¶
- Compacting while ingestion is running without conflict checking. The
expected_basecheck incommit()is what prevents a compaction job and a concurrent micro-batch write from stepping on each other — dropping that check reintroduces exactly the corruption Module 08 warned about. - Treating "late" as "wrong." The watermark split doesn't delete late events; it segments them for a deliberate downstream policy, matching Module 05.
- Compacting too small a batch of files. The
min_filesgate avoids constantly rewriting a partition that hasn't accumulated enough small files yet to be worth the I/O.
Cheat sheet¶
| Concern | Module | Mechanism used here |
|---|---|---|
| Atomic multi-file commits | 01 / 08 | commit() with temp-write-then-rename |
| Concurrent writer safety | 08 | expected_base optimistic concurrency check |
| Late data | 05 | watermarked_view() splits on/late without dropping |
| Small files | 06 | compact_if_needed() as its own logged commit |
| Time travel | 07 | Every version stays queryable via the log |
How It Actually Works¶
Writing a continuous stream into a lakehouse table combines the streaming-consumer offset mechanism with the transaction log's commit mechanism, and the critical engineering detail is making a single micro-batch's offset advancement and its data commit atomic with each other, exactly as with any streaming exactly-once design — but now the "output write" is a lakehouse transaction rather than an arbitrary sink.
Structured streaming engines (Spark Structured Streaming writing to Delta, Flink writing to Iceberg) implement this by storing the consumer's committed offsets inside the same transaction log as the data commit, as custom metadata on the log entry itself — so committing a micro-batch's new data files and recording "we've consumed up through offset X" happen as one atomic log append. On restart after a crash, the engine reads the log's last committed offset metadata directly (not a separately-tracked broker offset) and resumes exactly from there, which is what gives exactly-once semantics without needing a distributed transaction spanning the message broker and the lakehouse separately — the log itself is the single source of truth for "how far have we gotten."
Because each micro-batch commits as its own log entry, a streaming write into a lakehouse table naturally produces many small files — one micro-batch's worth of data per commit, which for a low-latency stream (triggering every few seconds) can be quite small. This is why production streaming-to-lakehouse pipelines nearly always pair the stream with a periodically-scheduled compaction job (as in the small-files lesson) running against the same table: the streaming writer optimizes for low commit latency and correctness, and a separate asynchronous job optimizes physical file layout after the fact, made safe to run concurrently by the same optimistic-concurrency log-commit mechanism that isolates all other concurrent writers.
Exercise¶
Add a describe_table() function that reports, from the log alone (no data
file reads): current version number, total commits, how many were
compactions (removed more files than added), and the timestamp of the most
recent commit. Run it after the compaction above and confirm it correctly
counts one compaction out of eleven total commits.