fix(engine): fast_aggs resolves <field>.keyword to its parent's column - #99
Merged
xerj-org merged 1 commit intoAug 1, 2026
Merged
Conversation
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
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
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).
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
Fixes a real-data reliability bug found live:
terms(and any other fast-path) aggregation on a<field>.keywordmulti-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-stringcols.get(field)at 27 call sites. A text field's auto-created.keywordmulti-field shares the same physical column as its parent, stored under the parent's unsuffixed name (verified on disk: a flushed segment hasextension.fst/.meta/.norms/.post, neverextension.keyword.*). So a request forextension.keywordalways misses, the segment is silently treated as having zero rows for that field, and the aggregation returns empty — whilehits.totalon 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_sourcedirectly, seeflatten_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 adate_histogram+avgquery). Root-caused instead totermsaggregations 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 rawcols.get(...)call sites infast_aggs.rswith 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 --checkcargo clippy -p xerj-engine -p xerj-api --all-targets -- -D warningscargo test -p xerj-engine --lib(288/290 — the 2 failures are the pre-existing, unrelatedsnapshot_path_security_testsfailures also present onupstream/main)cargo test -p xerj-api --lib(107/107)col_falls_back_to_keyword_multi_field_parenttermsonextension.keywordwent from{"buckets": []}(withXERJ_DISABLE_FAST_AGGSunset — i.e. the buggy fast path active) to correct real buckets (gz,css,zip,deb, ... with real doc counts), consistent across repeated queries, matching whatXERJ_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 parentgeo/machineobject 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