Skip to content

Prioritize L0 compaction with bounded slices - #35

Draft
byeongsu-hong wants to merge 11 commits into
orthory:masterfrom
byeongsu-hong:agent/bounded-priority-compaction
Draft

Prioritize L0 compaction with bounded slices#35
byeongsu-hong wants to merge 11 commits into
orthory:masterfrom
byeongsu-hong:agent/bounded-priority-compaction

Conversation

@byeongsu-hong

Copy link
Copy Markdown

What changed

  • run compaction rewrites in bounded, user-key-safe scheduling slices
  • let newly eligible upper levels preempt and later resume deeper jobs
  • route automatic maintenance and compact_all through the same scheduler
  • deepen the bottom level by moving existing runs in the manifest without rewriting SSTs
  • cover deep-job and bottom-splice preemption, ordering, tombstones, and reopen behavior

Why

A long deep-level rewrite held the only compaction executor until completion. L0 could reach its stall threshold during that window, blocking writers even though automatic compaction was running.

Impact

The default 1 MiB slice reduced median writer stall from 278.2 ms to 4.73 ms in a five-run 256 MiB L1-to-L2 compaction canary. Median total drain time was 291 ms before and 288 ms after; median peak RSS was 25.51 MiB before and 25.63 MiB after.

Jobs still publish atomically, no on-disk format changes, and no concurrent compaction workers are introduced.

Checks

  • cargo test --workspace
  • cargo test -p fluent31 --lib (73 passed)
  • cargo test --workspace --no-run
  • clippy with the repository's four pre-existing Rust 1.96 lints allowed
  • five-run baseline/patched priority benchmark plus 1/4/8/16/32 MiB slice sweep

byeongsu-hong and others added 11 commits July 29, 2026 16:37
Older MANIFEST files were swept only in open(); a long-running process
rotates a manifest every few minutes and never reopens, so stale
generations accumulate without bound — measured 441 files / 441 MB in
one production store after 31 hours of uptime (~14 GB across its 32
stores, ~10% of the volume).

save() now sweeps generations below the one it just flipped CURRENT to.
Best-effort by design: a crash mid-sweep leaves work for the next save,
and newer generations (pre-flip crash artifacts) are deliberately left
for the open-time sweep, which already handles them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(manifest): prune stale generations at save, not only at open
…entry allocs

The pinned index held one heap Vec<u8> per block entry; against ~40-byte
keys the Vec header + malloc header + size-class rounding roughly doubled
resident bytes, and a 148 GB production index pays that across ~30 M
entries (~3 GB observed). One concatenated keys arena + u32 ends + a
BlockRef vector keeps the same binary search over the same keys with no
per-entry allocations.

Existing multi-block tests (256-byte blocks, 500 keys, every key looked
up, forward/reverse iteration and seeks) cover the lookup equivalence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
perf(table): flat-arena block index — half the pinned heap
…e heap

Every open table pinned its whole bloom filter for life. At 10 bits/key
that is the single largest pinned population on a big store — measured
2.76 GB across one production deployment's 2,481 tables — and it grows
with data size, not load, so no cache budget ever governed it.

The filter now loads through the shared block cache under its natural
(file_id, filter.off) key: hot filters stay resident by being used, cold
tables cost nothing, and block_cache_size finally bounds every block the
reader touches. may_contain_ukey becomes fallible (a cache miss re-reads
the block); the two non-reader callers — the run-level check and the
compaction tombstone-drop predicate — propagate the error instead of
swallowing it. Corruption still surfaces at open via a verify-and-drop
read.

Quick bench probe (single run, examples/bench): cold gets -6.5% (the
added cache probe per lookup), everything else within noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
perf(table): bloom filters ride the block cache instead of pinning the heap
Compression grows a Zstd variant (codec byte 2, level fixed at 3 —
measured on real store data, level 8 buys ~0.5% for several times the
CPU) and Options grows bottom_compression: the codec for compaction
outputs landing in the deepest level, where ~90% of a store's bytes
live and rewrites are rarest. None means "same as compression", so
nothing changes without opting in.

Measured on two production tables (154 MB raw): zstd-3 stores 33% of
raw at 32 KiB blocks vs lz4's 41% — about a quarter less disk — at
2.3 GB/s single-thread decompress, which is noise on the read path.

Tables carrying a zstd block bump to format 3, so pre-zstd readers
reject them at open ("unsupported table format") instead of failing
mid-read on a codec byte. Per-block store-raw-if-not-smaller behavior
matches lz4, and the zstd payload is size-prefixed like lz4's.

The placement flag is wired at all three Job constructions and proven
through the production compaction path: with a 2-level tree every
output is the bottom, so the test asserts format-3 tables appear with
bottom_compression set and never without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(table): zstd codec + bottom-level compression placement
Land the production line: sorted bulk loading + four storage fixes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant