Skip to content

fix(api): merge keyed (object-shaped) bucket aggs across multi-index search - #103

Merged
xerj-org merged 1 commit into
xerj-org:mainfrom
Vinz2168:fix/multi-index-keyed-agg-merge
Aug 1, 2026
Merged

fix(api): merge keyed (object-shaped) bucket aggs across multi-index search#103
xerj-org merged 1 commit into
xerj-org:mainfrom
Vinz2168:fix/multi-index-keyed-agg-merge

Conversation

@Vinz2168

@Vinz2168 Vinz2168 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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-shaped buckets (terms, date_histogram, ...). 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 the function always returned None (.as_array() on an object is None).

The caller's fallback path made the identical mistake (also called .as_array() on the keyed buckets object), 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.

How this was found

Root-causing an OpenSearch Dashboards Timeline panel across three layers, in order:

  1. First found and fixed query_string picking only the first text field with no default_field (fix(engine): query_string with no default_field searches every text field #102) — real, but insufficient alone.
  2. Then found X-OpenSearch-Version missing from xerj's responses (fix(api): send X-OpenSearch-Version on responses to OpenSearch callers #101) — real, also insufficient alone.
  3. With both applied, the exact real Timelion query (captured live via temporary request logging, removed before this PR) still returned doc_count: 0 for the target index's filters bucket 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-less query_string, run on _all across 26 indices, one of which alone holds ~14,074 matching documents, returned doc_count: 0 for 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_1 system 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: sum doc_count per matching object key, recursively merge nested sub-aggregations the same way the array path already does (reusing merge_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 for terms, date_histogram, histogram, range (non-keyed), composite, etc.

Test plan

🤖 Generated with Claude Code

https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL

…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
@xerj-org
xerj-org merged commit da8720b into xerj-org:main Aug 1, 2026
9 checks passed
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.

2 participants