fix(api): merge keyed (object-shaped) bucket aggs across multi-index search - #103
Merged
Merged
Conversation
…search
Multi-index search runs the same aggregation independently per index
and merges the per-index results. `merge_bucket_agg` only handled
ARRAY-shaped `buckets` (`terms`, `date_histogram`, ...) — it required
`.as_array()` and returned `None` immediately otherwise. A KEYED
multi-bucket agg — `filters` in its default named-filter form, or
`terms`/`range`/`date_range` with `keyed: true` — represents `buckets`
as a JSON OBJECT (bucket name -> bucket body), not an array. For that
shape `merge_bucket_agg` always returned `None`.
The caller's fallback ("replace only if the existing result had empty
buckets") made the identical mistake — it also called `.as_array()` on
the keyed buckets object, always got `None`, and so `should_replace`
was always `false`. Net effect: across N indices, only the FIRST
index's contribution to a keyed bucket ever survived. Every other
index's matching documents were silently dropped from the merged
aggregation, even though `hits.total` summed correctly across those
same indices — the query matching was fine, only the aggregation merge
was broken.
Found live while root-causing an OpenSearch Dashboards Timeline
(Timelion) panel: Timelion selects which index's documents to count
using a `filters` aggregation with a `query_string` filter, run across
`_all`. Reproduced directly: `doc_count` on the target index's filter
bucket was 0 despite the index alone holding thousands of matches,
because whichever index xerj happened to process first (almost never
the one actually being searched for) "won" and every later index's
real contribution was discarded.
Fixed by adding a keyed-object merge path to `merge_bucket_agg`,
parallel to the existing array path: sum `doc_count` per matching
object key, recursively merge nested sub-aggregations the same way the
array path already does, and add any key present in only one side.
Falls through to the pre-existing array path unchanged for every
non-keyed aggregation.
This was the actual root cause of the Timeline panel investigation —
combined with the two prior commits in this chain (query_string
default-field, PR xerj-org#102; X-OpenSearch-Version header, PR xerj-org#101), the
panel now renders correctly end-to-end, verified live in a browser
against a real xerj-backed OpenSearch Dashboards instance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL
This was referenced Aug 1, 2026
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.
Summary
This is the actual root cause of an OpenSearch Dashboards Timeline (Timelion) panel that rendered empty against xerj. Multi-index search runs the same aggregation independently per index and merges the per-index results —
merge_bucket_agg, the merger for multi-bucket aggregations, only handled array-shapedbuckets(terms,date_histogram, ...). A keyed multi-bucket agg —filtersin its default named-filter form, orterms/range/date_rangewithkeyed: true— representsbucketsas a JSON object (bucket name → bucket body), not an array. For that shape the function always returnedNone(.as_array()on an object isNone).The caller's fallback path made the identical mistake (also called
.as_array()on the keyed buckets object), soshould_replacewas alwaysfalse. Net effect: across N indices, only the first index's contribution to a keyed bucket ever survived — every other index's matching documents were silently dropped from the merged aggregation, even thoughhits.totalsummed correctly across those same indices. The query matching was fine; only the aggregation merge was broken.How this was found
Root-causing an OpenSearch Dashboards Timeline panel across three layers, in order:
query_stringpicking only the first text field with nodefault_field(fix(engine): query_string with no default_field searches every text field #102) — real, but insufficient alone.X-OpenSearch-Versionmissing from xerj's responses (fix(api): send X-OpenSearch-Version on responses to OpenSearch callers #101) — real, also insufficient alone.doc_count: 0for the target index'sfiltersbucket on a multi-index (_all) search — while the identical query on a single index returned the correct count. That isolated it to the merge step, not query execution.Traced to
merge_bucket_agg: reproduced directly —filters+ field-lessquery_string, run on_allacross 26 indices, one of which alone holds ~14,074 matching documents, returneddoc_count: 0for that filter bucket. Whichever index xerj happened to process first (almost never the one actually being searched for — most of the 26 indices are internal.xerj_*/.kibana_1system indices) "won", and the real index's contribution — sent later in iteration order — was discarded by the broken merge.Fix
Added a keyed-object merge path to
merge_bucket_agg, parallel to the existing array path: sumdoc_countper matching object key, recursively merge nested sub-aggregations the same way the array path already does (reusingmerge_metric_agg/merge_bucket_agg/merge_single_bucket_agg), and add any key present in only one side. Falls through to the pre-existing array path unchanged for every non-keyed aggregation — zero behavior change forterms,date_histogram,histogram,range(non-keyed),composite, etc.Test plan
cargo fmt --checkcargo clippy -p xerj-api --all-targets -- -D warningscargo test -p xerj-api --lib(112/112)keyed_filters_agg_sums_doc_count_across_indices,keyed_filters_agg_merges_nested_sub_aggregation,keyed_filters_agg_adds_new_key_from_second_indexdoc_countwent from0to the correct3769,avg(bytes)buckets went from all-null to 143/171 real values. Confirmed in the browser: the "(Timeline) Avg bytes over time" panel — empty since before this investigation started — now renders a populated line chart, matching its TSVB sibling panel.🤖 Generated with Claude Code
https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL