Lab AG1: Trace an Aggregation
Background
You are going to take one concrete request — "average price per category, top 3
categories" — and follow it the whole way down: from the JSON, through the
aggregation profiler (which times the real collect/build/reduce phases), into
the OpenSearch source with grep, until you can point at the exact line where
a document's category ordinal becomes a bucket and the avg child gets driven
with that bucket's ordinal. By the end you will have seen, not just read about,
owningBucketOrd, the GlobalOrdinalsStringTermsAggregator, and
InternalTerms.reduce.
This is the companion lab to the Aggregations intensive. Re-read its "collection lifecycle" and "bucket-ordinal model" sections before starting — this lab makes those concrete.
Why This Matters for Contributors
Every aggregation bug report — "counts are wrong," "it's slow," "it OOMs" — is debugged by answering the same three questions: which aggregator class ran, where did the time go (collect vs build vs reduce), and how did the bucket ordinals flow? The profiler answers the first two; reading the source answers the third. A contributor who can do this trace in fifteen minutes can triage an aggregation issue that an outsider stares at for a day. You are building that reflex.
Prerequisites
-
A running OpenSearch (3.x) —
./gradlew runfrom an OpenSearch checkout, or a single-node tarball/Docker.curl localhost:9200must return cluster info. -
An OpenSearch source checkout at
~/src/OpenSearchfor thegrepsteps. -
jqinstalled (pretty-print the profile JSON).curl ... | jqthroughout. - Read Aggregations intensive and the docvalues/fielddata deep-dive.
Step-by-Step Tasks
Step 1 — Index data with a keyword field
Global-ordinals execution needs a keyword (or keyword sub-field). Force it
explicitly so you know which engine runs.
curl -s -XDELETE 'localhost:9200/shop' >/dev/null
curl -s -XPUT 'localhost:9200/shop' -H 'Content-Type: application/json' -d '{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": { "properties": {
"category": { "type": "keyword" },
"price": { "type": "double" }
}}
}'
curl -s -XPOST 'localhost:9200/shop/_bulk' -H 'Content-Type: application/json' -d '
{"index":{}}
{"category":"books","price":10}
{"index":{}}
{"category":"books","price":20}
{"index":{}}
{"category":"toys","price":30}
{"index":{}}
{"category":"toys","price":50}
{"index":{}}
{"category":"games","price":40}
{"index":{}}
{"category":"books","price":12}
'
curl -s -XPOST 'localhost:9200/shop/_refresh' >/dev/null
Note: One shard, no replicas, on purpose. With one shard the coordinator reduce is a pass-through, so any timing you see in the profile is pure shard work. You add shards in the stretch goals to watch reduce light up.
Step 2 — Run the aggregation and read the result
curl -s 'localhost:9200/shop/_search' -H 'Content-Type: application/json' -d '{
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category", "size": 3 },
"aggs": { "avg_price": { "avg": { "field": "price" } } }
}
}
}' | jq '.aggregations'
Expected (counts exact, avg per bucket):
{
"by_category": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{ "key": "books", "doc_count": 3, "avg_price": { "value": 14.0 } },
{ "key": "games", "doc_count": 1, "avg_price": { "value": 40.0 } },
{ "key": "toys", "doc_count": 2, "avg_price": { "value": 40.0 } }
]
}
}
-
Record the three buckets and their
avg_price. Notebooks=14.0 is(10+20+12)/3— the avg child accumulated into bucket-ord 0.
Step 3 — Turn on the profiler and read the aggregation breakdown
curl -s 'localhost:9200/shop/_search' -H 'Content-Type: application/json' -d '{
"profile": true,
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category", "size": 3 },
"aggs": { "avg_price": { "avg": { "field": "price" } } }
}
}
}' | jq '.profile.shards[0].aggregations'
You get a tree of timed aggregators. Pull out the names and the phase timers:
curl -s 'localhost:9200/shop/_search' -H 'Content-Type: application/json' -d '{
"profile": true, "size": 0,
"aggs": { "by_category": {
"terms": { "field": "category", "size": 3 },
"aggs": { "avg_price": { "avg": { "field": "price" } } } } }
}' | jq '.profile.shards[0].aggregations[] |
{ type, description,
initialize: .breakdown.initialize,
collect: .breakdown.collect,
build_aggregation: .breakdown.build_aggregation,
reduce: .breakdown.reduce,
children: [.children[]? | { type, build_aggregation: .breakdown.build_aggregation }] }'
-
The
typefield is the real Java class name. Forcategoryyou should see something containingGlobalOrdinalsStringTermsAggregator(orMapStringTermsAggregator— record which). The child is anAvgAggregator. -
Note the four phase timers:
initialize,collect,build_aggregation,reduce(nanoseconds).collectis the per-doc fold;build_aggregationisbuildAggregations;reduceis the coordinator reduce (tiny here).
Note:
descriptionis the agg name from your request (by_category);typeis the implementation class. The mapping between them is the whole point of the profiler — it tells you which engine the factory picked.
Step 4 — Confirm the terms execution engine
The two engines (global ordinals vs map) perform very differently at scale. Find where the factory chooses:
cd ~/src/OpenSearch
grep -n "GlobalOrdinalsStringTermsAggregator\|MapStringTermsAggregator\|ExecutionMode\|globalOrdinalsValues\|remapGlobalOrds" \
server/src/main/java/org/opensearch/search/aggregations/bucket/terms/TermsAggregatorFactory.java
-
Find the
ExecutionModeenum (GLOBAL_ORDINALS,MAP) and the method that picks one. Read the comment on when MAP is chosen.
Now force the other engine and re-profile to see type change:
curl -s 'localhost:9200/shop/_search' -H 'Content-Type: application/json' -d '{
"profile": true, "size": 0,
"aggs": { "by_category": {
"terms": { "field": "category", "size": 3, "execution_hint": "map" } } }
}' | jq '.profile.shards[0].aggregations[].type'
-
Confirm the class changed to
MapStringTermsAggregator. You just observed the factory'sExecutionModedecision from the outside.
Step 5 — Trace collect: where a doc becomes a bucket
Open the global-ordinals aggregator and find its getLeafCollector /
collect(int doc, long owningBucketOrd):
grep -n "getLeafCollector\|void collect(int doc\|owningBucketOrd\|bucketOrds.add\|collectBucket\|collectExistingBucket" \
server/src/main/java/org/opensearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java
Read the collect body and answer in your log:
-
What value does it read per doc? (a global ordinal from
SortedSetDocValues.) -
What does
bucketOrds.add(owningBucketOrd, globalOrd)return when the bucket is new vs already seen? (Hint: negative encodes "existing"; it does-1 - ord.) -
Which call drives the child
avg? (collectBucket/collectExistingBucket→collectBucket(sub, doc, bucketOrd)→ the sub'scollect(doc, bucketOrd).) ThatbucketOrdis the avg'sowningBucketOrd.
Now confirm the avg side reads from arrays indexed by that ordinal:
grep -n "class AvgAggregator\|DoubleArray\|LongArray\|sums\|counts\|void collect(int doc, long bucket" \
server/src/main/java/org/opensearch/search/aggregations/metrics/AvgAggregator.java
-
Confirm
sumsandcountsareBigArrays-backed and indexed bybucket(the owning ord). Write the line: "books' sum lives insums[0]."
Step 6 — Trace buildAggregations: ordinals become terms
grep -n "buildAggregations\|buildSubAggsForBuckets\|BucketPriorityQueue\|shardSize\|ordsToCollect\|lookupGlobalOrd" \
server/src/main/java/org/opensearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java
-
Find where it picks the top-
shard_sizebuckets (a priority queue) and where it resolves the surviving global ordinals back toBytesRefterms. Note: it only stringifies the survivors, not every ordinal — that's the global-ordinal win. -
Find
buildSubAggsForBuckets— this is where each surviving bucket'savgchild getsbuildAggregations(bucketOrd)called to produceInternalAvg.
Step 7 — Trace reduce: shards become one answer
grep -n "reduce\|doReduce\|class InternalTerms\|sum_other_doc_count\|doc_count_error\|reduceBucket\|reduceContext" \
server/src/main/java/org/opensearch/search/aggregations/bucket/terms/InternalTerms.java
grep -n "reduce\|class InternalAvg\|sum\|count" \
server/src/main/java/org/opensearch/search/aggregations/metrics/InternalAvg.java
-
In
InternalTerms.reduce: find where it merges buckets by key, sumsdocCount, and recursively reduces sub-aggregations for each merged bucket (that's how theavgchildren combine across shards). -
In
InternalAvg: confirm reduce sums thesums and sums thecounts and divides once — associative and commutative, so the two-level reduce is safe. Write why "sum-then-divide" is order-independent but "average of averages" would not be.
Step 8 — Write the reading-log artifact
Create lab-ag1-trace.md capturing the full path. Template:
# Lab AG1 — Aggregation Trace
## Profile observations
- terms aggregator class (type): __________ (description: by_category)
- avg aggregator class (type): AvgAggregator
- collect ns: ____ build_aggregation ns: ____ reduce ns: ____
- with execution_hint=map, type became: __________
## collect path (GlobalOrdinalsStringTermsAggregator)
- value read per doc: global ordinal from SortedSetDocValues
- bucketOrds.add(owningBucketOrd, globalOrd) returns:
new bucket -> ____ ; existing bucket -> ____ (encoding: ____)
- child avg driven via: ____ -> collect(doc, bucketOrd)
- "books' running sum lives in sums[__]"
## buildAggregations path
- top-shard_size selection: ____ (data structure: ____)
- ordinals resolved to terms via: ____ (only for survivors? yes/no)
- sub-aggs built via: buildSubAggsForBuckets
## reduce path
- InternalTerms.reduce merges buckets by ____, sums ____, recursively reduces ____
- InternalAvg.reduce: sums ____, sums ____, divides ____ times
- Why associative+commutative: ____
## owningBucketOrd in one sentence
____
- Fill every blank from what you actually saw/read, not from memory.
Deliverables
-
lab-ag1-trace.mdcompleted with profile numbers and real class names. -
The two profile JSON captures (global ordinals and
mapengines) showing thetypechange. -
A one-paragraph explanation of
owningBucketOrdand how theavgchild is routed to the right bucket, in your own words.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
.profile.shards[0].aggregations is null | forgot "profile": true | add it to the request body |
type is MapStringTermsAggregator unexpectedly | few segments / field cheap to remap | fine — note it; add more docs/segments to push to global ordinals |
avg buckets all null | aggregated a text field, no doc values | use the keyword field category |
grep finds nothing for a class | name varies by version | find server -name "GlobalOrdinals*Terms*Aggregator.java" then grep that |
| counts differ from expected | leftover docs from a prior run | DELETE /shop and re-index |
Expected Output
- The profile shows a
termsaggregator (global-ordinals class) with anAvgAggregatorchild, with nonzerocollectandbuild_aggregationtimers and a tinyreduce. execution_hint: mapflipstypetoMapStringTermsAggregator.- Your reading log names the exact methods on the collect → build → reduce path.
Stretch Goals
-
Make reduce non-trivial. Recreate
shopwithnumber_of_shards: 3, re-index, and re-profile. Watch thereducetimer grow anddoc_count_error_upper_boundpotentially become nonzero. Explain the change using the deep-dive'sshard_sizediscussion. -
Order by sub-metric. Add
"order": { "avg_price": "desc" }to the terms agg and re-profile. Note the warning in the deep-dive about metric-order approximation; raiseshard_sizeand observe. -
Concurrency. Set
search.concurrent_segment_search.mode: all, force a few segments (index in batches with refreshes), and re-profile. Find the per-slice structure in the profile (aslices/max_slicesfield). Connect to the concurrent search masterclass slice-reduce level. -
Cardinality. Add a
cardinalitysub-agg onpriceand findHyperLogLogPlusPlusin the source; note howreducemerges sketches.
Coding Exercises
Tracing is only half-learned until you turn an observation into an assertion. Each
exercise below makes you write code — a test, a patch, or a tiny program — that
pins down something you saw in the trace. Locate every class with rg first
(rg -l GlobalOrdinalsStringTermsAggregator server/) — never trust a remembered
path or line number.
-
(warm-up) Assert the buckets you read off the profile. Write an
OpenSearchIntegTestCasethat indexes the sixshopdocs from Step 1, runs the terms+avg aggregation, and asserts the three buckets, theirdoc_counts, andbooks == 14.0. Model it on existing terms tests — find one withrg -l "class.*TermsIT|StringTermsIT" server/src/.../search/aggregations/bucketand copy itsclient().prepareSearch(...).addAggregation(...)shape. Green test = you encoded the Step 2 result as an executable fact. -
(core) Unit-test the avg child's per-ord accumulation. Using
AggregatorTestCase(find it withrg -l "class AggregatorTestCase"), write a test that indexes docs whosecategoryordinal you control, runstermswith anavgsub-agg over an in-memory Lucene index, and asserts each bucket's avg. This provessums[owningBucketOrd]is routed correctly without a cluster — the same loop you traced in Step 5, now under JUnit. -
(core) Instrument the execution-mode decision. Add a temporary
logger.infoinTermsAggregatorFactoryat the branch that picksGLOBAL_ORDINALSvsMAP(the method you found in Step 4), printing the chosenExecutionModeand the field. Rebuild, run both the default andexecution_hint: maprequests, and capture the two log lines. Then write a one-paragraph note tying each log line to thetypeyou saw in the profile. (Revert the patch after — it's a probe, not a PR.) -
(core) A reduce/partial-reduce test for
InternalAvg. Write anOpenSearchTestCasethat constructs severalInternalAvginstances with knownsum/countpairs and callsreduce(...)(find the signature withrg -n "InternalAggregation reduce" server/.../metrics/InternalAvg.java). Assert that reducing them all at once equals reducing them in two batches and then reducing the partials — i.e. partial reduce == full reduce. This is the executable form of the associativity argument from Step 7. -
(advanced) Advanced challenge — a property-based reduce-order fuzzer. Write a parameterized
OpenSearchTestCasethat, for many random splits of N shard-levelInternalAvgpartials into random groups, asserts every grouping yields the same final average to within a float epsilon — and a contrasting test for a deliberately broken "average of averages" reducer (a tiny local subclass) that shows it does not hold. Then run the real terms+avg aggregation undersearch.concurrent_segment_search.mode: all(Step's stretch goal) and assert the concurrent result equals the sequential one. Deliverable: a single test file that proves the safe reducer is order-independent and the naive one is not, plus a short writeup connecting it to slice-reduce in concurrent search.
Issues to Practice On
Aggregation tracing is exactly the skill that triages the issues below. Work them
on the core repo, opensearch-project/OpenSearch.
| What to look for | How to list it |
|---|---|
| Area issues | gh issue list --repo opensearch-project/OpenSearch --label "Search:Aggregations" --state open |
| Beginner-friendly | gh issue list --repo opensearch-project/OpenSearch --label "good first issue" --state open |
| Flaky agg tests | gh issue list --repo opensearch-project/OpenSearch --label "flaky-test" --search "aggregation in:title" --state open |
Label taxonomies drift, so confirm on the tracker first
(gh label list --repo opensearch-project/OpenSearch | rg -i "search|agg").
Representative patterns. (1) "Counts/avg wrong with N shards but right with
1." — almost always a reduce or doc_count_error issue. Reproduce with a
multi-shard index, then trace InternalTerms.reduce (Step 7); the planted-bug drill
below rehearses exactly this. (2) "terms agg slower than expected on a
high-cardinality keyword." — reproduce with the profiler, confirm which
ExecutionMode ran (Step 4), and check whether MAP was chosen where global
ordinals would win. Approach any of them the same way: reproduce → locate with rg
→ fix → add a test → PR with a CHANGELOG entry and DCO sign-off.
Planted-bug drill. In InternalAvg.reduce, change the divide so it averages the
per-shard values instead of Σsum / Σcount (e.g. accumulate value() and divide
by the number of partials). Rebuild and run the avg/terms test suite
(rg -l "InternalAvgTests|AvgIT" server/). Watch which test goes red on a
multi-shard or multi-partial case. Now revert, and add the assertion that would have
caught it: a test where the partials have unequal counts (so "average of averages"
visibly differs from the true weighted average). That single asserted case is the
regression test a reviewer would demand.
Etiquette: claim an issue with a comment before working it, reproduce before proposing a fix, and remember every PR needs a test + CHANGELOG entry + DCO
Signed-off-by(git commit -s). See community interaction and the prepare-a-PR lab.
Validation / Self-check
- From the profile alone, how do you tell which
termsengine ran, and why does it matter for performance? - Define
owningBucketOrdand trace one document ({category: books, price:20}) fromcollectto the array slotsums[0]. - What does
bucketOrds.addreturn for a new vs existing bucket, and how is "existing" encoded? - In
buildAggregations, why does the global-ordinals engine only stringify the surviving top-shard_sizeordinals? - Argue
InternalAvg.reduceis associative and commutative, and explain why "average of averages" would break the two-level reduce.
Next: Lab AG2 — Build a Custom Aggregation. You just traced an existing agg; next you write one end to end.