Weekend Labs
All labs
Lab 01 · Part 2·Iceberg MERGE · Partitioning · Terragrunt hooks

From one table to a small lakehouse

Reference tables kept current with MERGE, an hourly-partitioned enriched table, and the time-travel trick that keeps old flows labeled the way they were.

Namespaces
3
New tables
3
MERGEs
2
Pruning
5.5× less read
Cost
~$0.005
Teardown
pending

Part 1 proved the pipeline: synthetic flow logs through Firehose into one Iceberg table on S3 Tables. Real log lakes grow layers: reference data, an enriched copy, summaries. So Part 2 builds them on the same data and goes looking for the operational sharp edges.

First, make teardown check itself

Every lab here ends with a verified teardown. I’d written a script that checks for leftovers, so the obvious move was a Terragrunt after_hook that runs it after destroy. The interesting part was where to put it. Hooks run per unit, but “is the lab gone?” is a question about the whole lab. In the shared root config, the Firehose unit’s destroy would trip the check while the table bucket still existed. So the hook lives only on the table-bucket unit, the one run --all always destroys last.

# live/sandbox/us-east-1/01-s3-tables-log-lake/table-bucket/terragrunt.hcl
after_hook "verify_teardown" {
  commands     = ["destroy"]
  execute      = ["env", "CHECK_ATTEMPTS=4",
                  "${get_repo_root()}/scripts/check-leftovers.sh",
                  include.root.locals.lab_id]   # "01", via include { expose = true }
  run_on_error = false                           # skip the check if destroy itself failed
}

Reference data, kept current with MERGE

A new namespace, ref, holds two small tables: an inventory of which host sits on each private IP, and a list of known-bad IPs. Both were created with plain Athena SQL. Terraform owns the platform and SQL owns the data, which also keeps teardown simple: S3 Tables won’t delete a namespace that still has tables in it.

Then one MERGE INTO per table applied a change batch: a host moved to a new tier, one was decommissioned, one appeared. Under the hood, Athena writes Iceberg changes as merge-on-read, so an update is really “mark the old row deleted, write a new one”. The snapshot history shows exactly that.

Snapshot history for eni_inventory: an append, then one overwrite
One MERGE, one snapshot: an overwrite with 2 new rows, 1 new data file, and 2 position deletes in 1 delete file. total_records says 17; a query returns 15 live rows.

I also broke it on purpose, with two source rows for the same key. MERGE refused, and the table still had exactly two snapshots afterwards. All or nothing.

Athena error MERGE_TARGET_ROW_MULTIPLE_MATCHES
Breaking it on purpose: MERGE_TARGET_ROW_MULTIPLE_MATCHES. Nothing was written.

The join that rewrote history

The first enriched query was a plain join from flows to the inventory, and it surprised me. It labeled all past traffic with today’s inventory. Scanner probes aimed at web-04 before it was decommissioned now said unknown, and app-08’s old traffic said batch, a tier it didn’t have yet.

Scanner rejects by destination tier using the current inventory
History, relabeled: joined to the current inventory, 138 old scanner flows land in unknown and 127 in batch.

Gotcha: a join to reference data answers “what is it now?”, not “what was it then?”. If history matters, label rows when they land, or keep validity dates on the reference table.

Labels that don’t lie

I chose to label each flow at load time and never touch the label again. Iceberg made the backfill easy: every existing flow had arrived before the MERGE, so I joined it to the inventory FOR VERSION AS OF its first snapshot. That’s time travel on the reference tables, not the flows.

For new data I sent a fresh burst and found exactly the new rows by subtracting the backfill snapshot from the current table (EXCEPT ALL). A snapshot diff works as a change feed. Those rows got today’s labels. Each row also records which inventory snapshot labeled it, so the result can be checked:

Traffic to app-08 split by inventory snapshot: 133 app, 11 batch
Label-at-load, proven: traffic to app-08, 133 older flows labeled app by inventory v1, 11 new flows labeled batch by today's inventory. Neither load rewrote the other.

Partitioning: same answer, 5.5× less read

The enriched table is PARTITIONED BY (hour(start_time)). That’s an Iceberg partition transform: there’s no extra column to keep in sync, and any filter on start_time skips the hours it doesn’t need. Same one-hour question, both tables:

Raw table one-hour query scanning 393 KB
Raw, unpartitioned: 22,956 flows, 393 KB scanned.
Silver table one-hour query scanning 72 KB
Silver, partitioned by hour: the same 22,956 flows from 72 KB.

Still to come in Part 2: bad records on purpose with a CloudWatch alarm, PyIceberg on a laptop (and a declared sort order), the Iceberg tag that breaks automatic snapshot cleanup, a read-only analyst role, and the undo window.