Skip to content

do not prune intermediate aggregation results at the root - #6778

Open
DeviousCardi wants to merge 3 commits into
quickwit-oss:mainfrom
DeviousCardi:fix/6676-no-root-intermediate-pruning
Open

do not prune intermediate aggregation results at the root#6778
DeviousCardi wants to merge 3 commits into
quickwit-oss:mainfrom
DeviousCardi:fix/6676-no-root-intermediate-pruning

Conversation

@DeviousCardi

Copy link
Copy Markdown

Description

Closes #6676.

merge_intermediate_aggregation_result prunes merged terms back to segment_size on every call. That is right for a leaf, whose result still has to cross the network and be merged again upstream, but the root has no such consumer: finalization already prunes to the requested size. Pruning a second time drops counts that finalization could otherwise have used, and the loss is reported as a wider doc_count_error_upper_bound.

QuickwitCollector could not tell the two cases apart, because make_merge_collector is used both by leaf nodes merging their splits (leaf.rs) and by the root merging leaf responses (root.rs). This adds a MergeLevel that the caller sets, and prunes intermediate results only at MergeLevel::Leaf.

Following @trinity-1686a's suggestion on the issue:

we can recoup that by only running intermediate pruning on leaf nodes (i.e. tell the QuickwitCollector whether it's merging for a root or leaf node, and not run the pruning on root. Final pruning will happen through finalization anyway)

Where each level is set:

Call site Level
make_collector_for_split Leaf — collecting a single split
leaf.rs (2 sites) Leaf — merging splits within a leaf node
root.rs:844 Root — merging leaf responses
root.rs:1427 Root — only contributes warmup_info, never merges

One caveat worth stating: the improvement shows up once the root merges two or more leaf responses. merge_leaf_responses returns early when there is a single response, so a single-node deployment never reached the root pruning in the first place and is unaffected.

How was this PR tested?

test_merge_intermediate_terms_prunes_to_segment_size from #6674 is now parameterised over the merge level, and a second case covers the root:

  • Leaf — nine distinct terms merge down to segment_size (4), unchanged behaviour.
  • Root — all nine survive, so finalization still has the full candidate set to choose from.
cargo test -p quickwit-search
  test result: ok. 210 passed; 0 failed

The REST API aggregation scenarios were also run against a local build, and pass unchanged:

./run_tests.py --engine quickwit --binary ../target/debug/quickwit --test scenarii/aggregations
  🟢 scenarii/aggregations/0001-aggregations.yaml: 18 steps (0 skipped)
  🟢 scenarii/aggregations/0002-doc-len.yaml: 2 steps (0 skipped)
  🟢 scenarii/aggregations/0003-multi-terms.yaml: 2 steps (0 skipped)

Those scenarios run a single node, so their doc_count_error_upper_bound expectations are unaffected for the reason above and needed no update.

make fmt is clean.


Generated with Claude Opus 5, then reviewed and tested locally before submission.

@DeviousCardi
DeviousCardi requested a review from a team as a code owner September 8, 2026 03:37
Pruning to `segment_size` bounds what a leaf sends upstream, so it is worth
paying there. The root has no such consumer: finalization already prunes to the
requested `size`, and cutting the candidate set a second time discards counts
that finalization could still have used, which surfaces as an inflated
`doc_count_error_upper_bound`.

Give `QuickwitCollector` a `MergeLevel`, set by whoever builds it, and prune
intermediate results only when merging at a leaf.

The effect is visible once the root merges two or more leaf responses; a root
with a single leaf response returns it untouched and never reached the pruning
either way.
@DeviousCardi
DeviousCardi force-pushed the fix/6676-no-root-intermediate-pruning branch from 0fbc27d to d134c0e Compare September 8, 2026 03:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0fbc27d324

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread quickwit/quickwit-search/src/root.rs Outdated
let merge_collector = make_merge_collector(
search_request,
searcher_context.get_aggregation_limits(),
MergeLevel::Root,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pruning when root finalization is skipped

When a gRPC RootSearch caller sets skip_aggregation_finalization = true and this root merges two or more leaf responses, finalize_aggregation_if_any returns these intermediate bytes unchanged. Passing MergeLevel::Root here now bypasses intermediate pruning, so a high-cardinality terms aggregation can return roughly the union of every leaf's segment_size candidates rather than a set bounded to segment_size, defeating the response-size bound needed by multi-step callers and potentially exceeding transport limits. Select the leaf/intermediate merge level when finalization is skipped, reserving Root for results finalized locally.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, this was a real hole. finalize_aggregation_if_any returns the merged bytes untouched when skip_aggregation_finalization is set, so with Root there the result was left unbounded.

Fixed in b20d289. The root now picks pruning based on whether finalization will actually run:

fn root_intermediate_pruning(search_request: &SearchRequest) -> IntermediatePruning {
    if search_request.skip_aggregation_finalization {
        IntermediatePruning::Apply
    } else {
        IntermediatePruning::Skip
    }
}

This also showed that leaf-versus-root was the wrong thing to encode: what matters is whether anything downstream still bounds the result. The flag is now IntermediatePruning::{Apply, Skip} and reads the same way at every call site. Covered by test_root_intermediate_pruning_follows_finalization.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

`skip_aggregation_finalization` makes `finalize_aggregation_if_any` hand the
merged bytes straight back to the caller, so the root merge is then the only
place that can bound them. Skipping intermediate pruning there let a
high-cardinality terms aggregation return close to the union of every leaf's
candidate set.

Rename the flag to say what it decides rather than where it is used: the
question is whether anything downstream will prune the result, not whether the
merge happens at a leaf or at the root.
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.

doc_count_error_upper_bound is higher than necessary

1 participant