docs/internals/adr/gaps/011-no-legacy-promotion-planner.md
Status: Open
Discovered: 2026-05-18
Context: Codex review on PR #6423 (feat(merge): legacy promotion path + body-col schema evolution) flagged that the promotion path is wired end-to-end at the library + executor layer but has no production trigger at the planner / policy level.
The streaming Parquet merge stack now contains a complete legacy promotion pipeline:
ParquetMergeOperation::promote_legacy(splits, target_prefix_len) constructs an operation with
target_prefix_len_override = Some(target).merge::execute_merge_operation routes each input through LegacyInputAdapter when its
declared rg_partition_prefix_len < target and through StreamingParquetReader otherwise. The
streaming engine then sees a homogeneous stream advertising prefix_len = target on every
input.ParquetMergeExecutor (in quickwit-indexing) detects target_prefix_len_override.is_some()
and routes those merges through execute_merge_operation (with LocalFileByteSource) instead
of the in-memory merge_sorted_parquet_files path.merge_parquet_split_metadata accepts a mixed_prefix_ok: bool flag so the post-merge
aggregator skips the input-side equality check.What's missing: nothing in the planner ever creates a promote_legacy operation in
production. MergePolicyState::record_split buckets each split by
CompactionScope::from_split, and that scope key includes rg_partition_prefix_len. Legacy
splits (prefix_len = 0) and aligned splits (prefix_len > 0) therefore land in different
buckets before ParquetMergePolicy::operations ever runs. The production policy then iterates
each bucket independently and emits ParquetMergeOperation::new (regular merge). A repo-wide
search finds promote_legacy only in tests.
In a mixed deployment (legacy + aligned splits coexisting), legacy splits therefore stay in
their prefix_len = 0 bucket forever — never gaining the prefix alignment that downstream
locality compaction depends on. The promotion plumbing is reachable only from tests.
quickwit-parquet-engine/src/merge/policy/mod.rs: ParquetMergePolicy::operations calls
ParquetMergeOperation::new(...) only. promote_legacy is constructed only by tests in the
same file.MergePolicyState::record_split keys its BTreeMap by CompactionScope::from_split. The
scope derivation includes rg_partition_prefix_len, so a legacy split and a prefix-aligned
split with otherwise identical sort fields / window / merge level are never compared by the
policy.scratch.merge_operation.target_prefix_len_override .is_some()) routes promotion through execute_merge_operation. Library coverage at
test_promote_legacy_executor_end_to_end exercises a prefix_len = 0 + prefix_len = 1 pair
successfully. But that operation is only ever constructed inside the test.In both cases, the design separates the trigger (decision to promote) from the mechanism (how the promotion is performed). Quickwit currently has the mechanism but not the trigger.
CompactionScope::from_splitDrop rg_partition_prefix_len from the scope key (or normalize it to a target value before
bucketing). The policy then sees legacy and aligned splits as candidates for the same
compaction operation and ParquetMergePolicy::operations decides whether to emit a regular
merge or a promote_legacy operation based on whether the bucket contains mixed prefix
lengths.
Simplest change, but requires the policy to detect mixed-prefix buckets and choose between
new and promote_legacy per operation.
Run a separate pass before the regular compaction policy that scans for legacy splits and emits
promote_legacy operations for them. The regular policy then sees only aligned splits.
Cleaner separation of concerns, but means legacy splits are migrated before any opportunity to coalesce them with aligned neighbors in a single multi-input merge — possibly more work overall.
Keep scope bucketing as in option A. Inside the policy, when a bucket contains mixed prefix
lengths AND has enough splits to merit a multi-input merge, emit promote_legacy. When only
legacy splits exist (no aligned neighbor), emit promote_legacy with the same target — single-
input promotion is still valuable because it converts the file to the new format for future
locality compaction.
Most flexible; gives the policy the freedom to amortize promotion cost when there are aligned neighbors AND to still promote isolated legacy splits in the background.
Primarily affects metrics in the near term: the legacy split format pre-dates the prefix-aligned RG layout, and only metrics has both formats in flight today. Traces and logs on the Parquet path will eventually reach the same state if a layout change ever happens; the same planner machinery would cover them.
Promotion is strictly more expensive than a regular merge: the legacy adapter buffers the full input file in memory and re-encodes it as a single-RG stream before the merge engine sees it. For 50 MB metrics splits this is acceptable; for larger inputs the in-memory buffer is the gating cost.
The planner should account for this when scheduling — promotion is best amortized into a multi-input merge rather than performed as a standalone file rewrite. Option C's "prefer multi-input promotion, fall back to single-input" structure captures this.
quickwit-parquet-engine/src/merge/policy/, quickwit-parquet-engine/src/merge/mod.rs (MergePolicyState::record_split + CompactionScope).legacy_splits_pending_promotion and promotion_operations_emitted so we
can observe the policy in production.4311184497 (raised the gap).test_promote_legacy_executor_end_to_end in quickwit-parquet-engine::merge::streaming —
library-level coverage of the mechanism.