Storage Layers: Advanced
- Torn reads: a query that lists the directory mid-write counts some new files beside some old ones.
- Orphaned output: a job that dies after writing 30 of its 50 files leaves 30 files that every later query counts.
- Lost history: an overwrite deletes the old files, so nothing records what the table held yesterday at 09:00.
- Slow planning: listings commonly return at most 1,000 keys per request, so a table of 1 million files costs 1,000 round trips before any data is read.
The metadata layer
A writer moves left to right: data files first, then metadata that references them, then the pointer swap. A reader moves right to left: pointer, then metadata, then only the data files that metadata lists.
One commit, traced
The orders table is partitioned by order_date and sits at snapshot 41, made of 3 files, f1 to f3, where f3 holds 2026-10-23. A nightly job recomputes that date and must replace f3 with 2 corrected files. The catalog accepts a new pointer only if it still holds the value the writer last read: a compare-and-swap.
| Step | What the job writes | Snapshot a reader gets |
|---|---|---|
| 1 | data files f4, f5 | 41: f1, f2, f3 |
| 2 | metadata m42 listing f1, f2, f4, f5 | 41: f1, f2, f3 |
| 3 | pointer swapped from m41 to m42 | 42: f1, f2, f4, f5 |
| Crash before 3 | f4, f5 and m42, referenced by nothing | 41, unchanged |
A job writes 40 of its 50 new data files into the orders location, then its machine dies before step 2. A dashboard queries orders a minute later. What does the dashboard read?
Why the metadata is the product
- The table is whatever a listing returns right now
- A half-finished write is visible to readers
- Schema is inferred from whichever files get opened
- Pruning needs a listing, then every file's footer
- The table is the file list in the current snapshot
- A commit appears all at once or not at all
- Schema is stored once, with a permanent id per column
- Pruning reads min and max values from manifests
The orders table holds 2,000 data files across 3 years. A query filters order_date = DATE 2026-10-23. How does the engine choose which files to open?
Concurrent writers share 1 pointer
| Time | Writer A: web orders | Writer B: app orders | Pointer |
|---|---|---|---|
| 10:00 | reads 42 | reads 42 | 42 |
| 10:02 | writes 3 files | writes 2 files | 42 |
| 10:03 | swap 42 to 43 succeeds | 43 | |
| 10:03 | swap 42 to 43 fails | 43 | |
| 10:03 | 43 only added files: rebuild metadata on 43 | 43 | |
| 10:04 | swap 43 to 44 succeeds | 44 |
| Winner committed | Loser attempted | Outcome |
|---|---|---|
| append to 2026-10-24 | append to 2026-10-24 | rebase metadata and commit |
| append to 2026-10-23 | overwrite 2026-10-23 | conflict: the overwrite missed new rows, recompute |
| overwrite 2026-10-23 | overwrite 2026-10-23 | conflict: the file to replace is gone, recompute |
| compaction of 2026-10-22 | append to 2026-10-22 | rebase: the append removes no files |
Writers A and B both read snapshot 50 of orders and append new files to the 2026-10-24 partition. A swaps first. What happens to B?
Time travel
| snapshot_id | committed_at | operation |
|---|---|---|
| 44 | 2026-10-24 10:04 | append |
| 43 | 2026-10-24 10:03 | append |
| 42 | 2026-10-24 02:15 | overwrite |
| 41 | 2026-10-23 23:50 | append |
Retention sets the horizon
- ▸Reproduction: if month-end reports are rebuilt 10 days later, retention under 10 days breaks them.
- ▸Cost: daily replaced volume times retention days is the extra storage you pay for.
- ▸Ceiling: snapshots cover days or weeks; years of history belong in the table's rows.
Retention on orders is 7 days. On 2026-11-05 an analyst runs a query pinned to snapshot 41, committed on 2026-10-23. What happens?
Columnar files record each column's name inside the file. Older lake engines matched file columns to the table schema by that name or by position, and both break. Rename amt to amount on a name-matching table and every file written before the rename lacks an amount column, so 3 years of orders read back as NULL. On a position-matching table, dropping the second column shifts every later column one slot. The traditional fix was to rewrite every file.
Field ids name the column
A table format assigns each column a numeric field id when the column is created and never reuses it. Writers store the id beside each column in every data file, and readers resolve columns by id, so the name becomes a label in metadata that can change freely. Here is the schema history in the root metadata of orders:
| field id | name in file | value |
|---|---|---|
| 1 | order_id | 1007 |
| 2 | order_date | 2023-04-02 |
| 3 | amt | 2500 |
| 4 | legacy_flag | true |
| field id | column | value |
|---|---|---|
| 1 | order_id | 1007 |
| 2 | order_date | 2023-04-02 |
| 3 | amount | 2500, as bigint |
| 5 | currency | NULL |
| Change | Statement | Data files rewritten |
|---|---|---|
| Add | ALTER TABLE orders ADD COLUMN currency varchar | 0: old files read NULL for id 5 |
| Rename | ALTER TABLE orders RENAME COLUMN amt TO amount | 0: id 3 keeps its bytes |
| Drop | ALTER TABLE orders DROP COLUMN legacy_flag | 0: id 4 bytes stay, unread |
| Widen | integer to bigint on id 3 | 0: values upcast on read |
Table L is a directory of files whose engine matches columns by name; table T is a table format with field ids. Both hold 3 years of orders. You rename amt to amount in each. What does SUM(amount) return?
Which type changes stay metadata-only
- integer to bigint
- real to double
- decimal(10,2) to decimal(12,2)
- required column to optional
- bigint to integer
- varchar to integer
- decimal(10,2) to decimal(10,4)
- optional column to required
Which change to orders cannot commit as a metadata-only schema update?
The drop and re-add trap
Ids also make a mistake expensive. Drop legacy_flag (id 4), regret it a week later and add legacy_flag boolean again: the new column gets id 6. Old files hold their values under id 4, readers ask for id 6, and every historical row reads NULL. The bytes still sit in the old files, but no schema maps a column to them, so recovery means a rewrite that copies id 4 values into id 6.
A file is small when its fixed costs outweigh its bytes. Every file read from object storage costs requests to open it and read its footer, plus a fetch for every column chunk, and each request pays network latency whatever its size. Writers create small files: each writer task produces its own file for every partition it touches on every commit. Take a clickstream table events that is partitioned by hour with a 256MB file target. A micro-batch job commits to it every minute, and each batch brings 30MB of new data spread across 20 writer tasks.
Reading the symptoms from metadata
| Signal | events now | At 256MB |
|---|---|---|
| Files per hour partition | 1,200 | about 7 |
| Average file size | 1.5MB | about 256MB |
| Files in manifests after 90 days | 2.6M | about 15,000 |
| Planning time as the table ages | grows | grows about 170x slower |
clicks has 400 files averaging 300MB. sessions has 90,000 files averaging 2MB, and its planning time doubled in 3 months while bytes scanned stayed flat. Which table needs compaction first?
Compaction
Compaction reads the small files of a partition, writes them back as target-size files, and commits a replace snapshot that removes the old files and adds the new ones. For 1 hour of events it swaps 1,200 files of 1.5MB for 7 files of about 256MB. The rows are identical, so no answer changes; queries already running keep their pinned snapshot, and the 1,200 files are deleted only when that snapshot expires. Table formats ship this as a built-in procedure, often exposed as an OPTIMIZE command. It takes a table and a target size, and a partition filter narrows it to the partitions you want rewritten.
Two choices decide whether compaction helps or fights the writers. First, compact closed partitions, such as the 09:00 hour once the clock is safely past 10:00. Appends only add files, so they rarely conflict with compaction. If a delete or update touches a file that compaction is replacing, though, whichever commits later has to redo its work. Second, sort while rewriting: sorting each hour by user_id gives every output file a narrow user_id range, so a filter on 1 user skips most files using their min/max stats.
A dashboard query on events starts at 11:00:00 on snapshot 900 and runs 40 seconds. At 11:00:15, compaction of the 09:00 hour commits snapshot 901. What does the query return?
Most real datasets serve several access patterns at once. Take a grocery delivery marketplace with 1 orders dataset and 3 groups of users. The shopper app reads and updates single orders at up to 3,000 writes per second and needs answers in under 20ms. Finance and data science need 4 years of history and must reproduce month-end numbers and training sets exactly. A group of 300 analysts refresh revenue dashboards by store and category and expect charts in under a second.
| Access pattern | Query shape | Latency | Store |
|---|---|---|---|
| Shopper app | 1 row by order_id, read and update | under 20ms | Operational database |
| Finance, data science | full scans of history, pinned to a snapshot | minutes | Lakehouse archive |
| Analyst dashboards | aggregates by store and category, 300 users | under 1s | Warehouse mart |
How data moves between the 3 stores
Changed orders leave the operational database hourly and land as appends in orders_raw; a nightly overwrite builds orders_curated. Each morning the mart build reads 1 pinned snapshot of orders_curated and replaces revenue_mart.
Each store owns a different part of the data. The operational database owns the present and keeps 90 days, so its indexes stay small. The lakehouse owns history: every order ever placed, plus each recent version within retention. The mart owns nothing: it is a derived copy that can be dropped and rebuilt from orders_curated, which is what makes it safe to tune hard for the dashboards.
The September revenue report was published on 2026-10-01 with its snapshot id recorded. On 2026-11-03 finance rebuilds it from orders_curated and gets a total 0.4% higher, because October corrections changed September rows. Retention is 45 days. What reproduces the published number?
Where it breaks, and what prevents it
| Stress | What breaks | Property that prevents it |
|---|---|---|
| Mart build runs while the nightly job commits | revenue mixes 2 versions of a day | snapshot isolation: the build pins 1 snapshot id |
| Mart build rerun after a failure | rows appended twice, revenue doubled | the build replaces the mart from 1 pinned snapshot |
| Operational database adds tip_amount | the extract drops the new field | ADD COLUMN in lakehouse metadata, old rows read NULL |
| Upstream renames store to store_id | a rename looks like a drop plus an add | extract maps names; lakehouse renames by field id |
| 24 hourly extracts a day into orders_raw | small files slow every history scan | nightly compaction of yesterday's partition |
| 300 analysts query orders_curated directly | per-file costs on every chart refresh | the mart, shaped for the dashboard query |
When to drop a layer
Every store is another copy to keep in sync and another place to fail. With 10 analysts happy with 10-second charts, querying orders_curated directly beats building a mart. With 30 days of history and no reproduction need, the operational database plus a warehouse could cover everything. Add a store only when an access pattern needs lower latency or more concurrency than the existing stores give, or more history than they keep, and record which pattern justified it.
A new design replaces the hourly extract with a streaming job that commits to orders_raw every 30 seconds using 40 writer tasks, and the mart build switches to reading orders_raw at 06:00. Which 2 problems does this create?
> A ride-hailing company keeps live trips in an operational database for the rider and driver apps, archives every trip in a lakehouse table partitioned by hour, and builds a city-level earnings mart each morning for operations dashboards. Trips stream into the archive every minute, finance restates last month after fare disputes settle, and the pricing team adds a surge_reason column mid-month.
surge_reason column lands as an ADD COLUMN with a fresh field id: older trips read NULL and no file is rewritten.Object storage plus a metadata layer: how lakehouse tables get atomic commits, time travel and cheap schema change
- Category
- Pipeline Architecture
- Difficulty
- advanced
- Duration
- 38 minutes
- Challenges
- 12 hands-on challenges
Topics covered: The Lakehouse: ACID on Object, Snapshot Isolation and Time Travel, Schema Evolution Without Rewrites, The Small Files Problem, Choosing Storage Across Workloads
Lesson Sections
- The Lakehouse: ACID on Object
Object storage offers 4 operations that matter here: put an object, get it, list keys under a prefix, delete it. Each put is atomic, but nothing groups 2 puts into 1 change, and most object stores implement a prefix 'rename' as a copy plus a delete for every key, so moving 500 files reaches readers as 1,000 separate changes. A plain data lake defines a table as every file under a directory. That definition fails in 4 ways once some jobs write while others read: The metadata layer A lakehouse kee
- Snapshot Isolation and Time Travel
A snapshot is an immutable list of data files. A query resolves the catalog pointer once when it is planned, then reads that snapshot to the end. Suppose a revenue query starts at 10:02:30 on snapshot 42 and runs for 90 seconds. A commit at 10:03 creates snapshot 43, and the running query never notices: its files stay in place, because nothing deletes a file that a retained snapshot lists. Concurrent writers share 1 pointer Writers take no locks. Each one reads the current snapshot, writes its d
- Schema Evolution Without Rewrites
Field ids name the column Schema 2 renamed id 3 and widened it from integer to bigint. It also dropped id 4 and added id 5, and not one data file was rewritten. A reader on schema 2 handles a file written under schema 1 like this: Which type changes stay metadata-only A widening is safe when every value the old type can hold is exactly representable in the new one, so readers upcast while decoding. Any change that could lose or reinterpret a value needs new files: add a new column, fill it by re
- The Small Files Problem
A query over 1 day reads 43.2GB in any layout, but this layout pays the per-file overhead once for each file the day produced, against 169 times for the same 43.2GB compacted to 256MB files. Planning slows as well, because manifests hold an entry with column stats for every file, so planning time grows with table age even for a query that scans 1 hour. Tiny files also compress less well, since each one restarts its dictionaries and encodings. Reading the symptoms from metadata The files metadata
- Choosing Storage Across Workloads
No single store meets all 3. The operational database is a row store with indexes, fast for single-row reads and writes and slow for scans. The lakehouse is versioned and built for scans, but each query spends seconds reading manifests and opening files, while 300 analysts expect charts in under 1 second. The warehouse mart answers those charts because it holds a small, pre-aggregated table shaped for 1 query pattern. How data moves between the 3 stores Where it breaks, and what prevents it When