Skip to content

fix(engine): fast_aggs resolves <field>.keyword to its parent's column - #99

Merged
xerj-org merged 1 commit into
xerj-org:mainfrom
Vinz2168:fix/fast-aggs-keyword-multifield-column
Aug 1, 2026
Merged

fix(engine): fast_aggs resolves <field>.keyword to its parent's column#99
xerj-org merged 1 commit into
xerj-org:mainfrom
Vinz2168:fix/fast-aggs-keyword-multifield-column

Conversation

@Vinz2168

@Vinz2168 Vinz2168 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a real-data reliability bug found live: terms (and any other fast-path) aggregation on a <field>.keyword multi-field silently returns empty buckets once the underlying documents have been flushed to a segment — even though the field is correctly mapped and full of matching documents.

Root cause

fast_aggs.rs — the optimized columnar aggregation path used for segment-resident (flushed) documents — looks up a segment's doc-value column with a raw, exact-string cols.get(field) at 27 call sites. A text field's auto-created .keyword multi-field shares the same physical column as its parent, stored under the parent's unsuffixed name (verified on disk: a flushed segment has extension.fst/.meta/.norms/.post, never extension.keyword.*). So a request for extension.keyword always misses, the segment is silently treated as having zero rows for that field, and the aggregation returns empty — while hits.total on the very same request correctly reports thousands of matches.

The brute-force fallback path (aggs.rs) already has this exact .keyword → parent fallback (it walks _source directly, see flatten_to_strings), which is why the bug only shows up for segment-resident data — a fresh index answered entirely from the memtable works fine, masking the gap in local/small-scale testing.

How this was found

While investigating an unrelated empty dashboard panel report, isolated that the issue wasn't the panel's own aggregation shape (verified via curl, concurrent requests, and a persistent keep-alive connection — all correctly returned data for a date_histogram+avg query). Root-caused instead to terms aggregations on any field on that same long-running, heavily-ingested index returning empty buckets, while an identical query against a freshly created index worked — isolating it to segment-resident (fast-path) execution rather than a regression in the aggregation logic itself.

Fix

Added SegEntry::col(field), a single lookup helper that falls back to the .keyword-stripped parent column name, and replaced all 27 raw cols.get(...) call sites in fast_aggs.rs with it — covering every aggregation type that reads segment columns (terms, cardinality, composite, histograms, sorting, top_hits, etc.), not just the one that surfaced it.

Test plan

  • cargo fmt --check
  • cargo clippy -p xerj-engine -p xerj-api --all-targets -- -D warnings
  • cargo test -p xerj-engine --lib (288/290 — the 2 failures are the pre-existing, unrelated snapshot_path_security_tests failures also present on upstream/main)
  • cargo test -p xerj-api --lib (107/107)
  • New regression test: col_falls_back_to_keyword_multi_field_parent
  • Live verification: on the real long-running index, terms on extension.keyword went from {"buckets": []} (with XERJ_DISABLE_FAST_AGGS unset — i.e. the buggy fast path active) to correct real buckets (gz, css, zip, deb, ... with real doc counts), consistent across repeated queries, matching what XERJ_DISABLE_FAST_AGGS=1 (forcing the brute-force path) already returned correctly before the fix.

Note

A related, separate and larger gap was also found during this investigation: fast_aggs has no column at all for nested object fields (e.g. geo.dest, machine.os — only the parent geo/machine object has a column), so those still return empty via the fast path regardless of this fix. That's a bigger change (columnar storage would need per-subfield columns at flush time, not just a smarter lookup) and is intentionally out of scope here — flagging it rather than silently leaving it unaddressed.

🤖 Generated with Claude Code

https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL

Every fast-path aggregation (terms, cardinality, and everything else that
reads `SegEntry.cols`) did a raw, exact-string lookup on the requested
field name against a segment's on-disk columns. A text field's
auto-created `.keyword` multi-field shares the *same* physical column,
stored under the parent's (unsuffixed) name — confirmed on a real
flushed segment, where only `extension.*` files exist on disk, never
`extension.keyword.*`.

So `seg.cols.get("extension.keyword")` always missed, the segment was
silently treated as having zero rows for that field, and the
aggregation returned empty buckets — despite the field being real,
mapped, and full of data. Queries answered entirely from the memtable
(not yet flushed) were unaffected, since the brute-force path
(`aggs.rs`) already has this exact `.keyword` fallback for walking
`_source` directly; the gap was fast_aggs-specific, and only visible
once documents were flushed to a segment.

Found live: on a long-running index with real ingested data, `terms`
aggregations on `extension.keyword`, `geo.dest`, and `response.keyword`
all returned empty buckets, while the same query against a freshly
created (memtable-only) index worked fine — isolating the bug to the
segment-resident fast path.

Fixed by adding `SegEntry::col(field)`, a single lookup helper that
falls back to the `.keyword`-stripped parent name, and replacing all
27 raw `cols.get(...)` call sites in `fast_aggs.rs` with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL
@xerj-org
xerj-org merged commit 0d796b3 into xerj-org:main Aug 1, 2026
9 checks passed
xerj-org pushed a commit to Vinz2168/xerj that referenced this pull request Aug 1, 2026
…ields

**Stacked on xerj-org#99** (needs `SegEntry::col` from that PR — this branch is
based on it, not on a fresh `upstream/main`).

A genuinely nested JSON field (`geo.dest`, `machine.ram`, the
doubly-nested `machine.os.keyword`) has no column of its own in any
segment — and, contrary to my first attempt at this fix, neither does
its parent object (`geo`, `machine`): fast_aggs' `.dv` columns only
ever cover scalar leaf fields, so an object-typed field never gets a
column either, live or synthetic. xerj-org#99 fixed the adjacent case (a text
field's `.keyword` multi-field sharing its parent's column), but that
fix only strips a single trailing `.keyword` suffix — it doesn't help
a genuinely nested path with no `.keyword` suffix (`geo.dest`), or a
doubly-nested one where stripping `.keyword` still doesn't reach a
real column (`machine.os.keyword` -> `machine.os`, which also isn't a
column; nothing under `machine` is).

Every fast-path executor (`exec_terms`, `exec_metric_top`,
`exec_cardinality`, ...) keys off `params["field"]`, and each
independently treated "no column found" as "field genuinely absent" —
a valid ES outcome for a sparse/dynamic field — instead of "fast_aggs
structurally cannot serve this field, escalate to the brute-force
path that walks `_source` directly and resolves nested paths
correctly regardless of storage layout."

Found live across three of the shipped sample dashboards:
- `geo.dest` terms (`(Horizontal Bar)`/`(VisBuilder) Top destination
  count`, `(Region Map) Destination count`) — empty
- `machine.ram` avg (`(Vega) Average machine RAM` — threw "Cannot
  convert undefined or null to object"; `(Goal) Average machine RAM`
  — showed 0% instead of ~64%)
- `machine.os.keyword` terms (`(Heatmap)`/`(Vega) Source vs OS`,
  `[Logs] Visitors by OS`) — empty

In every case the TSVB sibling panel for the SAME field showed
correct data, because TSVB's request shape happens to bypass the
buggy fast path entirely — proving the data and the aggregation logic
were both fine; only the fast-path field resolution was broken.

Fixed with a single guard at `exec_agg`'s dispatch point (every
concrete `exec_*` function is called from here, keyed on
`params["field"]`): `field_needs_brute_fallback` returns true when a
dotted field resolves via neither the exact name nor the
`.keyword`-stripped fallback, AND its top-level root is mapped
`object`/`nested` in the index's SCHEMA (not, as a first attempt
tried and a live debug trace disproved, whether the root happens to
exist as a physical column — it never does, since `.dv` columns only
ever cover scalar leaves). The schema snapshot is taken once per
request under the existing async `schema.read().await` lock (the same
pattern already used for `bool_fields`) and handed to the sync
executor as a `HashSet<String>` of object/nested field names.

That combination positively identifies "real nested path fast_aggs
can't reach" and excludes "field genuinely never indexed" (whose root
isn't schema-mapped at all — a true absence, correctly served as
empty by the fast path with no fallback needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL
xerj-org pushed a commit that referenced this pull request Aug 1, 2026
…ields (#104)

`fast_aggs` resolved a nested field path against the segment column map and,
when the lookup failed, carried on with a predicate that matched nothing. A
nested field the fast path could not resolve therefore produced a plausible
small number instead of an error or a correct result, while the brute path
computed the right answer for the same request.

The fast path now detects the unresolvable case and hands the aggregation to
the brute-force implementation rather than answering from a resolution it does
not have. Falling back is slower for that request and correct, which is the
right trade for a silent wrong number.

Rebased onto current main; the branch was previously stacked on #99, which has
since merged.
xerj-org added a commit that referenced this pull request Aug 2, 2026
This PR gives exec_terms a bucket cap, so the FAST_PATH_AGGS_SERVED comment
that cited it as a still-uncapped divergence is now false. Replaced with the
current list (#99/#104/#114/#120 closed, #128 nested-object still open).
xerj-org added a commit that referenced this pull request Aug 2, 2026
…brute (#134)

* fix(aggs): the columnar terms path honours config.limits.max_buckets (#121)

`fast_aggs::exec_terms` built its term map with no bucket cap of any kind,
only a `size`-derived OUTPUT cap. An operator who lowered
`config.limits.max_buckets` to protect memory got that protection only on
queries that missed the columnar path: measured at max_buckets=37 over 200
distinct terms, the fast path (a 12k-doc index) materialised all 200
(top5=300 + sum_other_doc_count=11,700) while the brute `run_terms` stopped
at 37. That is the OOM vector the cap exists to close — the half PR #125
left open after fixing the histogram executors.

The two executors must AGREE past the cap. Brute `run_terms` does not error
there; it keeps the first `max_buckets` distinct terms in doc-iteration
order, drops the rest, and reports a `sum_other_doc_count` computed only
over the terms it kept, so the dropped terms vanish silently and the total
conceals them (order-dependent). Mirroring that truncation onto the fast
path would copy the bug, not the contract; erroring would make the fast
path disagree with a brute path that still returns a body.

So `exec_terms` BAILS to brute the moment its distinct-term count would
exceed the cap, checked as the map grows (per segment and after the
memtable) so the map is bounded to ~`max_buckets`. After the bail the
request is answered by the very `run_terms` a test measures the fast path
against, so the two AGREE past the cap by construction — the same shape as
the #104 / #120 bail-to-brute fallbacks. An index whose distinct count is
at or under the cap is served columnarly as before, and the default-cap
(65,536) common case is untouched.

Test (its own binary, one function, like agg_bucket_cap.rs, because the cap
lives in a process-wide static): a 10,050-doc index over 200 terms at cap 37
is no longer served columnarly (`fast_path_aggs_served` does not tick) and
honours the cap (37 buckets, not 200); a 600-doc brute index of the same
shape agrees on the count; and an at-cap index (exactly 37 terms) is still
served columnarly, guarding against over-bailing. Reverting only
fast_aggs.rs fails it: served==1, 200 buckets against max_buckets=37.

* doc(aggs): drop the now-false exec_terms 'no bucket cap' note

This PR gives exec_terms a bucket cap, so the FAST_PATH_AGGS_SERVED comment
that cited it as a still-uncapped divergence is now false. Replaced with the
current list (#99/#104/#114/#120 closed, #128 nested-object still open).
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