Lab 7.3: Build It — A Custom Aggregation
Background
You have traced the search phases (Lab 7.1) and read the aggregation framework (Lab 7.2). Now you
build one. This lab walks you through a complete SearchPlugin that registers a custom metric
aggregation called stats_lite: for a numeric field it computes count, sum, min, and max
in a single pass — a deliberately small subset of the built-in stats agg, chosen so the code is
fully readable while exercising every required moving part.
You will write all five pieces the framework requires:
Plugin implements SearchPluginwithgetAggregations()registration.StatsLiteAggregationBuilder—Writeable+ToXContent, parsed from JSON.StatsLiteAggregatorFactory— builds the per-shard aggregator.StatsLiteAggregator— the per-shardcollect+buildAggregation.InternalStatsLite— the partial result and thereducethat merges shards.
Then you build, install, and exercise it via _search, and write a test extending
AggregatorTestCase.
Why a metric and not a bucket agg? A metric agg has the cleanest reduce: the partial carries raw accumulators (count/sum/min/max), and reduce just combines them. That makes the reducible-metric rule from Lab 7.2 concrete: ship the accumulators, not the derived answer.
Why This Lab Matters for Contributors
- New aggregations and queries are a steady stream of real OpenSearch issues and RFCs. This is the exact shape of that work.
- Getting
Writeable+ToXContent+reducecorrect is the skill that separates a mergeable PR from a "back to the drawing board" review. - The
AggregatorTestCaseharness you use here is the same one core uses for built-in aggs.
Prerequisites
- A built checkout;
./gradlew assemblegreen. - Lab 7.2 done — you know
AggregatorFactory/Aggregator/InternalAggregation. - Read alongside: Plugin Architecture, Aggregations deep dive, Serialization and BWC.
Step-by-Step Tasks
Step 1 — Scaffold the plugin module (15 min)
Create a plugin under plugins/ (the in-repo plugin location). Use an existing small plugin as a
template for the Gradle wiring:
ls plugins/ # see analysis-icu, mapper-murmur3, etc.
cat plugins/mapper-murmur3/build.gradle | head -40 # a minimal plugin build file
Create the directory tree:
mkdir -p plugins/agg-stats-lite/src/main/java/org/opensearch/aggregations/statslite
mkdir -p plugins/agg-stats-lite/src/test/java/org/opensearch/aggregations/statslite
plugins/agg-stats-lite/build.gradle:
apply plugin: 'opensearch.opensearchplugin'
apply plugin: 'opensearch.yaml-rest-test'
opensearchplugin {
description = 'Adds a stats_lite metric aggregation (count/sum/min/max).'
classname = 'org.opensearch.aggregations.statslite.StatsLitePlugin'
}
// No external deps; this plugin only uses server APIs.
Register the module in settings.gradle so Gradle sees it (find where plugins are included):
grep -n "project(\":plugins" settings.gradle | head
# add a line: include ':plugins:agg-stats-lite'
Note: Every source file you create needs the SPDX header (the repo's
CONTRIBUTING.mdand PR quality cover the conventions)../gradlew precommitwill fail without it.
Step 2 — The InternalStatsLite partial + reduce (the heart) (25 min)
This is the class to get right. It carries the accumulators so reduce is lossless.
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
package org.opensearch.aggregations.statslite;
import org.opensearch.core.common.io.stream.StreamInput;
import org.opensearch.core.common.io.stream.StreamOutput;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.search.aggregations.InternalAggregation;
import org.opensearch.search.aggregations.metrics.InternalNumericMetricsAggregation;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public class InternalStatsLite extends InternalNumericMetricsAggregation.MultiValue {
private final long count;
private final double sum;
private final double min;
private final double max;
public InternalStatsLite(String name, long count, double sum, double min, double max, Map<String, Object> metadata) {
super(name, metadata);
this.count = count;
this.sum = sum;
this.min = min;
this.max = max;
}
// Wire deserialization — order MUST match writeTo.
public InternalStatsLite(StreamInput in) throws IOException {
super(in);
this.count = in.readVLong();
this.sum = in.readDouble();
this.min = in.readDouble();
this.max = in.readDouble();
}
@Override
protected void doWriteTo(StreamOutput out) throws IOException {
out.writeVLong(count);
out.writeDouble(sum);
out.writeDouble(min);
out.writeDouble(max);
}
@Override
public String getWriteableName() {
return StatsLiteAggregationBuilder.NAME; // "stats_lite"
}
// THE REDUCE: combine shard partials losslessly. Not a concat — a merge of accumulators.
@Override
public InternalStatsLite reduce(List<InternalAggregation> aggregations, ReduceContext reduceContext) {
long count = 0;
double sum = 0;
double min = Double.POSITIVE_INFINITY;
double max = Double.NEGATIVE_INFINITY;
for (InternalAggregation agg : aggregations) {
InternalStatsLite s = (InternalStatsLite) agg;
count += s.count;
sum += s.sum;
min = Math.min(min, s.min);
max = Math.max(max, s.max);
}
return new InternalStatsLite(name, count, sum, min, max, getMetadata());
}
@Override
public double value(String name) {
switch (name) {
case "count": return count;
case "sum": return sum;
case "min": return min;
case "max": return max;
default: throw new IllegalArgumentException("unknown value [" + name + "]");
}
}
@Override
public XContentBuilder doXContentBody(XContentBuilder builder, Params params) throws IOException {
builder.field("count", count);
builder.field("sum", sum);
// min/max are meaningless with zero docs; emit null like core does.
builder.field("min", count == 0 ? null : min);
builder.field("max", count == 0 ? null : max);
return builder;
}
// count/sum/min/max accessors for tests
public long getCount() { return count; }
public double getSum() { return sum; }
public double getMin() { return min; }
public double getMax() { return max; }
}
Warning:
doWriteToand theStreamInputconstructor must read/write fields in exactly the same order. A mismatch is silent corruption across the wire — the canonical BWC bug. Test it with a round-trip (Step 7). See Serialization and BWC.
Step 3 — The StatsLiteAggregator (per-shard collect) (20 min)
/* SPDX header ... */
package org.opensearch.aggregations.statslite;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.search.ScoreMode;
import org.opensearch.common.lease.Releasables;
import org.opensearch.common.util.BigArrays;
import org.opensearch.common.util.DoubleArray;
import org.opensearch.common.util.LongArray;
import org.opensearch.index.fielddata.SortedNumericDoubleValues;
import org.opensearch.search.aggregations.Aggregator;
import org.opensearch.search.aggregations.InternalAggregation;
import org.opensearch.search.aggregations.LeafBucketCollector;
import org.opensearch.search.aggregations.LeafBucketCollectorBase;
import org.opensearch.search.aggregations.metrics.NumericMetricsAggregator;
import org.opensearch.search.aggregations.support.ValuesSource;
import org.opensearch.search.aggregations.support.ValuesSourceConfig;
import org.opensearch.search.internal.SearchContext;
import java.io.IOException;
import java.util.Map;
public class StatsLiteAggregator extends NumericMetricsAggregator.MultiValue {
private final ValuesSource.Numeric valuesSource;
// owningBucketOrdinal-indexed accumulators (bucket ordinals support sub-agg nesting).
private LongArray counts;
private DoubleArray sums;
private DoubleArray mins;
private DoubleArray maxes;
public StatsLiteAggregator(
String name,
ValuesSourceConfig config,
SearchContext context,
Aggregator parent,
Map<String, Object> metadata
) throws IOException {
super(name, context, parent, metadata);
this.valuesSource = config.hasValues() ? (ValuesSource.Numeric) config.getValuesSource() : null;
if (valuesSource != null) {
final BigArrays bigArrays = context.bigArrays();
counts = bigArrays.newLongArray(1, true);
sums = bigArrays.newDoubleArray(1, true);
mins = bigArrays.newDoubleArray(1, false);
mins.fill(0, mins.size(), Double.POSITIVE_INFINITY);
maxes = bigArrays.newDoubleArray(1, false);
maxes.fill(0, maxes.size(), Double.NEGATIVE_INFINITY);
}
}
@Override
public ScoreMode scoreMode() {
return valuesSource != null && valuesSource.needsScores() ? ScoreMode.COMPLETE : ScoreMode.COMPLETE_NO_SCORES;
}
@Override
public LeafBucketCollector getLeafCollector(LeafReaderContext ctx, LeafBucketCollector sub) throws IOException {
if (valuesSource == null) {
return LeafBucketCollector.NO_OP_COLLECTOR;
}
final BigArrays bigArrays = context.bigArrays();
final SortedNumericDoubleValues values = valuesSource.doubleValues(ctx); // reads DocValues
return new LeafBucketCollectorBase(sub, values) {
@Override
public void collect(int doc, long bucket) throws IOException {
growIfNeeded(bigArrays, bucket);
if (values.advanceExact(doc)) {
final int valueCount = values.docValueCount();
for (int i = 0; i < valueCount; i++) {
double v = values.nextValue();
counts.increment(bucket, 1);
sums.increment(bucket, v);
mins.set(bucket, Math.min(mins.get(bucket), v));
maxes.set(bucket, Math.max(maxes.get(bucket), v));
}
}
}
};
}
private void growIfNeeded(BigArrays bigArrays, long bucket) {
if (bucket >= counts.size()) {
long from = counts.size();
counts = bigArrays.grow(counts, bucket + 1);
sums = bigArrays.grow(sums, bucket + 1);
long oldMinSize = mins.size();
mins = bigArrays.grow(mins, bucket + 1);
mins.fill(oldMinSize, mins.size(), Double.POSITIVE_INFINITY);
long oldMaxSize = maxes.size();
maxes = bigArrays.grow(maxes, bucket + 1);
maxes.fill(oldMaxSize, maxes.size(), Double.NEGATIVE_INFINITY);
}
}
@Override
public boolean hasMetric(String name) {
switch (name) {
case "count": case "sum": case "min": case "max": return true;
default: return false;
}
}
@Override
public double metric(String name, long owningBucketOrd) {
if (valuesSource == null || owningBucketOrd >= counts.size()) {
return name.equals("count") ? 0 : Double.NaN;
}
switch (name) {
case "count": return counts.get(owningBucketOrd);
case "sum": return sums.get(owningBucketOrd);
case "min": return mins.get(owningBucketOrd);
case "max": return maxes.get(owningBucketOrd);
default: throw new IllegalArgumentException("unknown metric [" + name + "]");
}
}
// Builds the shard partial for a given owning bucket ordinal.
@Override
public InternalAggregation buildAggregation(long owningBucketOrd) throws IOException {
if (valuesSource == null || owningBucketOrd >= counts.size()) {
return buildEmptyAggregation();
}
return new InternalStatsLite(
name,
counts.get(owningBucketOrd),
sums.get(owningBucketOrd),
mins.get(owningBucketOrd),
maxes.get(owningBucketOrd),
metadata()
);
}
@Override
public InternalAggregation buildEmptyAggregation() {
return new InternalStatsLite(name, 0, 0, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, metadata());
}
@Override
public void doClose() {
Releasables.close(counts, sums, mins, maxes);
}
}
Note: Class/method signatures drift between branches (e.g.
BigArraysaccess, the exactNumericMetricsAggregator.MultiValuecontract,metadata()vsgetMetadata()). After you paste this, let the compiler guide you and diff against a real built-in metric aggregator for the current contract:ls server/src/main/java/org/opensearch/search/aggregations/metrics/ | grep -i "StatsAggregator\|AvgAggregator"
Step 4 — The factory and the builder (20 min)
StatsLiteAggregatorFactory (per-shard aggregator creation), extending the values-source factory so
field resolution and the ValuesSourceRegistry are handled for you:
/* SPDX header ... */
package org.opensearch.aggregations.statslite;
import org.opensearch.search.aggregations.Aggregator;
import org.opensearch.search.aggregations.AggregatorFactories;
import org.opensearch.search.aggregations.CardinalityUpperBound;
import org.opensearch.search.aggregations.support.CoreValuesSourceType;
import org.opensearch.search.aggregations.support.ValuesSourceAggregatorFactory;
import org.opensearch.search.aggregations.support.ValuesSourceConfig;
import org.opensearch.search.aggregations.support.ValuesSourceRegistry;
import org.opensearch.search.internal.SearchContext;
import org.opensearch.index.query.QueryShardContext;
import java.io.IOException;
import java.util.Map;
public class StatsLiteAggregatorFactory extends ValuesSourceAggregatorFactory {
public static void registerAggregators(ValuesSourceRegistry.Builder builder) {
builder.register(
StatsLiteAggregationBuilder.REGISTRY_KEY,
CoreValuesSourceType.NUMERIC,
StatsLiteAggregator::new,
true
);
}
StatsLiteAggregatorFactory(
String name,
ValuesSourceConfig config,
QueryShardContext queryShardContext,
AggregatorFactories.Builder subFactoriesBuilder,
Map<String, Object> metadata
) throws IOException {
super(name, config, queryShardContext, subFactoriesBuilder, metadata);
}
@Override
protected Aggregator createUnmapped(SearchContext searchContext, Aggregator parent, Map<String, Object> metadata) throws IOException {
return new StatsLiteAggregator(name, config, searchContext, parent, metadata);
}
@Override
protected Aggregator doCreateInternal(
SearchContext searchContext,
Aggregator parent,
CardinalityUpperBound bucketCardinality,
Map<String, Object> metadata
) throws IOException {
return new StatsLiteAggregator(name, config, searchContext, parent, metadata);
}
}
StatsLiteAggregationBuilder (Writeable + ToXContent, parsed from JSON). It is a values-source
builder so field/missing/script parsing is inherited:
/* SPDX header ... */
package org.opensearch.aggregations.statslite;
import org.opensearch.core.common.io.stream.StreamInput;
import org.opensearch.core.common.io.stream.StreamOutput;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.index.query.QueryShardContext;
import org.opensearch.search.aggregations.AggregationBuilder;
import org.opensearch.search.aggregations.AggregatorFactories;
import org.opensearch.search.aggregations.support.CoreValuesSourceType;
import org.opensearch.search.aggregations.support.ValuesSourceAggregationBuilder;
import org.opensearch.search.aggregations.support.ValuesSourceConfig;
import org.opensearch.search.aggregations.support.ValuesSourceRegistry;
import org.opensearch.search.aggregations.support.ValuesSourceType;
import java.io.IOException;
import java.util.Map;
public class StatsLiteAggregationBuilder
extends ValuesSourceAggregationBuilder.LeafOnly<ValuesSourceConfig, StatsLiteAggregationBuilder> {
public static final String NAME = "stats_lite";
public static final ValuesSourceRegistry.RegistryKey<?> REGISTRY_KEY =
new ValuesSourceRegistry.RegistryKey<>(NAME, StatsLiteAggregatorSupplier.class);
public StatsLiteAggregationBuilder(String name) {
super(name);
}
public StatsLiteAggregationBuilder(StreamInput in) throws IOException {
super(in); // field/script/missing read by the base class
}
@Override
protected ValuesSourceType defaultValueSourceType() {
return CoreValuesSourceType.NUMERIC;
}
@Override
public String getType() {
return NAME;
}
@Override
protected void innerWriteTo(StreamOutput out) {
// no extra fields beyond the base values-source config
}
@Override
protected StatsLiteAggregatorFactory innerBuild(
QueryShardContext queryShardContext,
ValuesSourceConfig config,
AggregatorFactories.Builder subFactoriesBuilder
) throws IOException {
return new StatsLiteAggregatorFactory(name, config, queryShardContext, subFactoriesBuilder, metadata);
}
@Override
protected XContentBuilder doXContentBody(XContentBuilder builder, Params params) {
return builder; // nothing custom to render
}
@Override
public BucketCardinality bucketCardinality() {
return BucketCardinality.NONE; // it's a metric, produces no buckets
}
}
The supplier interface the registry binds (StatsLiteAggregator::new matches it):
/* SPDX header ... */
package org.opensearch.aggregations.statslite;
import org.opensearch.search.aggregations.Aggregator;
import org.opensearch.search.aggregations.support.ValuesSourceConfig;
import org.opensearch.search.internal.SearchContext;
// ... (signature mirrors a built-in metric supplier; compare with AvgAggregatorSupplier)
@FunctionalInterface
public interface StatsLiteAggregatorSupplier {
Aggregator build(String name, ValuesSourceConfig config, SearchContext context,
Aggregator parent, java.util.Map<String, Object> metadata) throws java.io.IOException;
}
Step 5 — Register it in the plugin (10 min)
/* SPDX header ... */
package org.opensearch.aggregations.statslite;
import org.opensearch.plugins.Plugin;
import org.opensearch.plugins.SearchPlugin;
import java.util.List;
public class StatsLitePlugin extends Plugin implements SearchPlugin {
@Override
public List<AggregationSpec> getAggregations() {
return List.of(
new AggregationSpec(
StatsLiteAggregationBuilder.NAME,
StatsLiteAggregationBuilder::new, // wire reader
(parser, name) -> new StatsLiteAggregationBuilder(name) // XContent parser
)
.addResultReader(InternalStatsLite::new) // reads the partial off the wire
.setAggregatorRegistrar(StatsLiteAggregatorFactory::registerAggregators)
);
}
}
The four registration hooks map directly onto the four classes you wrote:
| Hook | Class | Why |
|---|---|---|
| Builder wire reader | StatsLiteAggregationBuilder::new(StreamInput) | Builder crosses to data nodes |
| Builder XContent parser | (parser,name) -> ... | JSON "stats_lite": {...} → builder |
addResultReader | InternalStatsLite::new(StreamInput) | Partial crosses back to coordinator |
setAggregatorRegistrar | StatsLiteAggregatorFactory::registerAggregators | Binds NUMERIC values source → aggregator |
plugin-descriptor.properties is generated by the opensearch.opensearchplugin Gradle plugin from
build.gradle; you do not write it by hand.
Step 6 — Build, install, and exercise (15 min)
./gradlew :plugins:agg-stats-lite:spotlessApply
./gradlew :plugins:agg-stats-lite:assemble
./gradlew :plugins:agg-stats-lite:precommit # license header, checkstyle, forbidden APIs
# Run a node WITH the plugin pre-installed:
./gradlew run -PinstalledPlugins="['agg-stats-lite']"
Then exercise it:
export OS=http://localhost:9200
curl -s -XPUT "$OS/m" -H 'Content-Type: application/json' -d '{"mappings":{"properties":{"v":{"type":"double"}}}}'
for x in 3 7 1 9 4 2; do curl -s -XPOST "$OS/m/_doc" -H 'Content-Type: application/json' -d "{\"v\":$x}" >/dev/null; done
curl -s -XPOST "$OS/m/_refresh" >/dev/null
curl -s "$OS/m/_search?size=0" -H 'Content-Type: application/json' -d '{
"aggs": { "s": { "stats_lite": { "field": "v" } } }
}' | python3 -m json.tool
Expected (count=6, sum=26, min=1, max=9):
{ "aggregations": { "s": { "count": 6, "sum": 26.0, "min": 1.0, "max": 9.0 } } }
Confirm it survives multi-shard reduce — recreate m with 3 shards and re-run; the numbers must be
identical (this is your reduce being exercised across shards).
Step 7 — Test with AggregatorTestCase (20 min)
AggregatorTestCase builds a tiny in-memory Lucene index, runs your aggregator over it, and gives
you the reduced InternalAggregation — no cluster needed.
/* SPDX header ... */
package org.opensearch.aggregations.statslite;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.SortedNumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.RandomIndexWriter;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.index.RandomIndexWriter; // package may differ by branch
import org.opensearch.index.mapper.MappedFieldType;
import org.opensearch.index.mapper.NumberFieldMapper;
import org.opensearch.search.aggregations.AggregatorTestCase;
import java.util.List;
import static org.apache.lucene.document.NumericUtils.doubleToSortableLong;
public class StatsLiteAggregatorTests extends AggregatorTestCase {
public void testCountSumMinMax() throws Exception {
MappedFieldType ft = new NumberFieldMapper.NumberFieldType("v", NumberFieldMapper.NumberType.DOUBLE);
StatsLiteAggregationBuilder builder = new StatsLiteAggregationBuilder("s").field("v");
testCase(builder, new org.apache.lucene.search.MatchAllDocsQuery(), iw -> {
for (double v : new double[]{3, 7, 1, 9, 4, 2}) {
Document d = new Document();
d.add(new SortedNumericDocValuesField("v", doubleToSortableLong(v)));
iw.addDocument(d);
}
}, (InternalStatsLite result) -> {
assertEquals(6, result.getCount());
assertEquals(26.0, result.getSum(), 0.0);
assertEquals(1.0, result.getMin(), 0.0);
assertEquals(9.0, result.getMax(), 0.0);
}, ft);
}
public void testEmptyIsZeroCount() throws Exception {
MappedFieldType ft = new NumberFieldMapper.NumberFieldType("v", NumberFieldMapper.NumberType.DOUBLE);
StatsLiteAggregationBuilder builder = new StatsLiteAggregationBuilder("s").field("v");
testCase(builder, new org.apache.lucene.search.MatchAllDocsQuery(), iw -> {}, (InternalStatsLite r) -> {
assertEquals(0, r.getCount());
assertEquals(0.0, r.getSum(), 0.0);
}, ft);
}
}
Run it (and add a serialization round-trip test — extend AbstractWireSerializingTestCase<InternalStatsLite>
to prove writeTo/readFrom agree, the BWC guard):
./gradlew :plugins:agg-stats-lite:test --tests "*.StatsLiteAggregatorTests"
Note: The exact
testCase(...)overload signature varies by branch. If it does not compile, grep an existing metric test for the current shape:grep -rln "extends AggregatorTestCase" server/src/test/java/org/opensearch/search/aggregations/metrics/ | head
Implementation Requirements
-
Five classes compile:
StatsLitePlugin,StatsLiteAggregationBuilder,StatsLiteAggregatorFactory,StatsLiteAggregator,InternalStatsLite(+ supplier). -
Every file carries the SPDX header;
precommitpasses. -
InternalStatsLite.reducemerges accumulators (count/sum/min/max), not concatenation. -
getAggregations()registers all four hooks (builder reader, parser, result reader, registrar). -
_searchwithstats_litereturns correct count/sum/min/max on a multi-shard index. -
StatsLiteAggregatorTestspasses (non-empty + empty cases). -
A
Writeableround-trip test passes forInternalStatsLite.
Expected Output
> Task :plugins:agg-stats-lite:test
StatsLiteAggregatorTests > testCountSumMinMax PASSED
StatsLiteAggregatorTests > testEmptyIsZeroCount PASSED
InternalStatsLiteWireTests > testSerialization PASSED
BUILD SUCCESSFUL
And the _search response: {"count":6,"sum":26.0,"min":1.0,"max":9.0}, identical on 1 and 3 shards.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
unknown aggregation type [stats_lite] | Plugin not installed / not registered | run -PinstalledPlugins="['agg-stats-lite']"; check getAggregations() |
NamedWriteable... [stats_lite] not found on reduce | Missing addResultReader(InternalStatsLite::new) | Add the result reader hook |
| Wire round-trip test fails | doWriteTo / StreamInput ctor field order mismatch | Make read order == write order |
| Aggregating throws "no doc values" | Field is text/non-numeric | Use a numeric field with DocValues |
| Multi-shard total wrong | reduce concatenated instead of merging | Sum count/sum, min/max across partials |
precommit fails on header | Missing SPDX block | Add the header to every file |
Stretch Goals
- Add
avgas a derived output (sum/count) indoXContentBody— and confirm you do not ship it on the wire (derive on render, reduce the accumulators). This is the reducible-metric rule made literal. - Convert the plugin into a custom query instead, via
SearchPlugin.getQueries()and aQuerySpecregistering a*QueryBuilder(Writeable+ToXContent) whosedoToQueryreturns a LuceneQuery. Compare the registration surface togetAggregations(). - Add a
yamlRestTestundersrc/yamlRestTest/resources/rest-api-spec/test/that asserts the_searchcontract end-to-end on a real./gradlew run-style cluster. - Make
stats_litework as a sub-aggregation inside atermsbucket and confirm the bucket-ordinal accumulator arrays (thegrowIfNeededlogic) handle many owning buckets.
Validation / Self-check
- Which method is your collect loop, and what does it read from each doc?
- Why does
InternalStatsLiteship count/sum/min/max and not the average? - What are the four registration hooks in
getAggregations(), and which class does each bind? - Why must
doWriteToand theStreamInputconstructor agree on field order? - How does
AggregatorTestCaselet you testreducewithout a cluster? - What changes if you make this a
getQueries()registration instead ofgetAggregations()? - Why must your multi-shard result equal your single-shard result, and which method guarantees it?
Cross-references: Aggregations deep dive, Plugin Architecture, Serialization and BWC, Level 8: Real Issue Contribution.