perf(engine): full-corpus agg path re-decoded segments on every query - #43
Merged
xerj-org merged 1 commit intoJul 27, 2026
Merged
Conversation
Same `search_inner`'s `need_full_corpus` block as the preceding
memtable fix, this time for the on-disk segment side: an unconditional
`open_segment_arc` + `decode_stored` + full `serde_json` parse of the
segment's ENTIRE stored section, on EVERY search request that needed
the full corpus for an aggregation -- once an index's data moves from
memtable to a flushed/merged segment (i.e. any real, sustained ingest,
not just the freshly-bulk-imported case).
Segments are immutable post-flush, so re-decoding the same segment's
full stored section per concurrent query is pure waste. Measured
15-26s per request under a concurrent Kibana dashboard burst against a
real ~4.7k-doc eCommerce index once its data had flushed to a segment
(`segment_loop=1300-3600ms`, `hydrate+corpus=15000-22000ms` dominant).
There's already a purpose-built cache for exactly this --
`stored_value_cache` / `stored_values_for_async`, a single-flight,
`spawn_blocking`-safe cache KNN search already relies on for the
identical reason (see its own doc comment: "for a 100-segment index
with 100MB stored per segment, every vector query re-paid ~10GB of
decompress + parse work"). This code path just never used it, doing
its own raw decode instead.
Rewired the segment loop to call `stored_values_for_async` -- first
caller decodes and publishes, every other concurrent caller
(single-flight) and every later query (cache hit) just clone the
`Arc`. Also tightened error handling to match the loop's own stated
intent ("open failures are errors, not skips") -- any segment-read
failure during full-corpus assembly is now a hard error instead of a
silent skip that would undercount every aggregation bucket.
Verified: replayed the exact captured slow-query shape (20 concurrent
requests, `query_string` wildcard sub-filter + `match_phrase` on a
`.keyword` field + date range) against the real flushed segment --
went from 15-26s to 1-4ms, including the cold-cache first hit
(single-flight coalesces the concurrent misses into one decode).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XJaZygeuRfZfUg2B8tPKU
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
Companion to the memtable-side fix (#42) — same
search_innerneed_full_corpusblock, this time for the on-disk segment side. An unconditionalopen_segment_arc+decode_stored+ fullserde_jsonparse of a segment's entire stored section happened on every search request needing the full corpus for an aggregation, once an index's data moved from memtable to a flushed/merged segment (i.e. any real, sustained ingest — not just the freshly-bulk-imported case the memtable fix covers).Root cause
Segments are immutable post-flush, so re-decoding the same segment's full stored section per concurrent query is pure waste. Measured 15–26 seconds per request under a concurrent Kibana dashboard burst against a real ~4.7k-doc eCommerce index once its data had flushed to a segment (
segment_loop=1300–3600ms,hydrate+corpus=15000–22000msdominant).There's already a purpose-built cache for exactly this:
stored_value_cache/stored_values_for_async— a single-flight,spawn_blocking-safe cache KNN search already relies on for the identical reason (per its own doc comment: "for a 100-segment index with 100MB stored per segment, every vector query re-paid ~10GB of decompress + parse work"). This code path just never used it, doing its own raw decode instead.Fix
Rewired the segment loop to call
stored_values_for_async— first caller decodes and publishes, every other concurrent caller (single-flight) and every later query (cache hit) just clone theArc. Also tightened error handling to match the loop's own stated intent ("open failures are errors, not skips") — any segment-read failure during full-corpus assembly is now a hard error instead of a silent skip that would undercount every aggregation bucket.Test plan
cargo build --release -p xerj-engine -p xerj-api -p xerj-servercargo fmt --check/cargo clippy --no-deps— cleancargo test --release -p xerj-engine --lib— 156/156 passedquery_stringwildcard sub-filter +match_phraseon a.keywordfield + date range) against the real flushed segment: went from 15–26s to 1–4ms, including the cold-cache first hit (single-flight coalesces the concurrent misses into one decode)🤖 Generated with Claude Code