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:

  1. Plugin implements SearchPlugin with getAggregations() registration.
  2. StatsLiteAggregationBuilderWriteable + ToXContent, parsed from JSON.
  3. StatsLiteAggregatorFactory — builds the per-shard aggregator.
  4. StatsLiteAggregator — the per-shard collect + buildAggregation.
  5. InternalStatsLite — the partial result and the reduce that 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 + reduce correct is the skill that separates a mergeable PR from a "back to the drawing board" review.
  • The AggregatorTestCase harness you use here is the same one core uses for built-in aggs.

Prerequisites


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.md and PR quality cover the conventions). ./gradlew precommit will 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: doWriteTo and the StreamInput constructor 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. BigArrays access, the exact NumericMetricsAggregator.MultiValue contract, metadata() vs getMetadata()). 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:

HookClassWhy
Builder wire readerStatsLiteAggregationBuilder::new(StreamInput)Builder crosses to data nodes
Builder XContent parser(parser,name) -> ...JSON "stats_lite": {...} → builder
addResultReaderInternalStatsLite::new(StreamInput)Partial crosses back to coordinator
setAggregatorRegistrarStatsLiteAggregatorFactory::registerAggregatorsBinds 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; precommit passes.
  • InternalStatsLite.reduce merges accumulators (count/sum/min/max), not concatenation.
  • getAggregations() registers all four hooks (builder reader, parser, result reader, registrar).
  • _search with stats_lite returns correct count/sum/min/max on a multi-shard index.
  • StatsLiteAggregatorTests passes (non-empty + empty cases).
  • A Writeable round-trip test passes for InternalStatsLite.

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

SymptomCauseFix
unknown aggregation type [stats_lite]Plugin not installed / not registeredrun -PinstalledPlugins="['agg-stats-lite']"; check getAggregations()
NamedWriteable... [stats_lite] not found on reduceMissing addResultReader(InternalStatsLite::new)Add the result reader hook
Wire round-trip test failsdoWriteTo / StreamInput ctor field order mismatchMake read order == write order
Aggregating throws "no doc values"Field is text/non-numericUse a numeric field with DocValues
Multi-shard total wrongreduce concatenated instead of mergingSum count/sum, min/max across partials
precommit fails on headerMissing SPDX blockAdd the header to every file

Stretch Goals

  1. Add avg as a derived output (sum/count) in doXContentBody — and confirm you do not ship it on the wire (derive on render, reduce the accumulators). This is the reducible-metric rule made literal.
  2. Convert the plugin into a custom query instead, via SearchPlugin.getQueries() and a QuerySpec registering a *QueryBuilder (Writeable + ToXContent) whose doToQuery returns a Lucene Query. Compare the registration surface to getAggregations().
  3. Add a yamlRestTest under src/yamlRestTest/resources/rest-api-spec/test/ that asserts the _search contract end-to-end on a real ./gradlew run-style cluster.
  4. Make stats_lite work as a sub-aggregation inside a terms bucket and confirm the bucket-ordinal accumulator arrays (the growIfNeeded logic) handle many owning buckets.

Validation / Self-check

  1. Which method is your collect loop, and what does it read from each doc?
  2. Why does InternalStatsLite ship count/sum/min/max and not the average?
  3. What are the four registration hooks in getAggregations(), and which class does each bind?
  4. Why must doWriteTo and the StreamInput constructor agree on field order?
  5. How does AggregatorTestCase let you test reduce without a cluster?
  6. What changes if you make this a getQueries() registration instead of getAggregations()?
  7. 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.