AdvancedPipeline Architecture · 38 min · 12 challenges

Storage Layers: Advanced

At Netflix, an analyst refreshes a viewing-hours dashboard at the exact second a nightly job rewrites yesterday's partition. On a plain directory of files, the chart can count some old files alongside some new ones. Netflix engineers built an open table format to make that moment impossible, and the fix lives entirely in metadata.

The Lakehouse: ACID on Object

Daily Life
Interviews
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:
  • 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 lakehouse keeps the same columnar data files and adds a second kind of file: metadata naming exactly which data files form the table. A root metadata file holds the schema and the partition spec, and it also lists the table's snapshots. Each snapshot points to manifests, and each manifest lists data files. For every file the manifest records its row count and partition values, along with a min and max per column. A catalog stores 1 thing per table: the location of the current root metadata file.
batch or streaming job
Writer job
immutable columnar files
Data files
snapshots
Metadata files
schemafile stats
1 compare-and-swap per commit
Catalog pointer
reads the pointer first
Query engine

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.

StepWhat the job writesSnapshot a reader gets
1data files f4, f541: f1, f2, f3
2metadata m42 listing f1, f2, f4, f541: f1, f2, f3
3pointer swapped from m41 to m4242: f1, f2, f4, f5
Crash before 3f4, f5 and m42, referenced by nothing41, unchanged
Atomicity comes from the single swap: every reader sees snapshot 41 or snapshot 42, with no state between them. Isolation comes from immutability, since no commit edits a file another query might be reading. Durability belongs to the object store. The job never deleted f3: snapshot 41 still lists it, and a cleanup step removes it only after 41 expires.
Multiple Choice

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

✓Directory of files
  • 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
✗Lakehouse table
  • 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
Copy every data file of a lakehouse table into a fresh bucket and leave the metadata behind: what you hold is a pile of files that has lost its schema and its version history, and nothing says which files were ever committed. Every capability in the rest of this lesson is a change to metadata. The data files are open columnar files any engine can read; the metadata turns them into a table many jobs can share.
Multiple Choice

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?

Snapshot Isolation and Time Travel

Daily Life
Interviews
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 data files, then tries to swap the pointer from the snapshot it read to its own. This is optimistic concurrency: assume nobody else committed, and check at the swap. When the swap fails, the writer reads the snapshot that beat it and asks whether that commit touched anything its own commit depends on.
TimeWriter A: web ordersWriter B: app ordersPointer
10:00reads 42reads 4242
10:02writes 3 fileswrites 2 files42
10:03swap 42 to 43 succeeds43
10:03swap 42 to 43 fails43
10:0343 only added files: rebuild metadata on 4343
10:04swap 43 to 44 succeeds44
Writer B rewrote 1 small metadata file and reused its 2 data files untouched. The retry stays cheap while the 2 commits are independent. When they overlap, the loser cannot rebase, because its output was computed from data that has since changed:
Winner committedLoser attemptedOutcome
append to 2026-10-24append to 2026-10-24rebase metadata and commit
append to 2026-10-23overwrite 2026-10-23conflict: the overwrite missed new rows, recompute
overwrite 2026-10-23overwrite 2026-10-23conflict: the file to replace is gone, recompute
compaction of 2026-10-22append to 2026-10-22rebase: the append removes no files
Multiple Choice

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

Retained snapshots stay queryable. The history is in the snapshots metadata table. Adding a version or timestamp clause to a query pins it to an older state.
SELECT
snapshot_id,
committed_at,
operation
FROM "orders$snapshots"
ORDER BY committed_at DESC
LIMIT 4
snapshot_idcommitted_atoperation
442026-10-24 10:04append
432026-10-24 10:03append
422026-10-24 02:15overwrite
412026-10-23 23:50append
SELECT
order_date,
SUM(amount) AS revenue
FROM orders FOR VERSION AS OF 41
GROUP BY order_date
SELECT
COUNT(*) AS order_count
FROM orders FOR TIMESTAMP AS OF TIMESTAMP '2026-10-24 09:00:00 UTC'
Store the snapshot id beside every published report, and the report can be regenerated exactly while that snapshot is retained. Compare 2 snapshots to see which rows a job changed. Roll back a bad commit by pointing the table at an earlier snapshot, a metadata commit that copies no data.

Retention sets the horizon

Snapshot expiry deletes snapshots older than the retention window, then deletes data files that no surviving snapshot lists. Retention is therefore a cost knob. If the nightly job overwrites a 50GB partition and retention is 7 days, the table keeps up to 7 replaced copies, about 350GB beyond its live size. A query pinned to an expired snapshot fails with an error.
Setting retention
  • ▸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.
Multiple Choice

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?

Schema Evolution Without Rewrites

Daily Life
Interviews

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:

{
"current_schema_id": 2,
"schemas": [
{
"schema_id": 1,
"fields": [
{
"id": 1,
"name": "order_id",
"type": "bigint"
},
{
"id": 2,
"name": "order_date",
"type": "date"
},
{
"id": 3,
"name": "amt",
"type": "integer"
},
{
"id": 4,
"name": "legacy_flag",
"type": "boolean"
}
]
},
{
"schema_id": 2,
"fields": [
{
"id": 1,
"name": "order_id",
"type": "bigint"
},
{
"id": 2,
"name": "order_date",
"type": "date"
},
{
"id": 3,
"name": "amount",
"type": "bigint"
},
{
"id": 5,
"name": "currency",
"type": "varchar"
}
]
}
]
}
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:
File written under schema 1
field idname in filevalue
1order_id1007
2order_date2023-04-02
3amt2500
4legacy_flagtrue
Row returned under schema 2
field idcolumnvalue
1order_id1007
2order_date2023-04-02
3amount2500, as bigint
5currencyNULL
ChangeStatementData files rewritten
AddALTER TABLE orders ADD COLUMN currency varchar0: old files read NULL for id 5
RenameALTER TABLE orders RENAME COLUMN amt TO amount0: id 3 keeps its bytes
DropALTER TABLE orders DROP COLUMN legacy_flag0: id 4 bytes stay, unread
Wideninteger to bigint on id 30: values upcast on read
Multiple Choice

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

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 rewriting, move readers over, then drop the old one.
✓Metadata-only
  • integer to bigint
  • real to double
  • decimal(10,2) to decimal(12,2)
  • required column to optional
✗Not metadata-only
  • bigint to integer
  • varchar to integer
  • decimal(10,2) to decimal(10,4)
  • optional column to required
Multiple Choice

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.

The Small Files Problem

Daily Life
Interviews

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.

28,800
files written per day
1.5MB
average file size
2.6M
files in manifests after 90 days
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 table lists every data file in the current snapshot with its size and row count, so diagnosis is a query:
SELECT
COUNT(*) AS file_count,
ROUND(AVG(file_size_in_bytes) / 1048576.0, 1) AS avg_file_mb,
COUNT_IF(file_size_in_bytes < 16777216) AS files_under_16mb
FROM "events$files"
Signalevents nowAt 256MB
Files per hour partition1,200about 7
Average file size1.5MBabout 256MB
Files in manifests after 90 days2.6Mabout 15,000
Planning time as the table agesgrowsgrows about 170x slower
Read the signals together. An average far below target with thousands of files per partition means writer fan-out, and planning time that climbs month over month while bytes scanned stay flat points at file count rather than data volume.
Multiple Choice

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.

TIP
Fix the writer as well. Routing rows by partition key before the write lets 1 task write each hour's data per batch, which yields 60 files of 30MB an hour. That is still below target, so compaction stays, with 20 times fewer files to merge.
Multiple Choice

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?

Choosing Storage Across Workloads

Daily Life
Interviews

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 patternQuery shapeLatencyStore
Shopper app1 row by order_id, read and updateunder 20msOperational database
Finance, data sciencefull scans of history, pinned to a snapshotminutesLakehouse archive
Analyst dashboardsaggregates by store and category, 300 usersunder 1sWarehouse mart
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

Serving
Archive
Mart
3
Shopper app
000 writes/s
row store
Operational DB
90 daysSLAlive
batch job
Hourly extract
lakehouse
orders raw
append onlySLA1 hour
batch job
Nightly curate
lakehouse
orders curated
daily overwriteSLAdaily
time travel queries
Finance and DS
pins 1 snapshot id
Mart build
warehouse table
revenue mart
SLAby 06:00
300 analysts
Dashboards

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.

Multiple Choice

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

StressWhat breaksProperty that prevents it
Mart build runs while the nightly job commitsrevenue mixes 2 versions of a daysnapshot isolation: the build pins 1 snapshot id
Mart build rerun after a failurerows appended twice, revenue doubledthe build replaces the mart from 1 pinned snapshot
Operational database adds tip_amountthe extract drops the new fieldADD COLUMN in lakehouse metadata, old rows read NULL
Upstream renames store to store_ida rename looks like a drop plus an addextract maps names; lakehouse renames by field id
24 hourly extracts a day into orders_rawsmall files slow every history scannightly compaction of yesterday's partition
300 analysts query orders_curated directlyper-file costs on every chart refreshthe 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.

Multiple Choice

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?

PUTTING IT ALL TOGETHER

> 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.

Minute-level commits from many writer tasks fill each hourly partition with small files, so the archive needs compaction on closed hours and fewer writer tasks per hour.
Compaction and streaming appends commit side by side because appends remove no files; a dispute correction that rewrites trip files is the commit that can conflict.
The morning mart build pins 1 archive snapshot and stores its id, so finance can reproduce a published number for as long as retention covers the gap.
The new surge_reason column lands as an ADD COLUMN with a fresh field id: older trips read NULL and no file is rewritten.
The mart stays a disposable copy rebuilt from the archive, while the operational database keeps only the trips the apps still touch.
KEY TAKEAWAYS
A lakehouse table is the file list in its current snapshot; the catalog pointer swap is the single atomic step that makes every commit all or nothing.
Each query pins 1 snapshot, and writers commit optimistically: appends rebase cheaply, while overlapping overwrites must recompute.
Time travel reaches back only as far as retention, and retention costs the storage of every replaced file.
With field ids, adding or dropping a column is a metadata-only change, and so is renaming or widening one; narrowing a type and re-adding a dropped name are not.
File count drives the small files problem. Diagnose it from the files metadata table, then compact closed partitions and cut writer fan-out at the source.
Give each access pattern a store that meets its latency and concurrency needs and keeps the history it requires, and keep derived stores rebuildable from the lakehouse.

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

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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