Stage 7 — Search and Aggregation Issues
What this stage teaches
Stage 7 is the read path: turning a query into Lucene work on each shard, then merging
per-shard results on the coordinating node. The skill is reasoning about a fan-out /
reduce pipeline where the hard bugs live in the merge — especially aggregation
reduce, which must produce the same answer whether a shard returned data, returned
nothing, or did not exist at all.
QueryPhase/FetchPhase— per-shard execution.QueryPhaseruns the query and collects top docs and aggregation buckets;FetchPhasehydrates the matched docs.SearchService.executeQueryPhase/executeFetchPhaseare the entry points.SearchPhaseController— the coordinating-node reduce: merges top docs, callsInternalAggregation.reduce(...), computes the final response.- Aggregations —
AggregatorFactory→Aggregator(per shard) →InternalAggregation(the serializable result) →reduce(...)(the cross-shard merge). The merge is where empty-shard and partial-result bugs hide. QueryBuilder/AbstractQueryBuilder— the DSL → LuceneQuerytranslation viaQueryShardContext, including query rewrite (rewrite(...)), where another family of bugs lives.
The two classic Stage 7 bugs: an aggregation reduce edge case (wrong result when some shards are empty or absent, or when partial reduce runs) and a query rewrite bug (a builder that rewrites to the wrong query, or is not equal/serializable round-trip).
Prerequisite: Stage 4 plus the search deep dives: Search execution, Aggregations, and Query DSL & QueryBuilders.
Fan-out and reduce
flowchart TD
C[TransportSearchAction<br/>coordinating node] --> F1[shard 1: QueryPhase]
C --> F2[shard 2: QueryPhase]
C --> F3[shard N: QueryPhase]
F1 --> RED[SearchPhaseController reduce<br/>InternalAggregation.reduce]
F2 --> RED
F3 --> RED
RED --> FET[FetchPhase on matched docs]
FET --> RESP[final SearchResponse]
The invariant that breaks most often:
reducemust be associative and handle the empty/absent case. It runs in batches (partial reduce ofMshard results at a time, then a final reduce of the partials), soreduce(reduce(a, b), c)must equalreduce(a, reduce(b, c)), andreduceof an empty or all-empty input must produce a well-formed (often empty) aggregation, not a null, not a throw, not a wrong default.
A shard can contribute no documents (empty index), and with index filtering a shard may not be queried at all. Both must reduce cleanly.
Finding Stage 7 issues
is:issue is:open label:bug no:assignee label:"Search:Aggregations"
is:issue is:open label:bug no:assignee label:"Search:Query Capabilities"
is:issue is:open label:bug no:assignee "aggregation" "empty" in:body
is:issue is:open label:bug no:assignee "rewrite" "query" in:body
Area labels include Search:Aggregations, Search:Query Capabilities,
Search:Relevance, Search:Performance. Check the current set.
Fallback grep — find a reduce that does not guard the empty case, or a builder whose
doRewrite is suspect:
grep -rn "public InternalAggregation reduce" server/src/main/java/org/opensearch/search/aggregations/ | head
grep -rln "doRewrite\|protected QueryBuilder doRewrite" server/src/main/java/org/opensearch/index/query/
Walked example — an aggregation reduce edge case
Illustrative of the pattern. The grep finds the real aggregation; treat the class as a stand-in for "some aggregation whose reduce mishandles empty shards."
Symptom: an issue reports that an aggregation returns a wrong value (or NaN, or a
null) when at least one shard has no matching documents — the empty shard's
InternalAggregation is folded into the reduce incorrectly. A common shape: a metric agg
that averages by summing values and dividing by count, where an empty shard contributes
count == 0 and the reduce divides by a total that ignored it, or includes a sentinel that
poisons the sum.
Locate the reduce
ls server/src/main/java/org/opensearch/search/aggregations/metrics/
grep -n "reduce\|getProperty\|buildEmptyAggregation\|InternalAggregation" \
server/src/main/java/org/opensearch/search/aggregations/metrics/InternalAvg.java | head
git log --oneline -n 5 -- server/src/main/java/org/opensearch/search/aggregations/metrics/InternalAvg.java
The schematic of a metric reduce:
@Override
public InternalAvg reduce(List<InternalAggregation> aggregations, ReduceContext reduceContext) {
CompensatedSum sum = new CompensatedSum(0, 0);
long count = 0;
for (InternalAggregation aggregation : aggregations) {
InternalAvg avg = (InternalAvg) aggregation;
count += avg.count;
sum.add(avg.sum); // empty shard contributes count=0, sum=0 — must be a no-op
}
return new InternalAvg(getName(), sum.value(), count, format, getMetadata());
}
If an empty shard contributes a non-zero sentinel sum, or if buildEmptyAggregation()
returns the wrong neutral element, the average skews. Read both reduce and
buildEmptyAggregation() — they must agree on the identity element.
Diff
--- a/server/src/main/java/org/opensearch/search/aggregations/metrics/InternalAvg.java
+++ b/server/src/main/java/org/opensearch/search/aggregations/metrics/InternalAvg.java
@@ public InternalAvg reduce(...) {
for (InternalAggregation aggregation : aggregations) {
InternalAvg avg = (InternalAvg) aggregation;
+ // An empty shard has count == 0; its sum must be the additive identity (0),
+ // never a sentinel like Double.NaN, or it poisons the merged average.
count += avg.count;
sum.add(avg.sum);
}
- return new InternalAvg(getName(), sum.value(), count, format, getMetadata());
+ // With zero documents across all shards, the average is defined as NaN by contract;
+ // make that explicit rather than dividing 0/0 implicitly.
+ return new InternalAvg(getName(), count == 0 ? Double.NaN : sum.value(), count, format, getMetadata());
The reasoning that generalises beyond this one aggregation:
- Find the identity element. For sum it is
0; for min it is+inf; for max it is-inf; for a sketch it is the empty sketch.buildEmptyAggregation()must return it andreducemust treat it as a no-op. - Define the all-empty result by contract, not by accident.
avgof nothing isNaN,sumof nothing is0,min/maxof nothing is null/absent. Make it explicit. - Associativity. Because reduce runs in partial batches, the per-shard order must not change the result. Test that.
Test with AggregatorTestCase
AggregatorTestCase runs an aggregation over an in-memory Lucene index, including the
multi-shard reduce, with no cluster:
public void testAvgWithEmptyShardIsCorrect() throws IOException {
// One "shard" with values, one with no documents.
testCase(new MatchAllDocsQuery(), iw -> {
iw.addDocument(singleton(new NumericDocValuesField("value", 10)));
iw.addDocument(singleton(new NumericDocValuesField("value", 20)));
// ... and a segment/leaf with no "value" docs ...
}, agg -> {
InternalAvg avg = (InternalAvg) agg;
assertEquals(15.0, avg.getValue(), 0.0); // empty shard did not skew it
}, new NumberFieldMapper.NumberFieldType("value", NumberFieldMapper.NumberType.LONG),
avgBuilder("value"));
}
public void testAvgOfNothingIsNaN() throws IOException {
testCase(new MatchAllDocsQuery(), iw -> { /* add no value docs */ }, agg -> {
assertTrue(Double.isNaN(((InternalAvg) agg).getValue()));
}, /* field */, avgBuilder("value"));
}
For the reduce specifically, InternalAggregationTestCase round-trips and reduces
InternalAggregation instances directly — use it to assert associativity across an empty
element:
grep -rln "extends AggregatorTestCase\|extends InternalAggregationTestCase" \
server/src/test/java/org/opensearch/search/aggregations/
./gradlew :server:test --tests "*InternalAvgTests" --tests "*AvgAggregatorTests" -q
Confirm end to end
./gradlew run &
curl -s -XPUT 'localhost:9200/idx' -H 'content-type: application/json' -d '{"settings":{"number_of_shards":3}}'
curl -s -XPOST 'localhost:9200/idx/_doc?refresh' -H 'content-type: application/json' -d '{"value":10}'
curl -s -XPOST 'localhost:9200/idx/_doc?refresh' -H 'content-type: application/json' -d '{"value":20}'
curl -s 'localhost:9200/idx/_search' -H 'content-type: application/json' -d '{
"size":0, "aggs":{"a":{"avg":{"field":"value"}}}
}' | jq '.aggregations.a.value'
With 3 shards and 2 docs, at least one shard is empty — the average must read 15, proving
the empty-shard reduce.
A second pattern — a query rewrite bug
Illustrative of the pattern.
QueryBuilders rewrite themselves before execution (e.g. a range over a constant field
rewrites to match_none or match_all). A rewrite bug returns the wrong simplified query,
or breaks the equals/hashCode/serialization round-trip that the framework requires.
grep -n "doRewrite\|Rewriteable\|protected Query doToQuery" \
server/src/main/java/org/opensearch/index/query/RangeQueryBuilder.java | head
These are tested with AbstractQueryTestCase, which auto-checks serialization round-trip,
equals/hashCode, XContent parse, and toQuery:
// Extend AbstractQueryTestCase<RangeQueryBuilder>; override doAssertLuceneQuery
// to assert the rewritten/translated Lucene Query is what you expect.
protected void doAssertLuceneQuery(RangeQueryBuilder qb, Query query, QueryShardContext context) {
assertThat(query, instanceOf(/* expected Lucene query class */));
}
grep -rln "extends AbstractQueryTestCase" server/src/test/java/org/opensearch/index/query/
./gradlew :server:test --tests "*RangeQueryBuilderTests" -q
AbstractQueryTestCase is strict: if your change breaks Writeable round-trip or
equals, the base class fails the test for you — which is exactly the safety you want.
Pitfalls
reducethat throws/returns null on empty input. It runs on empty and all-empty inputs. The identity element and the all-empty contract must be explicit.- Disagreement between
reduceandbuildEmptyAggregation. They must share the same identity element. Fix both in one PR. - Ignoring partial reduce. Large searches reduce in batches; a non-associative reduce
gives different answers under different
batched_reduce_size. Test associativity. - Breaking the query round-trip. Any new field on a
QueryBuildermust be added toequals,hashCode,doWriteTo/constructor-StreamInput, anddoXContent.AbstractQueryTestCaseenforces this — do not suppress its checks. - Forgetting BWC on a serialized field. A new field on an
InternalAggregationorQueryBuilderthat crosses the wire needs aVersionguard (Stage 11). - Hand-rolling Lucene fixtures. Use
AggregatorTestCase/AbstractQueryTestCase; they build the leaf readers, field types, and shard context correctly. - Confusing query phase and fetch phase. Scoring/aggregation bugs are
QueryPhase; source/highlight/fields bugs areFetchPhase. Diagnose which phase before you edit.
Exit criteria — when you're ready for Stage 8
- One search or aggregation fix is merged with an
AggregatorTestCase/InternalAggregationTestCase/AbstractQueryTestCasetest that exercises the edge case (empty shard, all-empty, rewrite boundary). - You can name the identity element for the aggregation you fixed and you reconciled
reducewithbuildEmptyAggregation. - You can explain why aggregation reduce must be associative and how partial reduce makes that observable.
- For a
QueryBuilderchange, you letAbstractQueryTestCaseprove the round-trip rather than disabling its checks.
You have now worked both the write and read paths of core. Stage 8 steps to the boundary where core meets the plugins that build on it.