perf(store): bound a compaction pass by the bytes it admits - #256
Merged
Merged
Conversation
A sorted merge appends every row group of every input into one k-way merge, and parquet-go opens a cursor per row group and reads from all of them during initialisation, so every input's dictionaries are resident at once. The live cost of a pass therefore tracks the bytes it admits. Selection bounded rows (25M) and file count, neither of which predicts that. Row counts cannot stand in for bytes: a batch of wide spans carrying large attribute payloads and a batch of bare log lines can hold the same row count and differ by an order of magnitude on disk. With 128 inputs admissible, a generation-0 pass could hold several gigabytes live -- large enough to be the dominant allocator in the process, and large enough that an out-of-memory kill mid-merge is a plausible way to lose the box. Selection now also carries a 256 MiB ceiling on admitted bytes. A pair is always admitted however large, because a merge of two inputs is bounded by those two and refusing it would strand files that are individually over budget instead of ever shrinking them -- the same stranding the row ceiling's bounded-group behaviour exists to avoid. Past a pair the ceiling binds. Batch size is measured in loadStoredBatch from the os.Stat that validation already performs, so it costs no extra syscalls and needs no metadata version bump: BatchMetadata.Bytes is derived on load, not persisted. A batch that was never measured is charged a deliberately pessimistic per-row estimate rather than treated as weightless. TestSelectCompactionBatchesBuildsRowBoundedGroup and TestSelectCompactionBatchesCombinesSaturatedLargeFiles now declare explicit small sizes. Both exist to pin the row ceiling, and without a size the new estimate would have become their binding constraint and retargeted them silently.
vishr
force-pushed
the
perf/bound-compaction-by-bytes
branch
from
September 20, 2026 18:46
1338cda to
10e5576
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second item of #254. Follows #255.
Problem
mergeTypedParquetappends every row group of every input into one k-way merge, and parquet-go opens a cursor per row group and reads from all of them during initialisation. Every input's dictionaries are resident simultaneously, so the live cost of a pass tracks the bytes it admits.selectBoundedCompactionGroupbounded rows (25M) and file count. Neither predicts bytes. A batch of wide spans carrying large attribute payloads and a batch of bare log lines can hold identical row counts and differ by an order of magnitude on disk.With up to 128 inputs admissible (
internal/query/duck.go:104), a generation-0 pass could hold several gigabytes live. That makes compaction a strong candidate for the dominant allocator in the process — plausibly larger than ingest, and notably it fits the production kill pattern better, since production's 20ms admission windows produce small ingest batches but the same compaction passes.Change
A 256 MiB ceiling on admitted bytes, alongside the existing row and count ceilings.
A pair is always admitted, however large. A merge of two inputs is bounded by those two, and refusing it would strand files that are individually over budget instead of ever shrinking them. That is the same stranding the row ceiling's bounded-group behaviour already exists to avoid — the comment on
selectBoundedCompactionGroupcalls it out. Past a pair, the ceiling binds.I got this wrong on the first attempt: skipping any batch over the ceiling stranded large files permanently, and two existing tests caught it.
Measuring batch size
BatchMetadata.Bytesis populated inloadStoredBatchfrom theos.Statthat validation already performs — no extra syscalls, and no metadata version bump, because the field is derived on load rather than persisted (json:"-").A batch that was never measured is charged a deliberately pessimistic per-row estimate rather than treated as weightless. Over-charging costs smaller groups and more passes; under-charging costs the merge the ceiling exists to prevent.
Tests
TestSelectBoundedCompactionGroupStopsAtTheByteBudget— sixteen 64 MiB inputs, well inside the row and count ceilings. Asserts the byte ceiling binds and that a group is still produced.TestSelectBoundedCompactionGroupChargesUnsizedBatchesAnEstimate— unmeasured batches are priced, not free.Both written first and verified failing.
Two existing tests changed
TestSelectCompactionBatchesBuildsRowBoundedGroupandTestSelectCompactionBatchesCombinesSaturatedLargeFilesnow declare explicit smallBytes.Both exist to pin the row ceiling. Their fixtures carry no size, so the new estimate became their binding constraint and they began asserting the byte ceiling instead — passing or failing for a reason unrelated to their names. Declaring a small size keeps each test exercising the ceiling it is about.
Flagging this explicitly: their fixtures changed, their assertions did not.
Still open in #254
The restart loop is untouched here — an OOM mid-merge writes no marker (
compaction.go:134-141), so a clean restart reselects the same group and dies identically. This change makes that far less likely by shrinking the group; it does not stop the loop. Separate fix.just checkpasses.