Stop losing data when split-and-merge compaction stalls mid-way - #4
Merged
Conversation
Split-and-merge compaction can leave a group's sharded split output at
level 2 forever: the merge stage only runs when a shard has two or more
blocks, so a range whose split produced a single block per shard never
gets promoted. That is not a rare corner - it happens whenever ingestion
is scheduled rather than continuous, because the "don't compact the most
recent blocks prematurely" guard rejects the merge job for the last open
range and no later block ever arrives to un-gate it.
Once the level 1 ancestors are deleted, those level 2 blocks are the only
copy of the data, and the querier dropped them unconditionally: sharded
blocks below the deduplication level were treated as always superseded,
with no check that anything survived to serve the range. An hour of
profiles silently returned nothing.
Three changes, all pulling in the same direction:
- The querier only prefers a block's ancestors over the intermediate
block itself when those ancestors are actually still there. If they
are gone, it serves the intermediate block and deduplicates, and
warns so the stall is visible. Completeness of the sharded set is
re-checked over what survives the collapse, so a partial fallback
still yields an empty plan rather than half the data.
- The deduplication level (2, or 3 when sharding is enabled) becomes a
single shared definition in pkg/phlaredb/block instead of a constant
hardcoded on both sides, so the querier and the compactor cannot
disagree about which blocks are authoritative.
- The compactor plans a merge for a lone sharded block that is still
below the deduplication level, promoting it instead of leaving it
stuck. This terminates because the output is one level higher, and
the general "not enough blocks to compact" guard is relaxed only for
that deliberate single-block promotion.
The premature-compaction guard now takes its reference point from the
most recent block of the group being planned, not of the whole tenant.
Groups are independent streams written by unrelated services on
unrelated schedules; taking the maximum across all of them let whichever
group had the freshest data un-gate compaction for every other group,
which is how the half-compacted range above came about in the first
place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blocks-storage.bucket-store.symbol-cache-max-bytes was added without rerunning `make reference-help`, so TestHelp/all fails on every branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same omission as the reference help: blocks-storage.bucket-store.symbol-cache-max-bytes was added without rerunning `make generate`, so the check-generated CI job reports a dirty tree on every branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Problem
Split-and-merge compaction can leave a group's sharded split output at level 2 permanently. The merge stage only runs when a shard holds two or more blocks, so a range whose split produced exactly one block per shard is never promoted.
That is not a rare corner. It happens whenever ingestion is scheduled rather than continuous: the "don't compact the most recent blocks prematurely" guard (
rangeEnd <= highestMaxTime) rejects the merge job for the last open range, and once ingestion stops for the day no later block arrives to un-gate it.Once the level 1 ancestors are deleted, those level 2 blocks are the only copy of the data — and the querier dropped them unconditionally.
pruneSupersededBlockstreated any sharded block below the deduplication level as superseded, with no check that anything survived to serve the range. An hour of profiles silently returned nothing, with no error and no log line.Two details made it worse:
job.maxTime()-job.minTime() == job.rangeLength(), is inherited from Mimir where a TSDB block'sMaxTimeis the range boundary. In PyroscopeMaxTimeis the newest profile's timestamp, so the clause is effectively unsatisfiable and the guard collapses torangeEnd <= highestMaxTime.highestMaxTimewas computed over the whole tenant while planning is per main group, so an unrelated group's fresher data decided whether a group whose range was still open got compacted.Changes
Querier — serve intermediate blocks when there is no fallback (
pkg/querier/replication.go)pruneSupersededBlocksnow prefers a block's ancestors over the intermediate block only when those ancestors are still present. If they are gone, the intermediate block is kept and the query deduplicates, with a warning so the stalled compaction is visible.The shard-completeness check is split in two: once at the deduplication level before the collapse (so an incomplete deduplicated set is pruned while its ancestors are still around as a fallback), and once at level 0 afterwards over whatever survived. The second pass is what preserves the existing protection against serving a partial set — if only some shards have a usable fallback, the plan is empty rather than half the data.
A single definition of the deduplication level (
pkg/phlaredb/block/dedup.go)Level 2, or 3 when split sharding is enabled, was hardcoded on both the querier and compactor sides. It is now one shared definition, so the two cannot disagree about which blocks are authoritative.
Compactor — promote a lone sharded block (
pkg/compactor/split_merge_grouper.go,pkg/phlaredb/compact.go)A merge job is now planned for a single sharded block that is still below the deduplication level, instead of skipping it forever. This terminates because the output is one level higher. The
len(shardBlocks) < 2check stays for every other case — it is the planner's only termination condition.phlaredb.CompactWithSplittingrejects single-block compactions as no-ops; that guard is relaxed only via an explicitAllowSingleBlockoption, which the block compactor sets solely for this deliberate promotion.Compactor — scope the premature-compaction guard to the group (
pkg/compactor/split_merge_grouper.go)highestMaxTimeis taken from the most recent block of the group being planned rather than of the whole tenant. Groups are independent streams written by unrelated services on unrelated schedules. This makes the filter strictly stricter, never more permissive.Tests
pkg/querier/replication_test.go— intermediate sharded blocks kept when ancestors are gone (with deduplication) and dropped when they are not; dropped when a deduplicated block supersedes them; the partial-fallback case still yields an empty plan; the warning is emitted only when an intermediate block actually has to be served.pkg/compactor/split_merge_grouper_test.go— a lone sharded block below the deduplication level is merged; one at or above it, or with no compaction metadata, is not; a group's open range is not compacted just because another group has fresher blocks, and is compacted once its own range closes.pkg/phlaredb/compact_test.go— single-block compaction is rejected by default, allowed withAllowSingleBlockand produces a block one level higher, and an empty input is always rejected.go test ./pkg/querier/... ./pkg/compactor/... ./pkg/phlaredb/...andmake lintpass.🤖 Generated with Claude Code