Small Files and Compaction: Advanced
What you will be able to do
- Slower over time, same volumeA scan grew from seconds to minutesBytes per day are steady, so the layout changed. Ask when the write cadence last moved; if it didn't, check the writer count and the partition key.
- Planning outlasts executionThe engine idles before its first taskListing keys and reading per-file metadata is work that scales with file count and runs before a single row is read.
- Requests outgrow storageThe bill moves while the terabytes holdObject stores charge per request, so a layout with 2,400 times more files issues roughly 2,400 times more reads per scan.
- Review this streaming designA writer commits every minuteNo symptom is given. You are expected to predict the file count before anyone measures it, by multiplying commits by tasks and by the partitions each commit touches.
- A fine-grained partition keyHour and city, or a partition per customerEach partition owns its files, so bytes per partition shrink while file count multiplies with every new key value.
Diagnose with file counts first
Per partition, ask for the file count and the median file size, with total bytes alongside them. Then compare the median with a target size. For columnar files scanned in bulk, teams commonly choose between 128 MB and 512 MB; this lesson uses 256 MB. A table format exposes a queryable file inventory, and a plain directory needs a listing job to build one. Given an inventory table trip_events_files with 1 row per data file, a single query is enough to make the diagnosis.
| event_day | file_count | median_mb | total_gb |
|---|---|---|---|
| 2026-10-22 | 288,000 | 0.1 | 30.1 |
| 2026-10-21 | 288,000 | 0.1 | 29.8 |
| 2026-07-14 | 4,800 | 6.4 | 30.2 |
Every 1-minute batch fans out to 200 writer tasks, and each task writes 1 file into the current hour of trip_events: 12,000 files an hour, none of them ever merged.
Scope the answer before giving it
- ▸Diagnose: file count and median size per partition, measured against a target size.
- ▸Explain: what each file costs in requests and listing, plus scheduling and metadata overhead, with numbers.
- ▸Prevent: write fewer files by committing less often, or by changing how rows are distributed across tasks or how the table is partitioned.
- ▸Compact: bin-pack or sort what remains, selected from metadata, at a stated rewrite cost.
- ▸Operate: keep commits atomic when writers conflict, handle late data and schema change, and expire old snapshots.
Try it. Before the next section does the arithmetic, estimate it yourself: each file costs 2 requests, footer then data, at 20 ms each, and the cluster has 100 reader slots. How long does opening yesterday's 288,000 files take before any row is decoded?
Every file carries a fixed price
| Cost | Paid once per | How it grows with file count |
|---|---|---|
| Listing | page of up to 1,000 keys | Serial list calls during planning on a plain directory |
| Footer read | file | 1 request, then decoding schema and column statistics |
| Data read | file | At least 1 more request and its first-byte latency |
| Task scheduling | split | Engines pack small files into splits, so task count stays bounded |
| Metadata entry | file | A manifest or catalog row the planner must read and filter |
| Encoding | file | Dictionaries and statistics restart, so compression weakens |
One day of trip_events in 3 layouts
| Layout | Files per day | Average file | Requests | Request wait | Listing | Total |
|---|---|---|---|---|---|---|
| 1-minute batches, 200 tasks | 288,000 | 110 KB | 576,000 | 115.2 s | 14.4 s | 133 s |
| 1-hour batches, 200 tasks | 4,800 | 6.4 MB | 9,600 | 1.9 s | 0.25 s | 5.2 s |
| Compacted to 256 MB | 120 | 256 MB | 240 | 0.05 s | 0.05 s | 3.2 s |
Try it. Extend the 1-minute layout to a 30-day scan under the same assumptions. How many files and requests does it touch, and how many minutes does it spend waiting on requests?
Answer: 8.64 million files, 17.28 million requests and 3,456 seconds of wait, roughly 58 minutes, plus 8,640 list calls that take 7.2 minutes on their own.
Why split packing does not rescue you
Costs that never show up in a latency chart
- Weaker compression. A 110 KB file restarts its dictionaries and stores its own schema and statistics, so the same rows take more bytes than inside a 256 MB file.
- Pruning that costs more than it saves. The planner tests a filter against 288,000 sets of min/max values, and files cut from arrival order span nearly the full range of every column, so few get skipped.
- Planning metadata. A table format records each file as a manifest entry: 288,000 a day, 8.64 million for a 30-day scan, all read before the first task starts.
- Writer overhead. Each file is opened, flushed, closed and recorded at commit, so the writer spends 200 file lifecycles a minute on 21 MB of data.
- Downstream spread. A job that reads trip_events and writes 1 output file per input split copies the layout into the next table.
Bin-packing or sorting the output
- Groups input files by size into output files near 256 MB
- No shuffle: a task concatenates the rows of its group
- Per hour of trip_events: read 1.25 GB, write 1.25 GB
- Fixes request and listing cost, and metadata cost with it
- Leaves column statistics as wide as the inputs were
- Shuffles the partition's rows by a key such as city_id, then writes
- Each output file covers a narrow key range, so min/max pruning works
- Pays a full shuffle plus sort memory for every partition it rewrites
- A 1-city filter on a 5-file hour opens 1 file instead of 5
- Later appends arrive unsorted, so the order decays until the next run
Select files from metadata, not from the clock
A file is a candidate when it is under 75% of target, here 192 MB. A partition qualifies when it holds at least 5 candidates, so a partition with 1 straggler is left alone. Files already near target are never selected, and that rule is what keeps repeated runs from rewriting the same bytes.
Cadence and write amplification
| Schedule | Bytes rewritten per byte ingested | Small files a reader may hit | Contention with writers |
|---|---|---|---|
| Every 10 min, rewrite the whole open hour | 3.5 | about 2,000 | Every run races the minute appends |
| Every 10 min, candidates under 192 MB only | 1.0 | about 2,000 | Every run races the minute appends |
| Once, after the hour closes | 1.0 | up to 12,000 | Late writers only |
| Once a day | 1.0 | up to 288,000 | Late writers only |
Sizing the job and the target
- Select files under 75% of target from metadata, whatever their age
- Require at least 5 candidates before rewriting a partition
- Sort only on columns the top queries filter on
- Cap input bytes per run so backlogs drain in bounded chunks
- Rewrite the whole open partition every few minutes
- Choose partitions to compact by wall-clock hour alone
- Sort on a key that no predicate uses
- Run 1 job across a whole backlog and lose it all to a conflict
Files written per commit are at most the writer tasks times the partitions each task touches. The baseline writer has 200 tasks, each touching 1 hour partition, committing every minute: 200 files a minute. Every fix below shrinks one factor or the number of commits.
| Change | Files per hour | Average file | What it costs |
|---|---|---|---|
| Baseline: 1-minute commits, 200 tasks, rows in arrival order | 12,000 | 110 KB | Nothing up front; every reader pays |
| Shuffle rows by partition_hour before writing | 60 | 21 MB | A shuffle per batch; 1 task carries the whole hour |
| Also stretch commits to every 5 minutes | 12 | 107 MB | Data is up to 5 minutes old instead of 1 |
| Also rewrite each closed hour at 256 MB | 5 | 256 MB | A second write of every byte; curated data lags up to 1 hour |
Distribute rows before the write
Backfills multiply the same bound
Over-partitioning sets a floor compaction cannot lift
Split freshness from layout with a raw tier
Raw keeps 1-minute freshness with small files. The hourly rewrite rebuilds each touched hour into 256 MB curated files, and the dashboard view reads the current hour from raw.
- ▸Retry: a writer that dies after writing files but before committing leaves orphans; without an atomic commit, its retry duplicates the batch.
- ▸Late data: late events still write 1 small file per commit into an old hour, so prevention never removes the need to compact.
- ▸Schema change: a rewrite job that pins yesterday's schema writes curated files without the column producers added today.
- ▸Skew: hashing by hour lands a holiday spike or a lopsided backfill on 1 writer task.
Simplified metadata: real formats also keep a manifest list per snapshot, and a manifest written by one snapshot stays shared by the snapshots after it.
Directory swap versus commit
- Writes 5 new files, then deletes 12,000 old ones
- A reader listing in between sees both and counts every trip twice
- A retry after a partial delete can't tell which files it already removed
- Every reader still lists keys to plan
- New files stay invisible until the snapshot pointer swaps
- Readers see the old 12,000 or the new 5, never both
- A failed or retried job leaves only unreferenced files for cleanup
- Planning reads file entries from manifests, with no listing
Optimistic concurrency and the compaction loop
| Concurrent operation | Conflicts with the rewrite? | Reason |
|---|---|---|
| Append to the same hour | No | New files are added; none of the removed files changed |
| Row-level delete touching a rewritten file | Yes | The new large file would resurrect the deleted rows |
| Second compaction of the same files | Yes | Both commits remove the same files; the later one must retry |
| Column added to the schema | Depends on the format | No conflict where columns have stable IDs and only data files are validated; formats that validate the whole table metadata reject the commit, and the rewrite retries from the new snapshot |
Metadata needs compaction too
Storage drops only after snapshots expire
- ▸Retry: needs an atomic replace commit, so a rerun either lands whole or leaves only orphans.
- ▸Late data: needs selection by file metadata, so old hours with new small files get found.
- ▸Schema change: needs column IDs and reads in the current schema, so rewrites keep new and renamed columns.
- ▸Concurrency: needs conflict checks on removed files, plus file-group commits to bound lost work.
- ▸Cost: needs snapshot expiry and orphan cleanup, or superseded files linger for the whole retention window.
> A food-delivery company streams order status events (placed, accepted, picked up, delivered) into a lake table partitioned by hour. 120 writer tasks commit every 30 seconds so an operations screen shows courier delays within a minute. Finance runs a nightly revenue job on the same table, a privacy service deletes a customer's rows within 24 hours of a request, and couriers in tunnels upload events up to 2 days late. After a year, the nightly job takes 4 hours instead of 20 minutes and the privacy deletes keep failing their commits.
File count, not data volume, sets scan time: measure it, stop it at the writer, and compact the rest safely.
- Category
- Pipeline Architecture
- Difficulty
- advanced
- Duration
- 25 minutes
- Challenges
- 2 hands-on challenges
Topics covered: "Why Is My Query So Slow?", Why Small Files Kill Performance, Compaction Strategies, Preventing Small Files at the Source, Compaction in Table Formats
Lesson Sections
- "Why Is My Query So Slow?" (concepts: Small File Problem)
Interviewers rarely say 'small files'. They describe a symptom and watch whether you reach for bytes or for objects. The signature is a mismatch: the data a query reads stays flat while its latency climbs, usually with planning time or request count climbing too. Whenever cost tracks the number of files instead of the number of bytes, you are in this question, whatever its wording. Diagnose with file counts first The July row predates the cadence change. Volume matches on both sides, while file
- Why Small Files Kill Performance
Here is the estimate worked through. Yesterday's 288,000 files at 2 requests each make 576,000 requests, and at 20 ms apiece that is 11,520 seconds of waiting. Spread over 100 slots, it comes to 115 seconds before a single row is decoded, on a scan whose data takes roughly 3 seconds to move. Every file carries a fixed price None of these depend on how many bytes the file holds. A 110 KB file and a 256 MB file each cost 1 footer read, at least 1 data request and 1 metadata entry, so a scan costs
- Compaction Strategies (concepts: Small File Problem)
Compaction reads many small files and commits fewer large ones in their place. You decide how output rows are laid out, which files are selected, and when the job runs. Each decision has a price measured in bytes rewritten, and the interviewer expects that price stated. Bin-packing or sorting the output Pick bin-packing by default and sorting when a filter on one key is frequent and selective. For filters on 2 or 3 columns at once, a space-filling curve interleaves the bits of each key so files
- Preventing Small Files at the Source (concepts: Small File Problem)
Compaction repairs a layout after the fact and pays a rewrite for it. Prevention changes how many files the writer creates in the first place, and it comes down to one bound. Read the table as a menu with prices. The first change spends no freshness and costs a shuffle, the second spends freshness, the third spends storage and compute. A strong answer picks the cheapest combination that meets the consumer's stated freshness, instead of applying all 3 by reflex. Distribute rows before the write S
- Compaction in Table Formats
A table format replaces 'the files in this directory' with a log of snapshots. Each snapshot points to manifests, and each manifest lists data files, recording for each one its partition values and row count along with column statistics. A reader resolves one snapshot and reads exactly the files it lists. Compaction becomes a commit like any other: it adds the new large files and removes the old small ones in one swap of the current-snapshot pointer. Simplified metadata: real formats also keep a