You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ha...@apache.org on 2026/10/23 12:28:21 UTC

(skywalking-banyandb) branch main updated: [2/4] Add SQL measure aggregation and native pushdown (#1424)

This is an automated email from the ASF dual-hosted git repository.

hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git


The following commit(s) were added to refs/heads/main by this push:
     new e409a6c68 [2/4] Add SQL measure aggregation and native pushdown (#1424)
e409a6c68 is described below

commit e409a6c688452a1edf6de15ba1a4692c81bf5665
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Fri Oct 23 20:28:14 2026 +0800

    [2/4] Add SQL measure aggregation and native pushdown (#1424)
---
 CHANGES.md                                         |      1 +
 banyand/liaison/grpc/measure.go                    |     19 +-
 banyand/liaison/grpc/measure_ql_aggregate.go       |    245 +
 banyand/liaison/grpc/measure_ql_aggregate_hooks.go |     32 +
 .../grpc/measure_ql_aggregate_hooks_disabled.go    |     27 +
 .../grpc/measure_ql_aggregate_hooks_enabled.go     |     37 +
 banyand/liaison/grpc/measure_ql_aggregate_test.go  |    133 +
 banyand/liaison/grpc/measure_ql_raw.go             |     56 +-
 docs/design/0.12.0/README.md                       |      1 +
 docs/design/0.12.0/measure-ql-core-sql/README.md   |     54 +-
 .../measure-ql-core-sql/datafusion-engine.md       |     18 +-
 .../measure-ql-core-sql/implementation-tickets.md  |    106 +-
 .../0.12.0/measure-ql-core-sql/plan-reuse.md       |     33 +-
 .../0.12.0/measure-ql-core-sql/pr2-verification.md |    236 +
 .../0.12.0/measure-ql-core-sql/t07-verification.md |    312 +
 .../0.12.0/measure-ql-core-sql/t08-verification.md |    270 +
 docs/interacting/bydbql.md                         |     47 +-
 pkg/bydbql/plan_measure.go                         |     49 +-
 pkg/bydbql/plan_measure_aggregate.go               |    198 +
 pkg/bydbql/plan_measure_aggregate_test.go          |    113 +
 pkg/bydbql/plan_measure_entities.go                |    266 +
 pkg/bydbql/plan_measure_entities_test.go           |    219 +
 pkg/bydbql/plan_measure_pushdown.go                |    219 +
 pkg/bydbql/plan_measure_pushdown_test.go           |    151 +
 pkg/bydbql/plan_measure_test.go                    |      2 +-
 pkg/query/ql/aggregate.go                          |    405 +
 pkg/query/ql/aggregate_benchmark_test.go           |    292 +
 pkg/query/ql/aggregate_group.go                    |    269 +
 pkg/query/ql/aggregate_index_test.go               |    103 +
 pkg/query/ql/aggregate_lifecycle_test.go           |     86 +
 pkg/query/ql/aggregate_merge.go                    |    284 +
 pkg/query/ql/aggregate_merge_benchmark_test.go     |    216 +
 pkg/query/ql/aggregate_merge_contract_test.go      |    237 +
 pkg/query/ql/aggregate_merge_group.go              |    151 +
 pkg/query/ql/aggregate_merge_input.go              |    236 +
 pkg/query/ql/aggregate_merge_keys_test.go          |     95 +
 pkg/query/ql/aggregate_merge_lifecycle_test.go     |    212 +
 pkg/query/ql/aggregate_merge_metrics.go            |     37 +
 pkg/query/ql/aggregate_merge_metrics_disabled.go   |     29 +
 pkg/query/ql/aggregate_merge_metrics_enabled.go    |     75 +
 pkg/query/ql/aggregate_merge_metrics_test.go       |     41 +
 pkg/query/ql/aggregate_merge_output.go             |    118 +
 pkg/query/ql/aggregate_merge_resource_test.go      |    121 +
 pkg/query/ql/aggregate_merge_test.go               |    116 +
 pkg/query/ql/aggregate_metrics.go                  |     37 +
 pkg/query/ql/aggregate_metrics_disabled.go         |     26 +
 pkg/query/ql/aggregate_metrics_enabled.go          |     70 +
 pkg/query/ql/aggregate_metrics_test.go             |     57 +
 pkg/query/ql/aggregate_output.go                   |     82 +
 pkg/query/ql/aggregate_query.go                    |     53 +
 pkg/query/ql/aggregate_query_test.go               |    103 +
 pkg/query/ql/aggregate_state.go                    |    183 +
 pkg/query/ql/aggregate_test.go                     |    479 +
 pkg/query/ql/aggregate_value.go                    |    221 +
 pkg/query/ql/raw.go                                |     19 +-
 test/cases/measure/cmd/generate/ql_only.go         |      6 +-
 test/cases/measure/ql_generated.go                 |      2 +
 test/cases/measure/ql_generated_aggregate.go       |    141 +
 .../measure/ql_generated_aggregate_compare.go      |     90 +
 .../cases/measure/ql_generated_native_aggregate.go |    173 +
 .../ql_generated_native_aggregate_compare.go       |    100 +
 .../ql_generated_native_aggregate_lifecycle.go     |    149 +
 test/cases/measure/qlgen/README.md                 |     92 +-
 test/cases/measure/qlgen/coverage_contract.go      |     40 +-
 test/cases/measure/qlgen/fixture.go                |     30 +-
 test/cases/measure/qlgen/generate.go               |     16 +-
 test/cases/measure/qlgen/generated/suite.json      | 112752 +++++++++++++++++-
 test/cases/measure/qlgen/model.go                  |     24 +-
 test/cases/measure/qlgen/suite.go                  |     55 +-
 test/cases/measure/qlgen/t07_fixture.go            |    123 +
 test/cases/measure/qlgen/t07_generate.go           |    194 +
 test/cases/measure/qlgen/t07_model.go              |    122 +
 test/cases/measure/qlgen/t07_oracle.go             |    199 +
 test/cases/measure/qlgen/t07_test.go               |    173 +
 test/cases/measure/qlgen/t08_fixture.go            |    204 +
 test/cases/measure/qlgen/t08_generate.go           |    206 +
 test/cases/measure/qlgen/t08_model.go              |    102 +
 test/cases/measure/qlgen/t08_oracle.go             |    210 +
 test/cases/measure/qlgen/t08_test.go               |    216 +
 test/integration/distributed/querybench/README.md  |    183 +
 .../distributed/querybench/benchmark_test.go       |     41 +-
 test/integration/distributed/querybench/config.go  |     42 +-
 .../distributed/querybench/orchestrate.sh          |     15 +
 .../distributed/querybench/qlb_t07_build_test.go   |    201 +
 .../distributed/querybench/qlb_t07_data_test.go    |    212 +
 .../querybench/qlb_t07_metrics_disabled_test.go    |     32 +
 .../distributed/querybench/qlb_t07_metrics_test.go |     76 +
 .../distributed/querybench/qlb_t07_oracle_test.go  |    159 +
 .../querybench/qlb_t07_readiness_contract_test.go  |    124 +
 .../querybench/qlb_t07_readiness_test.go           |    109 +
 .../distributed/querybench/qlb_t07_report_test.go  |    171 +
 .../querybench/qlb_t07_resources_test.go           |    184 +
 .../distributed/querybench/qlb_t07_test.go         |    170 +
 .../distributed/querybench/qlb_t08_build_test.go   |    102 +
 .../querybench/qlb_t08_contract_test.go            |    154 +
 .../distributed/querybench/qlb_t08_data_test.go    |    115 +
 .../querybench/qlb_t08_metrics_disabled_test.go    |     36 +
 .../distributed/querybench/qlb_t08_metrics_test.go |    126 +
 .../distributed/querybench/qlb_t08_oracle_test.go  |    162 +
 .../distributed/querybench/qlb_t08_report_test.go  |    228 +
 .../distributed/querybench/qlb_t08_test.go         |    179 +
 .../standalone/query/vectorized_test.go            |      4 +-
 102 files changed, 125376 insertions(+), 165 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 74b83cff5..75062f828 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -6,6 +6,7 @@ Release Notes.
 
 ### Features
 
+- Add SQL-correct measure QL grouping and multiple aggregates, with shared calls, weighted AVG, bounded local state and eligible native aggregate split/merge.
 - Sample traces with source-stage native plugins before lifecycle migration, with durable decisions, checkpointed replay, and crash recovery.
 - Add typed raw measure QL with WHERE time, aliases and bounded compiled-plan reuse, executed over shared columnar native batches.
 - Add a native ICE v3 encoder that writes committed index generations compatible with existing readers.
diff --git a/banyand/liaison/grpc/measure.go b/banyand/liaison/grpc/measure.go
index f7ca4c75a..0f09ff7c6 100644
--- a/banyand/liaison/grpc/measure.go
+++ b/banyand/liaison/grpc/measure.go
@@ -485,7 +485,6 @@ func (ms *measureService) executeQueryWithRawPlan(ctx context.Context, req *meas
 	now := time.Now()
 	var tracer *query.Tracer
 	var span *query.Span
-	var responseDataPointCount int
 	if req.Trace {
 		tracer, _ = query.NewTracer(ctx, now.Format(time.RFC3339Nano))
 		span, _ = tracer.StartSpan(ctx, "measure-grpc")
@@ -495,7 +494,7 @@ func (ms *measureService) executeQueryWithRawPlan(ctx context.Context, req *meas
 				span.Error(err)
 				span.Stop()
 			} else {
-				span.Tagf("response_data_point_count", "%d", responseDataPointCount)
+				span.Tagf("response_data_point_count", "%d", len(resp.GetDataPoints()))
 				span.AddSubTrace(resp.Trace)
 				span.Stop()
 				resp.Trace = tracer.ToProto()
@@ -534,17 +533,17 @@ func (ms *measureService) executeQueryWithRawPlan(ctx context.Context, req *meas
 		originalBegin := req.TimeRange.GetBegin().AsTime()
 		newBegin, rangeEmpty := clampTimeRangeBegin(originalBegin, endTime, createdAts)
 		if rangeEmpty {
-			if len(gatedStatuses) > 0 {
-				return &measurev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
-			}
-			return emptyMeasureQueryResponse, nil
+			return emptyPlannedMeasure(ctx, gatedStatuses, rawPlan)
 		}
 		if newBegin != originalBegin {
 			req.TimeRange.Begin = timestamppb.New(newBegin)
 		}
 	}
 	if isEmptyRawQuery(rawPlan) {
-		return &measurev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+		return emptyPlannedMeasure(ctx, gatedStatuses, rawPlan)
+	}
+	if ms.canUseNativeAggregate(req, rawPlan) {
+		return ms.executeNativeAggregate(ctx, req, gatedStatuses, rawPlan)
 	}
 	var payload any = req
 	if batches {
@@ -557,14 +556,14 @@ func (ms *measureService) executeQueryWithRawPlan(ctx context.Context, req *meas
 	msg, err := feat.Get()
 	if err != nil {
 		if errors.Is(err, io.EOF) {
+			if rawPlan != nil && rawPlan.Aggregate != nil {
+				return emptyPlannedMeasure(ctx, gatedStatuses, rawPlan)
+			}
 			return emptyMeasureQueryResponse, nil
 		}
 		return nil, err
 	}
 	resp, err = measureQueryResponseWithPlan(ctx, msg.Data(), gatedStatuses, rawPlan)
-	if resp != nil {
-		responseDataPointCount = len(resp.DataPoints)
-	}
 	return resp, err
 }
 
diff --git a/banyand/liaison/grpc/measure_ql_aggregate.go b/banyand/liaison/grpc/measure_ql_aggregate.go
new file mode 100644
index 000000000..00328a4b7
--- /dev/null
+++ b/banyand/liaison/grpc/measure_ql_aggregate.go
@@ -0,0 +1,245 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	"github.com/apache/skywalking-banyandb/api/data"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+	"github.com/apache/skywalking-banyandb/pkg/bus"
+	"github.com/apache/skywalking-banyandb/pkg/query"
+	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+	vmeasure "github.com/apache/skywalking-banyandb/pkg/query/vectorized/measure"
+)
+
+func (ms *measureService) canUseNativeAggregate(request *measurev1.QueryRequest, plan *ql.RawPlan) bool {
+	if plan == nil || plan.NativeAggregate == nil || nativeAggregateHooks().Disabled || ms.groupRepo == nil || len(request.Groups) != 1 {
+		return false
+	}
+	// Group lifecycle options are watched independently of the measure schema
+	// and therefore must be checked after binding a cached measure template.
+	ms.groupRepo.RLock()
+	defer ms.groupRepo.RUnlock()
+	options := ms.groupRepo.resourceOpts[request.Groups[0]]
+	if options == nil {
+		return false
+	}
+	stages := request.Stages
+	if len(stages) == 0 {
+		stages = options.DefaultStages
+	}
+	if len(stages) == 0 {
+		return len(options.Stages) == 0
+	}
+	// A custom global hot selector is outside the liaison's schema view.
+	if len(stages) != 1 || strings.EqualFold(stages[0], "hot") {
+		return false
+	}
+	matches := 0
+	for _, stage := range options.Stages {
+		if strings.EqualFold(stage.Name, stages[0]) && strings.TrimSpace(stage.NodeSelector) != "" {
+			matches++
+		}
+	}
+	return matches == 1
+}
+
+func (ms *measureService) executeNativeAggregate(ctx context.Context, request *measurev1.QueryRequest,
+	statuses map[string]modelv1.Status, plan *ql.RawPlan,
+) (response *measurev1.QueryResponse, err error) {
+	native := *plan.NativeAggregate
+	hooks := nativeAggregateHooks()
+	native.MaxGroups = hooks.MaxGroups
+	var tracer *query.Tracer
+	var span *query.Span
+	if request.Trace {
+		tracer, ctx = query.NewTracer(ctx, "ql-native-aggregation")
+		span, ctx = tracer.StartSpan(ctx, "native-aggregate-merge")
+		defer func() {
+			if err != nil {
+				span.Error(err)
+			}
+			span.Stop()
+			if response != nil {
+				response.Trace = tracer.ToProto()
+			}
+		}()
+	}
+	branch := 0
+	open := func(branchCtx context.Context, leaf ql.NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		if cancelErr := branchCtx.Err(); cancelErr != nil {
+			return nil, cancelErr
+		}
+		// Bind owns each leaf request privately. Carry forward the current
+		// creation-time clamp and revision/authorization provenance.
+		leaf.Request.TimeRange = request.TimeRange
+		leaf.Request.GroupModRevisions = request.GroupModRevisions
+		if hooks.BeforeBranch != nil {
+			if hookErr := hooks.BeforeBranch(branchCtx, branch, leaf.Request); hookErr != nil {
+				return nil, hookErr
+			}
+		}
+		if cancelErr := branchCtx.Err(); cancelErr != nil {
+			return nil, cancelErr
+		}
+		if !ms.canUseNativeAggregate(request, plan) {
+			return nil, status.Error(codes.FailedPrecondition, "native aggregate lifecycle selection changed before dispatch")
+		}
+		if gateErr := ms.checkNativeAggregateGate(leaf.Request); gateErr != nil {
+			return nil, gateErr
+		}
+		branch++
+		result, openErr := ms.openNativeAggregateBranch(branchCtx, leaf, native.Aggregate.GroupBy)
+		if openErr != nil {
+			return nil, openErr
+		}
+		return &nativeAggregateBranchResult{PullOperator: result, finish: func() {
+			if span != nil {
+				if traced, ok := result.(vmeasure.BatchQueryResult); ok {
+					span.AddSubTrace(traced.QueryTrace())
+				}
+			}
+		}}, nil
+	}
+	if len(native.Leaves) == 0 {
+		return nil, fmt.Errorf("native aggregate plan has no leaves")
+	}
+	// The processor initializes the shared memory scope with its configured
+	// limit. Open the first leaf before constructing local state so no default
+	// liaison limit can replace that processor-owned configuration.
+	first, err := open(ctx, native.Leaves[0])
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		if first != nil {
+			_ = first.Close()
+		}
+	}()
+	operator, err := ql.MergeNativeAggregate(ctx, &native, func(branchCtx context.Context, leaf ql.NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		if first != nil {
+			result := first
+			first = nil
+			return result, nil
+		}
+		return open(branchCtx, leaf)
+	})
+	if err != nil {
+		return nil, err
+	}
+	tracker := vectorized.QueryMemoryTracker(ctx, 256<<20)
+	bytes := int64(len(native.Aggregate.Outputs)) * 256
+	if err = tracker.Reserve(bytes); err != nil {
+		_ = operator.Close()
+		return nil, err
+	}
+	outputs := make([]ql.Slot, len(native.Aggregate.Outputs))
+	for index, output := range native.Aggregate.Outputs {
+		outputs[index] = ql.Slot{ID: ql.SlotID(index), Source: output.Column, Output: output.Column, Nullable: true}
+	}
+	response, collectErr := collectPlannedMeasure(ctx, operator, statuses, &ql.RawPlan{Slots: outputs, Limit: plan.Limit, Offset: plan.Offset})
+	tracker.Release(bytes)
+	if collectErr != nil {
+		return nil, collectErr
+	}
+	return response, nil
+}
+
+func (ms *measureService) checkNativeAggregateGate(request *measurev1.QueryRequest) error {
+	registry := schemaRevisionRegistry(ms.metadataRepo)
+	statuses, shortCircuit := checkQueryGate(request.Groups, request.Name, request.GroupModRevisions,
+		func(name, group string) (int64, bool) {
+			locator, found := ms.entityRepo.getLocator(identity{name: name, group: group})
+			return resolveQueryGateRevision(registry, schema.KindMeasure, group, name, locator.ModRevision, found)
+		}, ms.maxWaitDuration)
+	if shortCircuit {
+		return status.Errorf(codes.FailedPrecondition, "native aggregate branch failed its revision gate: %v", statuses)
+	}
+	return nil
+}
+
+func (ms *measureService) openNativeAggregateBranch(ctx context.Context, leaf ql.NativeAggregateLeaf,
+	keys []vectorized.ColumnDef,
+) (vectorized.PullOperator, error) {
+	future, err := ms.broadcaster.Publish(ctx, data.TopicMeasureQuery,
+		bus.NewMessage(bus.MessageID(time.Now().UnixNano()), &vmeasure.BatchQueryRequest{Request: leaf.Request}))
+	if err != nil {
+		return nil, err
+	}
+	message, err := future.Get()
+	if err != nil && !errors.Is(err, io.EOF) {
+		return nil, err
+	}
+	if err == nil {
+		switch result := message.Data().(type) {
+		case vmeasure.BatchQueryResult:
+			return result, nil
+		case error:
+			return nil, result
+		case *measurev1.QueryResponse:
+			return nil, status.Errorf(codes.FailedPrecondition, "native aggregate branch returned an incomplete revision-gated result: %v", result.GetGroupStatuses())
+		default:
+			return nil, fmt.Errorf("native aggregate branch requires batches, got %T", result)
+		}
+	}
+	tracker := vectorized.QueryMemoryTracker(ctx, 256<<20)
+	reserved := int64(2048 + (len(keys)+1)*1024)
+	if err = tracker.Reserve(reserved); err != nil {
+		return nil, err
+	}
+	columns := make([]vectorized.ColumnDef, len(keys), len(keys)+1)
+	copy(columns, keys)
+	for index := range columns {
+		columns[index].Entity = false
+	}
+	value := leaf.Source
+	value.Role, value.TagFamily, value.Entity = vectorized.RoleField, "", false
+	if leaf.Function == ql.AggregateCount && value.Type != vectorized.ColumnTypeFloat64 {
+		value.Type = vectorized.ColumnTypeInt64
+	}
+	columns = append(columns, value)
+	return &emptyMeasureScan{schema: vectorized.NewBatchSchema(columns), tracker: tracker, reserved: reserved}, nil
+}
+
+type nativeAggregateBranchResult struct {
+	vectorized.PullOperator
+	finish func()
+	closed bool
+}
+
+func (r *nativeAggregateBranchResult) Close() error {
+	if r.closed {
+		return nil
+	}
+	r.closed = true
+	err := r.PullOperator.Close()
+	r.finish()
+	return err
+}
diff --git a/banyand/liaison/grpc/measure_ql_aggregate_hooks.go b/banyand/liaison/grpc/measure_ql_aggregate_hooks.go
new file mode 100644
index 000000000..29c6e6d9a
--- /dev/null
+++ b/banyand/liaison/grpc/measure_ql_aggregate_hooks.go
@@ -0,0 +1,32 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"context"
+
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+)
+
+// NativeAggregateTestHooks controls real native aggregate requests in instrumented tests.
+// Callers must serialize overrides, honor cancellation and restore the previous hooks.
+type NativeAggregateTestHooks struct {
+	BeforeBranch func(context.Context, int, *measurev1.QueryRequest) error
+	MaxGroups    uint64
+	Disabled     bool
+}
diff --git a/banyand/liaison/grpc/measure_ql_aggregate_hooks_disabled.go b/banyand/liaison/grpc/measure_ql_aggregate_hooks_disabled.go
new file mode 100644
index 000000000..5cfe440d7
--- /dev/null
+++ b/banyand/liaison/grpc/measure_ql_aggregate_hooks_disabled.go
@@ -0,0 +1,27 @@
+//go:build !querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+func nativeAggregateHooks() NativeAggregateTestHooks { return NativeAggregateTestHooks{} }
+
+// SetNativeAggregateHooksForTest requires a binary built with querymetrics.
+func SetNativeAggregateHooksForTest(NativeAggregateTestHooks) func() {
+	panic("native aggregate hooks require -tags=querymetrics")
+}
diff --git a/banyand/liaison/grpc/measure_ql_aggregate_hooks_enabled.go b/banyand/liaison/grpc/measure_ql_aggregate_hooks_enabled.go
new file mode 100644
index 000000000..5521a0475
--- /dev/null
+++ b/banyand/liaison/grpc/measure_ql_aggregate_hooks_enabled.go
@@ -0,0 +1,37 @@
+//go:build querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import "sync/atomic"
+
+var nativeAggregateTestHooks atomic.Pointer[NativeAggregateTestHooks]
+
+func nativeAggregateHooks() NativeAggregateTestHooks {
+	if hooks := nativeAggregateTestHooks.Load(); hooks != nil {
+		return *hooks
+	}
+	return NativeAggregateTestHooks{}
+}
+
+// SetNativeAggregateHooksForTest installs hooks for serialized integration tests.
+func SetNativeAggregateHooksForTest(hooks NativeAggregateTestHooks) func() {
+	previous := nativeAggregateTestHooks.Swap(&hooks)
+	return func() { nativeAggregateTestHooks.Store(previous) }
+}
diff --git a/banyand/liaison/grpc/measure_ql_aggregate_test.go b/banyand/liaison/grpc/measure_ql_aggregate_test.go
new file mode 100644
index 000000000..cb56031e7
--- /dev/null
+++ b/banyand/liaison/grpc/measure_ql_aggregate_test.go
@@ -0,0 +1,133 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/proto"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	"github.com/apache/skywalking-banyandb/pkg/bydbql"
+)
+
+func TestNativeAggregateLifecycleSelection(t *testing.T) {
+	execution := nativeAggregateStageExecution(t)
+	warm := &commonv1.LifecycleStage{Name: "warm", NodeSelector: "role=data-warm"}
+	cold := &commonv1.LifecycleStage{Name: "cold", NodeSelector: "role=data-cold"}
+	cases := []struct {
+		name        string
+		options     *commonv1.ResourceOpts
+		stages      []string
+		groups      []string
+		withoutRepo bool
+		want        bool
+	}{
+		{name: "implicit hot", options: &commonv1.ResourceOpts{}, want: true},
+		{name: "unavailable metadata"},
+		{name: "missing group repository", withoutRepo: true},
+		{name: "multiple measure groups", options: &commonv1.ResourceOpts{}, groups: []string{"g", "other"}},
+		{name: "explicit multiple stages", options: &commonv1.ResourceOpts{Stages: []*commonv1.LifecycleStage{warm, cold}}, stages: []string{"warm", "cold"}},
+		{name: "default multiple stages", options: &commonv1.ResourceOpts{
+			Stages: []*commonv1.LifecycleStage{warm, cold}, DefaultStages: []string{"warm", "cold"},
+		}},
+		{name: "explicit hot", options: &commonv1.ResourceOpts{}, stages: []string{"HoT"}},
+		{name: "default hot", options: &commonv1.ResourceOpts{DefaultStages: []string{"HOT"}}},
+		{name: "configured hot", options: &commonv1.ResourceOpts{
+			Stages: []*commonv1.LifecycleStage{{Name: "hot", NodeSelector: "role=data-hot"}}, DefaultStages: []string{"hot"},
+		}},
+		{name: "unknown stage", options: &commonv1.ResourceOpts{Stages: []*commonv1.LifecycleStage{warm}}, stages: []string{"missing"}},
+		{name: "unknown default", options: &commonv1.ResourceOpts{Stages: []*commonv1.LifecycleStage{warm}, DefaultStages: []string{"missing"}}},
+		{name: "blank selector", options: &commonv1.ResourceOpts{
+			Stages: []*commonv1.LifecycleStage{{Name: "warm", NodeSelector: " \t "}}, DefaultStages: []string{"warm"},
+		}},
+		{name: "configured without selection", options: &commonv1.ResourceOpts{Stages: []*commonv1.LifecycleStage{warm}}},
+		{name: "one requested named stage", options: &commonv1.ResourceOpts{Stages: []*commonv1.LifecycleStage{warm, cold}}, stages: []string{"WARM"}, want: true},
+		{name: "one default named stage", options: &commonv1.ResourceOpts{
+			Stages: []*commonv1.LifecycleStage{warm, cold}, DefaultStages: []string{"warm"},
+		}, want: true},
+		{name: "request overrides multiple defaults", options: &commonv1.ResourceOpts{
+			Stages: []*commonv1.LifecycleStage{warm, cold}, DefaultStages: []string{"warm", "cold"},
+		}, stages: []string{"cold"}, want: true},
+		{name: "ambiguous duplicate stage", options: &commonv1.ResourceOpts{
+			Stages: []*commonv1.LifecycleStage{warm, {Name: "WARM", NodeSelector: "role=other"}}, DefaultStages: []string{"warm"},
+		}},
+	}
+	for _, example := range cases {
+		t.Run(example.name, func(t *testing.T) {
+			request := proto.Clone(execution.Request).(*measurev1.QueryRequest)
+			request.Stages = example.stages
+			if example.groups != nil {
+				request.Groups = example.groups
+			}
+			repo := &groupRepo{resourceOpts: map[string]*commonv1.ResourceOpts{"g": example.options}}
+			if example.withoutRepo {
+				repo = nil
+			}
+			service := &measureService{discoveryService: &discoveryService{groupRepo: repo}}
+			require.Equal(t, example.want, service.canUseNativeAggregate(request, execution.Plan))
+		})
+	}
+}
+
+func TestNativeAggregateLifecycleSelectionRereadsOptionsForBoundPlan(t *testing.T) {
+	execution := nativeAggregateStageExecution(t)
+	candidate := execution.Plan.NativeAggregate
+	repo := &groupRepo{resourceOpts: make(map[string]*commonv1.ResourceOpts)}
+	service := &measureService{discoveryService: &discoveryService{groupRepo: repo}}
+	updates := []struct {
+		options *commonv1.ResourceOpts
+		want    bool
+	}{
+		{&commonv1.ResourceOpts{}, true},
+		{&commonv1.ResourceOpts{
+			Stages: []*commonv1.LifecycleStage{{Name: "warm", NodeSelector: "role=data-warm"}}, DefaultStages: []string{"warm"},
+		}, true},
+		{&commonv1.ResourceOpts{DefaultStages: []string{"warm", "cold"}}, false},
+		{&commonv1.ResourceOpts{DefaultStages: []string{"hot"}}, false},
+		{nil, false},
+		{&commonv1.ResourceOpts{}, true},
+	}
+	for index, update := range updates {
+		repo.Lock()
+		repo.resourceOpts["g"] = update.options
+		repo.Unlock()
+		require.Equal(t, update.want, service.canUseNativeAggregate(execution.Request, execution.Plan), "metadata update %d", index)
+		require.Same(t, candidate, execution.Plan.NativeAggregate)
+	}
+}
+
+func nativeAggregateStageExecution(t *testing.T) *bydbql.MeasureExecution {
+	t.Helper()
+	schema := minimalMeasure("g", "m", 100)
+	schema.Fields = []*databasev1.FieldSpec{{Name: "v", FieldType: databasev1.FieldType_FIELD_TYPE_INT}}
+	statement, err := bydbql.Prepare("SELECT COUNT(v) AS c FROM g.m WHERE svc = 'a' " +
+		"AND time >= '1970-01-01T00:00:00Z' AND time < '1970-01-01T00:00:06Z'")
+	require.NoError(t, err)
+	template, err := bydbql.NewTransformer(nil).CompileMeasureWithSchemas(t.Context(), statement,
+		map[bydbql.MeasureSource]*databasev1.Measure{{Group: "g", Name: "m"}: schema})
+	require.NoError(t, err)
+	execution, err := template.BindParams(nil, time.UnixMilli(0))
+	require.NoError(t, err)
+	require.NotNil(t, execution.Plan.NativeAggregate)
+	return execution
+}
diff --git a/banyand/liaison/grpc/measure_ql_raw.go b/banyand/liaison/grpc/measure_ql_raw.go
index 23e73ca49..b0df42b7f 100644
--- a/banyand/liaison/grpc/measure_ql_raw.go
+++ b/banyand/liaison/grpc/measure_ql_raw.go
@@ -24,6 +24,7 @@ import (
 	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
 	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
 	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
 	vmeasure "github.com/apache/skywalking-banyandb/pkg/query/vectorized/measure"
 )
 
@@ -42,7 +43,7 @@ func measureQueryResponseWithPlan(ctx context.Context, data any, statuses map[st
 
 func measureRawQueryResponse(ctx context.Context, data any, statuses map[string]modelv1.Status,
 	plan *ql.RawPlan,
-) (_ *measurev1.QueryResponse, err error) {
+) (*measurev1.QueryResponse, error) {
 	result, ok := data.(vmeasure.BatchQueryResult)
 	if !ok {
 		if failure, failed := data.(error); failed {
@@ -50,7 +51,17 @@ func measureRawQueryResponse(ctx context.Context, data any, statuses map[string]
 		}
 		return nil, fmt.Errorf("typed measure query requires a native batch result, got %T", data)
 	}
-	scan := ql.NewNativeScan(result)
+	response, collectErr := collectPlannedMeasure(ctx, ql.NewNativeScan(result), statuses, plan)
+	if collectErr != nil {
+		return nil, collectErr
+	}
+	response.Trace = result.QueryTrace()
+	return response, nil
+}
+
+func collectPlannedMeasure(ctx context.Context, scan vectorized.PullOperator, statuses map[string]modelv1.Status,
+	plan *ql.RawPlan,
+) (_ *measurev1.QueryResponse, err error) {
 	defer func() {
 		if closeErr := scan.Close(); err == nil {
 			err = queryStatus(closeErr)
@@ -90,6 +101,45 @@ func measureRawQueryResponse(ctx context.Context, data any, statuses map[string]
 	if err = iterator.Close(); err != nil {
 		return nil, queryStatus(err)
 	}
-	response.Trace = result.QueryTrace()
 	return response, nil
 }
+
+func emptyPlannedMeasure(ctx context.Context, statuses map[string]modelv1.Status, plan *ql.RawPlan) (*measurev1.QueryResponse, error) {
+	if plan == nil || plan.Aggregate == nil || plan.Limit != nil && *plan.Limit == 0 {
+		return &measurev1.QueryResponse{GroupStatuses: statuses}, nil
+	}
+	tracker := vectorized.QueryMemoryTracker(ctx, 256<<20)
+	bytes := int64(2048 + len(plan.Slots)*1024)
+	if err := tracker.Reserve(bytes); err != nil {
+		return nil, queryStatus(err)
+	}
+	columns := make([]vectorized.ColumnDef, len(plan.Slots))
+	for index, slot := range plan.Slots {
+		columns[index] = slot.Source
+	}
+	empty := *plan
+	empty.Empty, empty.Residual, empty.Ranges = true, false, nil
+	source := &emptyMeasureScan{schema: vectorized.NewBatchSchema(columns), tracker: tracker, reserved: bytes}
+	return collectPlannedMeasure(ctx, source, statuses, &empty)
+}
+
+type emptyMeasureScan struct {
+	schema   *vectorized.BatchSchema
+	tracker  *vectorized.MemoryTracker
+	reserved int64
+}
+
+func (*emptyMeasureScan) Init(ctx context.Context) error          { return ctx.Err() }
+func (s *emptyMeasureScan) OutputSchema() *vectorized.BatchSchema { return s.schema }
+func (*emptyMeasureScan) NextBatch(ctx context.Context) (*vectorized.RecordBatch, error) {
+	return nil, ctx.Err()
+}
+
+func (s *emptyMeasureScan) Close() error {
+	if s.reserved != 0 {
+		s.tracker.Release(s.reserved)
+		s.reserved = 0
+	}
+	s.schema = nil
+	return nil
+}
diff --git a/docs/design/0.12.0/README.md b/docs/design/0.12.0/README.md
index 2d182e0e5..9be170f2f 100644
--- a/docs/design/0.12.0/README.md
+++ b/docs/design/0.12.0/README.md
@@ -3,6 +3,7 @@
 - [Trace sampling before lifecycle migration](lifecycle-trace-sampling.md)
 - [Measure QL Catalog B: core SQL implementation plan](measure-ql-core-sql/README.md)
   - [PR 1 scope and isolated verification](measure-ql-core-sql/pr1-verification.md)
+  - [PR 2 aggregation scope and isolated verification](measure-ql-core-sql/pr2-verification.md)
   - [Compiled measure QL plan reuse](measure-ql-core-sql/plan-reuse.md)
   - [Implementation tickets: per-ticket end-to-end tests and benchmarks](measure-ql-core-sql/implementation-tickets.md)
 - [Tag aggregation and time bucketing in the measure query engine](tag-aggregation/README.md)
diff --git a/docs/design/0.12.0/measure-ql-core-sql/README.md b/docs/design/0.12.0/measure-ql-core-sql/README.md
index 02d8a5629..31ad5b408 100644
--- a/docs/design/0.12.0/measure-ql-core-sql/README.md
+++ b/docs/design/0.12.0/measure-ql-core-sql/README.md
@@ -1,29 +1,28 @@
 # Measure QL Catalog B: Core SQL Implementation Plan
 
-This branch delivers **PR 1 of 4**: the shared execution foundation, typed raw
-measure QL and compiled-plan reuse. See the [PR 1 verification record](pr1-verification.md)
-for its isolated scope and current evidence. Subsequent PRs deliver aggregation,
-expressions/filtering, and complete grouping/output composition.
-
-Status: **T02 shared batch execution, T03 typed raw queries and T05 compiled-plan
-reuse are implemented in [PR #1417](https://github.com/apache/skywalking-banyandb/pull/1417);
-eight later capability tickets are outside this PR.** The isolated branch passes
-451 independent measure/QL cases in each topology, unit/race checks and the focused
-planning benchmark. The latest single RPC comparison has higher cache-on latency
-and CPU despite lower allocated bytes; no end-to-end latency gain is established.
-See the [PR 1 verification record](pr1-verification.md) for the current results.
-
-The T01/T02/T03/T05 verification documents retain historical ticket-stage evidence.
-T05's earlier five-run comparison recorded modest median savings and mixed tails;
-T03's additional compatibility comparison was stopped without a verdict. T02's
-inconclusive/stopped statistical acceptance is preserved. These historical records
-are not measurements of the latest PR head, and the PR 1 refresh does not turn
-those outcomes into passes.
-
-The delivered foundation includes Go-only native buffering, ownership and indexed
-array/empty-selector compatibility repairs, listed in the PR 1 verification
-record. Native requests, frame versions, protobuf definitions and persisted
-formats remain compatible. SQL operators continue to run on the liaison.
+This branch prepares **PR 2 of 4**: local SQL aggregation and eligible native
+aggregate split/merge (T07/T08), on merged [PR #1417](https://github.com/apache/skywalking-banyandb/pull/1417)
+at `e05ed7f22`. See the [PR 2 verification record](pr2-verification.md) for its
+isolated scope, current checks and focused measurements. Expressions/filtering
+and complete grouping/output composition remain in PRs 3 and 4.
+
+Status: **T02/T03/T05 are merged; T07/T08 are implemented in this branch; six later
+capability tickets remain outside this PR.** Aggregation includes SQL null/empty
+semantics, checked integer sums, weighted AVG, shared calls and final pagination.
+Safe native alternatives reuse existing operators; unsupported provider semantics
+retain the complete local path. Eligibility proves correctness rather than cost:
+small queries can be slower with multiple native branches.
+
+The T01/T02/T03/T05/T07/T08 verification documents retain historical ticket-stage
+evidence. The [PR 1 record](pr1-verification.md) preserves its isolated checks and
+mixed cache latency observations. Historical T02 inconclusive/stopped acceptance
+is unchanged. These records do not measure the current PR 2 head; current evidence
+is kept separately rather than converting earlier uncertainty into a pass.
+
+The inherited foundation includes Go-only native buffering, ownership and indexed
+array/empty-selector compatibility repairs. This PR adds liaison aggregation
+operators without changing native requests, frame versions, protobuf definitions
+or persisted formats. Memory protection remains enabled; disk spilling is deferred.
 
 The [DataFusion-inspired engine design](datafusion-engine.md) defines the batch
 execution architecture and revised delivery order. Its [source research](datafusion-research.md)
@@ -62,10 +61,9 @@ specifications are:
   infrastructure, which QL reuses rather than replaces.
 
 `012115c5` is the historical design-review baseline. PR #1412 merged as
-`15635181b`; PR 1 is rebased onto main at `f9424047d`, including the documentation
-fixes from #1415 and native aggregation optimization from #1413. The benchmark
-refresh predates #1413; its measured source and later rebase checks are recorded
-separately in the PR 1 verification record.
+`15635181b`; PR 2 is based on merged PR #1417 at `e05ed7f22`, including the native
+aggregation optimization from #1413. Historical benchmark bases remain in their
+verification documents, separate from the current PR 2 measurements.
 
 The gaps document proposes results as columns and rows but does not specify their
 wire encoding. For this scope, that is an **Arrow-style columnar representation**
diff --git a/docs/design/0.12.0/measure-ql-core-sql/datafusion-engine.md b/docs/design/0.12.0/measure-ql-core-sql/datafusion-engine.md
index e3dbc2fdf..9ec5ee127 100644
--- a/docs/design/0.12.0/measure-ql-core-sql/datafusion-engine.md
+++ b/docs/design/0.12.0/measure-ql-core-sql/datafusion-engine.md
@@ -1,17 +1,17 @@
 # DataFusion-inspired measure QL engine
 
-Status: **partially implemented in PR 1 (T02/T03/T05).** Shared batches, typed raw
-queries and compiled-plan reuse are implemented. The expression, aggregation,
-HAVING, DISTINCT, computed-grouping and general-sort sections describe the target
-architecture for later PRs, not features available in this branch. The current
-scope and evidence are in the [PR 1 verification record](pr1-verification.md).
+Status: **implemented through T08 in PR 2.** Shared batches, typed raw queries
+and compiled-plan reuse are merged through PR #1417. This branch adds local SQL
+aggregation and eligible native aggregate split/merge. Expressions, HAVING,
+DISTINCT, computed grouping and general sorting remain later PRs. Current scope
+and evidence are in the [PR 2 verification record](pr2-verification.md).
 
 This refines the [Catalog B implementation plan](README.md), originally reviewed
 against PR #1412 at `012115c5`. The companion [research note](datafusion-research.md)
 records primary sources and the examined DataFusion revision. SQL extensions run
-in Go on the liaison; the foundation also repairs shared native Go buffering,
-ownership and read/dispatch behavior. Protobuf definitions, native request
-contracts, persisted formats and frame v4 wire encoding remain compatible.
+in Go on the liaison and share native batches and existing native operators.
+Protobuf definitions, native request contracts, persisted formats and frame v4
+wire encoding remain unchanged by this PR.
 
 ## 1. Decision
 
@@ -362,4 +362,4 @@ single-query performance and the prior response-materializing QL design.
 Acceptance: B1–B9 compose through typed batches; the semantic/pushdown equivalence
 tests pass; existing Query responses remain compatible; and protobuf definitions
 and native frame wire formats are unchanged. This is the full Catalog B target;
-only the PR 1 subset identified above is implemented on this branch.
+only the subset through T08 identified above is implemented on this branch.
diff --git a/docs/design/0.12.0/measure-ql-core-sql/implementation-tickets.md b/docs/design/0.12.0/measure-ql-core-sql/implementation-tickets.md
index 75485a301..9a89d128a 100644
--- a/docs/design/0.12.0/measure-ql-core-sql/implementation-tickets.md
+++ b/docs/design/0.12.0/measure-ql-core-sql/implementation-tickets.md
@@ -1,28 +1,27 @@
 # Catalog B implementation tickets and verification contracts
 
-Status: **T02, T03 and T05 are implemented in PR 1; eight capability tickets remain
-planned for later PRs.** Implementation and correctness do not imply that every
-historical performance gate passed: T02's original statistical acceptance remains
-inconclusive/stopped. Current isolated scope, checks and benchmark limits are in
-the [PR 1 verification record](pr1-verification.md). The [T01 verification
-record](t01-verification.md) retains the supporting baseline and is not a separate
-engine capability.
+Status: **T02/T03/T05 are merged in PR #1417; T07/T08 are implemented in PR 2;
+six capability tickets remain planned for PRs 3 and 4.** Implementation and
+correctness do not imply that every historical performance gate passed: T02's
+original acceptance remains inconclusive/stopped. Current aggregation scope,
+checks and benchmark limits are in the [PR 2 verification record](pr2-verification.md).
+The [PR 1 verification record](pr1-verification.md) and [T01 support record](t01-verification.md)
+retain their original bases and evidence.
 
 The original design-review base was `012115c5`. PR #1412 merged as `15635181b`;
-PR 1 targets main at `f9424047d`, including documentation-example fixes from
-#1415 and the native aggregation optimization from #1413.
-This document defines the [language contract](README.md),
+PR 2 is based on merged PR #1417 at `e05ed7f22`, including #1413's native aggregation
+optimization. This document defines the [language contract](README.md),
 [batch engine design](datafusion-engine.md) and [compiled-plan reuse design](plan-reuse.md).
-T02/T03/T05 commands are implemented. Commands for later capabilities describe
-planned contracts until those capabilities ship. Existing `QLB-T01` labels and
-`qlb_t01` measurements retain their historical meaning.
+T02/T03/T05/T07/T08 commands are implemented. Commands for later capabilities
+remain planned contracts. Existing `QLB-T01` labels and measurements retain their
+historical meaning.
 
 ## Four-PR delivery
 
 | PR | Scope | State in this branch |
 |---|---|---|
-| [1 — execution foundation](https://github.com/apache/skywalking-banyandb/pull/1417) | T02 shared batches, T03 typed raw QL, T05 compiled-plan reuse and required compatibility repairs | Implemented and independently verified |
-| 2 — SQL aggregation | T07 local aggregation and T08 native split/merge | Planned |
+| [1 — execution foundation](https://github.com/apache/skywalking-banyandb/pull/1417) | T02 shared batches, T03 typed raw QL, T05 compiled-plan reuse and required compatibility repairs | Merged as PR #1417 |
+| 2 — SQL aggregation | T07 local aggregation and T08 native split/merge | Prepared on merged PR #1417; see the PR 2 record |
 | 3 — expressions and filtering | T10 expressions, T12 field WHERE and T14 HAVING/membership | Planned |
 | 4 — complete grouping/output composition | T15 DISTINCT, T16 computed/ordinal grouping, T17 ordering/pagination and count-distinct composition | Planned |
 
@@ -55,13 +54,12 @@ formats; no Rust runtime or Arrow IPC dependency. SQL operators stay on the liai
 Shared native Go buffering/ownership and read/dispatch compatibility repairs are
 included with their regression tests, as described in the PR 1 verification record.
 
-**Readiness:** PR #1412 is merged and the shared batch seam, raw-query interfaces
-and compiled-template cache are available in PR 1. Its selected measure/QL cases
-pass 451/451 in both topologies. Later tickets require their listed prerequisites
-and their own independent expected results, activation proof and benchmarks.
-The historical T02/T03/T05 measurements below retain their original bases and
-verdicts; consult the PR 1 record for the latest isolated checks. Preserve the
-completed T01 support work rather than treating it as a new capability.
+**Readiness:** PR #1412 and PR #1417 are merged. The shared batch seam, typed
+raw-query interfaces and compiled-template cache are available on main; local
+aggregation and native merging are available in this branch. Later tickets still
+require their listed prerequisites, independent expected results, activation
+proof and focused benchmarks. Historical measurements retain their original
+bases and verdicts; the PR 2 record covers this isolated aggregation branch.
 
 ### Dependency and benchmark map
 
@@ -431,9 +429,25 @@ groups, and fixed-width aggregate state grows with groups rather than rows.
 Report key/state capacity bytes separately from native residency; G1/G2 apply.
 
 **Checks/readiness:** E(T07), P(T07), K(T07); focused `./pkg/query/ql`,
-`./pkg/bydbql`, `./banyand/liaison/grpc`. Blocked by T03. Bind/query tests expose
+`./pkg/bydbql`, `./banyand/liaison/grpc`. Builds on T03. Bind/query tests expose
 new multi-output semantics; null-collapsing legacy helpers are not the oracle.
 
+**Implementation status:** local aggregation and generated correctness coverage
+are implemented. Both resource-limited topologies pass all 215 selected specs,
+including 48 T07 specs. Five focused RPC repetitions pass at 1,024/32,768 rows;
+the local kernel also executes one million groups. The original successful
+100,000/1,000,000-row RPC performance gate remains unmet: inherited native frame
+reservations reject these queries before the QL aggregate starts. These smaller
+measurements do not replace that requirement. The 16-MiB real-RPC resource probe
+also remains blocked during native readiness. See
+[T07 verification](t07-verification.md) for results, resource limits and retained
+evidence.
+
+**Scope decision:** disk spilling is deferred to a later ticket, with its own
+generated E2E coverage and focused benchmarks. T07 retains current memory limits
+and ResourceExhausted behavior. The blocked measurements above remain recorded
+with their original outcomes.
+
 ### T08 — Execute native multi-aggregate queries with keyed merge
 
 **Performance question:** when does native aggregate fan-out/merge reduce cost
@@ -453,15 +467,18 @@ and reuse matching visible/auxiliary calls. Unsafe count precision, nullable key
 or lost aggregate information select T07. AVG's state/finalization is part of the
 aggregate operation, not an independent division helper ticket.
 
-**E2E cases:** `sum-max-repeated-call` selects SUM(v) twice and MAX(v) by host,
-with an exact host IN ('a','b','c') predicate, producing (a,2,2), (b,4,4), (c,6,6)
-for sum/max. Expect three leaves: SUM(v), MAX(v) and auxiliary exact COUNT(v),
-with no fourth leaf for the repeated SUM. In a separate case, add an all-null host
-and include it in the IN predicate to prove count-based normalization to null.
-Native eligibility must be established from actual provider contracts.
-`bucket-and-multiple-keys` checks distinct buckets/keys with deliberately permuted
-branch arrival. `missing-null-empty-groups` validates union/normalization or the
-documented local fallback. `branch-failure-cap-and-denial` covers >100 groups,
+**E2E cases:** shared float SUM/COUNT/AVG/MEAN outputs, including repeated AVG,
+use two branches over exact non-null entity selections. Integer MIN/MAX/COUNT
+use three branches. All-null and missing groups prove count-based normalization
+to null, and two entity keys prove full-tuple merging. The original
+`sum-max-repeated-call` three-leaf example needs refinement: native integer SUM
+wraps and native float MAX has different nonfinite semantics. Such combinations
+remain valid SQL through T07; no small fixture proves native equivalence.
+`bucket-and-multiple-keys` additionally names bucket timestamps and deliberately
+permuted branch arrival. T08 tests the merge's timestamp key at the operator seam;
+extended bucket grammar and its real-RPC coverage remain T16 work and are not
+claimed as passed by T08. `missing-null-empty-groups` validates union/normalization
+or the documented local fallback. `branch-failure-cap-and-denial` covers >100 groups,
 finite complete-scan-cap exhaustion via a test seam, failed child gates and
 cancellation before any unauthorized leaf dispatch.
 
@@ -470,20 +487,37 @@ partition goldens with identical SQL semantics. `sum-count-average-reuse` proves
 native branch reuse, including auxiliary counts for null normalization.
 
 **Benchmark:** P(T08) compares native split/merge, forced-local same-semantics
-reference and equivalent individual native queries on a fixed dataset. Include
-AVG alone and SUM+COUNT+AVG for integer/float targets, reporting raw fallback cost.
+reference and equivalent individual native queries on a fixed dataset. The
+focused protocol measures float AVG, shared float SUM+COUNT+AVG, integer
+MIN/MAX/COUNT and an integer AVG fallback control. Integer SUM overflow is
+covered by E2E tests; no earlier ticket benchmark dimensions are added.
 G3: eligible AVG needs at most two distinct leaves, exposing its matching SUM/COUNT
 adds no leaves, and unsafe AVG makes one local raw scan. For other combinations,
-leaf count equals unique eligible visible/auxiliary calls (three above).
+leaf count equals unique eligible visible/auxiliary calls (three for integer extrema above).
 Intermediate DataPoints are zero; merge work <= sum of input group rows plus one
 output visit per group; no branch receives a fresh full budget. K(T08) covers
 different branch order and high key cardinality;
 G1/G2 apply. Record split-versus-raw latency/cost without assuming pushdown wins.
 
 **Checks/readiness:** E(T08), P(T08), K(T08); focused `./pkg/query/ql`,
-`./pkg/bydbql`, `./banyand/liaison/grpc`. Blocked by T07. Fixed-data oracles must
+`./pkg/bydbql`, `./banyand/liaison/grpc`. Builds on T07. Fixed-data oracles must
 not imply a common snapshot across separate native queries during concurrent writes.
 
+**Implementation:** native lowering, full-tuple batch merging, SQL AVG/null
+finalization, safe whole-query fallback and shared branch ownership are implemented.
+The exact eligible subset and provider proofs are recorded in
+[T08 verification](t08-verification.md). Both real topologies pass 246 selected
+specs, including 31 generated T08 specs with independent expected values. The
+focused K/P protocol compares eligible paths and the integer AVG fallback;
+it does not broaden earlier ticket benchmark matrices or change their outcomes.
+Five resource-limited repetitions at both input sizes pass correctness and work
+checks. At 32,768 rows, float pushdown is about 2.4x faster with about 79% fewer
+allocated bytes; at 1,024 rows, three-branch integer extrema is about 1.5x slower.
+The verification record retains this regression and the remaining bucket E2E
+boundary rather than treating safe native eligibility as a universal speedup.
+Native branches remain sequential, at most 32 distinct calls; query memory
+limits and monotonic admission charges remain enforced without spilling.
+
 ### T10 — Evaluate typed vector expressions and masked conditionals
 
 **Performance question:** does compiling scalar-or-column kernels avoid per-row
diff --git a/docs/design/0.12.0/measure-ql-core-sql/plan-reuse.md b/docs/design/0.12.0/measure-ql-core-sql/plan-reuse.md
index 9092ec47f..b517c9881 100644
--- a/docs/design/0.12.0/measure-ql-core-sql/plan-reuse.md
+++ b/docs/design/0.12.0/measure-ql-core-sql/plan-reuse.md
@@ -2,9 +2,11 @@
 
 Status: **implemented by T05**, with verification recorded in
 [T05 verification](t05-verification.md) as historical ticket evidence and the
-[PR 1 verification record](pr1-verification.md) for the current isolated branch.
-PR 1 reuses legacy and typed raw measure templates. Later aggregate/expression/
-sort recipes discussed below are extensions for subsequent Catalog B PRs.
+[PR 1 verification record](pr1-verification.md) for the merged cache foundation.
+PR 2 extends reusable metadata to aggregate calls, grouping columns and native
+entity identities; see its [verification record](pr2-verification.md). Native
+eligibility, requests and lifecycle selection remain fresh for each execution.
+Expression and sort recipes remain extensions for subsequent Catalog B PRs.
 This extends the [engine design](datafusion-engine.md) on PR #1412. It changes no
 protobuf, RPC, native request or frame format.
 
@@ -25,8 +27,8 @@ The [earlier plan-cache design](../bydbql-plan-cache/README.md) deferred compile
 plans after measuring 3–6 microseconds for Bind + transformation with local schema
 reads. Preserve that historical result. This decision revisits the deferral for
 Catalog B: typed raw source/output resolution and time-range planning add work,
-while OAP repeatedly varies bound values. Later tickets add expression planning
-and aggregate decomposition.
+while OAP repeatedly varies bound values. PR 2 adds aggregate metadata; later
+tickets add general expression planning.
 Benchmark both simple legacy templates and new plans; do not assume the earlier
 measurement proves either a large benefit or no benefit for this engine.
 
@@ -37,19 +39,20 @@ snapshot is introduced, and end-to-end speedup must be measured separately.
 
 ## 2. Reusable template versus execution instance
 
-| Cached immutable template in PR 1 | Fresh for every execution |
+| Cached immutable template through PR 2 | Fresh for every execution |
 |---|---|
 | Parsed syntax and placeholder constraints | Parameter values, native list values and validation |
 | Resolved raw slots, source identities, types and output names | Bound time ranges and one captured now |
 | Legacy native lowering metadata and typed raw projection/time recipes | Newly lowered native requests and optional RawPlan |
+| Aggregate calls, grouping columns and native entity identities | Native eligibility proofs, branch requests and local/merge state |
 | Schema dependency revisions and local watch generations | Dependency checks, current authorization and native gates |
 | Output mapping and source schema metadata | Operator instances, query budgets, borrowed owners and result encoding |
 
 Do not cache a running Pipeline, PullOperator, mutable bound request, result batch,
 cursor, memory lease or permission outcome. A template is a recipe; every request
-owns its bound values and execution state. Aggregate decomposition, general typed
-expression kernels, hash aggregation and sorting are future template extensions,
-not implemented cache payloads in PR 1.
+owns its bound values and execution state. Local hash aggregation and native
+aggregate merging instantiate fresh state from the reusable aggregate metadata.
+General expression kernels and sorting remain future template extensions.
 
 The implemented request flow is:
 
@@ -59,8 +62,9 @@ exact QL text -> prepared syntax lookup -> validate parameter overlay
     -> hit: immutable MeasureTemplate
        miss: capture schemas -> compile -> revalidate -> publish if reusable
     -> MeasureTemplate.Bind with fresh parameters and one execution clock
-    -> native QueryRequest + optional raw QL recipe
-    -> current authorization -> admission/gates -> batches -> release handle
+    -> native QueryRequest + optional raw/aggregate recipe and native alternative
+    -> current authorization -> admission/revision/lifecycle gates
+    -> native batches -> optional local aggregation or keyed merge -> release handle
 ```
 
 Parameters do not enter the compiled key. Time ranges, native criteria/list values,
@@ -69,9 +73,10 @@ LIMIT 0 can choose an empty recipe; contradictory typed time bounds also avoid a
 scan. Native eligibility and schema gates use current metadata rather than cached
 topology or authorization decisions.
 
-Later Catalog B features must preserve this division when adding parameterized
-expression kernels, aggregate eligibility guards or full-sort/top-k choices.
-Those future choices do not describe operators present in PR 1.
+PR 2 rechecks native aggregate eligibility after binding and current lifecycle
+selection before each branch. Integer overflow, nullable keys, count bounds and
+provider restrictions select the complete local fallback when proof is missing.
+Later expression and full-sort/top-k features must preserve this division.
 
 ## 3. Cache key and parameter types
 
diff --git a/docs/design/0.12.0/measure-ql-core-sql/pr2-verification.md b/docs/design/0.12.0/measure-ql-core-sql/pr2-verification.md
new file mode 100644
index 000000000..f94c8f345
--- /dev/null
+++ b/docs/design/0.12.0/measure-ql-core-sql/pr2-verification.md
@@ -0,0 +1,236 @@
+# Catalog B PR 2: SQL aggregation and native split/merge
+
+This branch is based on merged PR #1417 at `e05ed7f22`. It extracts T07 and T08
+from the combined Catalog B implementation. Expressions, field WHERE, HAVING,
+SELECT DISTINCT, computed grouping, selectable time buckets and general sorting
+remain in PRs 3 and 4. Legacy scalar COUNT(DISTINCT) keeps the merged PR 1 behavior;
+extended exact count-distinct composition is not part of this PR.
+
+## Execution and compatibility
+
+```text
+parameterized QL -> cached schema-resolved metadata -> fresh binding
+  -> authorization/admission/revision/lifecycle gates
+  -> eligible: sequential existing native aggregates -> keyed batch merge
+     otherwise: complete deduplicated native scan -> local hash aggregation
+  -> output aliases -> final LIMIT/OFFSET -> existing measure_result
+```
+
+Extended queries support named scalar grouping and multiple COUNT/SUM/MIN/MAX/
+AVG/MEAN calls. Null keys differ from empty strings and zero. Counts are exact
+int64; integer SUM overflow fails. AVG uses sum and non-null count rather than
+the legacy native MEAN behavior. Repeated calls share work. Scalar empty input
+returns zero COUNT and null other aggregates; grouped empty input returns no rows.
+
+Native eligibility is conservative: one measure group, a provable stage, one
+exact time interval and complete non-null entity identity. Unsafe integer sums,
+floating extrema, non-entity keys, residual time predicates and uncertain stage
+selection retain the complete local path. Native branches run sequentially and
+share the parent allowance; each source closes before the next opens. Partial
+failures do not return partial SQL groups. Local key/state growth reserves memory
+before allocation, and cleanup releases owners and reservations.
+
+No protobuf definitions, RPCs, native operator implementations, persisted formats
+or native frame versions change. Memory protection remains active. Disk spilling
+and retained-result pagination remain deferred.
+
+The generated QL-only contracts derive expected values from independent fixture
+arithmetic. Existing raw-query cases remain separate compatibility controls.
+T07 covers local grouping, null/empty semantics, shared calls and pagination;
+T08 compares cached, bypassed and forced-local execution, including branch reuse,
+lifecycle changes, cancellation, authorization/revision failures and recovery.
+
+The extraction retains the main-branch compatibility fixes and the CI regression
+fixes from PR 1. Shared benchmark fixture dispatch now lives in the test entrypoint,
+so ordinary Go package compilation and vulnerability analysis do not depend on
+helpers present only in `_test.go` files.
+
+## Isolated correctness checks (2026-10-21)
+
+- Planner, QL operators, liaison, fixture generator and measure-data unit suites pass.
+- The planner/operator/liaison/generator suites also pass with `-race -tags=querymetrics`.
+- The unchanged deterministic generation check passes, preserving PR 1's scalar
+  count pagination and empty-entity regression cases alongside the new scenarios.
+- Both independent measure/QL selections pass **530/530** cases in four-CPU,
+  8-GiB Docker containers: standalone in 73.315 s and distributed in 92.942 s.
+  These include 79 new T07/T08 specs and the 451 inherited compatibility specs.
+- Full `make lint` passes. All 78 relative documentation links/anchors resolve.
+
+Reports are `/mnt/d/qlb-pr2-standalone-report.json` and
+`/mnt/d/qlb-pr2-distributed-report.json`. Unit, race, generator and lint logs use
+the `/mnt/d/qlb-pr2-` prefix. Other integration blocks were not selected.
+
+## Independent toolchain prerequisite
+
+The Go 1.26.10 upgrade is isolated in [PR #1425](https://github.com/apache/skywalking-banyandb/pull/1425),
+based directly on main. This aggregation branch changes neither `go.mod` nor
+dependency versions. Merge the toolchain PR and rebase this branch to pick up the
+security fix; it is no longer part of the aggregation diff.
+
+On 2026-10-21, `make vuln-check` with inherited Go 1.26.9 reports reachable
+[GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809) in `crypto/x509`. The
+pre-aggregation PR 1 tree reports the same failure. Logs are
+`/mnt/d/qlb-pr2-vuln.log` and `/mnt/d/qlb-pr2-main-vuln-control.log`.
+This inherited-toolchain scan is not counted as passed.
+
+Before extraction, the same aggregation implementation was separately verified
+with Go 1.26.10: unit/race suites, 530 selected measure/QL cases per topology,
+full lint, `make check` and `make vuln-check` passed. Those results remain
+separate evidence under `/mnt/d/qlb-pr2-go12610-`; they do not imply that the
+current Go 1.26.9 default passes the vulnerability scan. The standalone toolchain
+PR also passes its own vulnerability scan, focused version/TLS/gRPC/pipeline
+tests and `make check` on its main-based tree.
+
+Aggregation benchmarks retain the frozen Go 1.26.9 protocol and are not
+relabeled as patched-toolchain results. After extraction, all 2,405 source and
+contract files match the measured source manifest again.
+
+## Evidence boundaries
+
+[T07 verification](t07-verification.md) and [T08 verification](t08-verification.md)
+preserve historical measurements at their original revisions. The current branch
+includes #1413's native aggregation optimization and PR 1's ownership/accounting
+repairs; historical figures are not treated as measurements of this isolated head.
+
+Fresh measurements use only the existing T07/T08 workloads, in four-CPU, 8-GiB
+Docker containers with no extra swap and Go 1.26.9. T07 compares dense local
+aggregation with its independent scalar reference. T08 compares the same SQL
+through native and forced-local execution, with existing individual-native RPC
+bundles as additional controls. Kernel and RPC observations remain separate;
+neither eligibility nor a specialized scalar comparison guarantees a speedup.
+
+
+## Fresh kernel measurements (2026-10-21)
+
+All 40 existing variants completed five one-second samples each (200 observations),
+with independent value/null checks and no cgroup OOM or memory-max events. The
+following values are medians of five samples; the complete matrix is retained in
+`/mnt/d/qlb-pr2-bench/kernel-summary.json`.
+
+T07 runs 65,537 input rows in the first three rows below; the million-group rows
+use one million inputs. The scalar reference specializes Go map keys and omits
+the public operator lifecycle, typed key codec and tracked allocation checks.
+
+| Groups | Shape | Dense ms | Scalar ms | Dense B/op | Scalar B/op |
+|---:|---|---:|---:|---:|---:|
+| 1 | sum-count-avg | 6.925 | 1.128 | 2,872 | 384 |
+| 1,024 | sum-count-avg | 7.945 | 2.243 | 138,296 | 321,784 |
+| 65,537 | sum-count-avg | 42.125 | 31.060 | 11,574,845 | 20,981,057 |
+| 1,000,000 | avg | 545.762 | 629.366 | 99,909,512 | 300,868,672 |
+| 1,000,000 | sum-count-avg | 618.048 | 669.411 | 99,926,680 | 333,016,168 |
+
+The general operator is slower at low group counts; high-cardinality allocation
+volume is lower. Both million-group shapes use two state lanes: 16,000,000 used
+state bytes, 16,777,216 state-capacity bytes and 41,165,888 key-capacity bytes.
+They fit the 128-MiB operator tracker, excluding caller-owned fixtures and the
+untimed scalar oracle. This is not a million-row RPC acceptance result.
+
+T08 compares the complete native-group merge with its specialized scalar map.
+The table shows aligned branch rows; the permuted variants also pass and have
+similar measured cost. Both orders visit 2N input groups and 3N merge entries.
+
+| Finalized groups | Batch merge | Scalar reference | Batch B/op |
+|---:|---:|---:|---:|
+| 1 | 6.147 µs | 0.130 µs | 4,096 |
+| 1,024 | 725.915 µs | 248.269 µs | 160,472 |
+| 65,537 | 59.607 ms | 27.840 ms | 12,632,837 |
+
+This kernel comparison establishes no CPU speedup. Native pushdown can reduce
+RPC raw-row transport despite the more expensive general merge; that question
+is measured separately with the same SQL in native and forced-local modes.
+
+
+## Fresh local-aggregation RPC measurements
+
+T07 completed five repetitions at each cardinality: 40 variant measurements and
+2,400 timed queries. Each cardinality runs in a fresh process, with one RPC
+worker, 100 timed/5 warmup queries at 1,024 rows and 20 timed/3 warmup queries at
+32,768 rows. Setup, visibility and full scalar checks are untimed. All values,
+structural counters and post-close ownership/reservation checks pass.
+
+The table reports medians of the five repetition summaries. Allocated MB uses
+decimal bytes. CPU and allocated bytes cover the whole benchmark process,
+including client, liaison, data nodes and background work. Twenty large-query
+samples per repetition do not establish a precise tail-latency guarantee.
+
+| Input rows | Shape | p50/p95 ms | Allocated MB/query | Process CPU ms/query |
+|---:|---|---:|---:|---:|
+| 1,024 | AVG, one group | 3.967 / 5.966 | 1.253 | 10.0 |
+| 1,024 | COUNT/SUM/MIN/MAX, 1,024 groups | 10.980 / 16.616 | 4.399 | 22.4 |
+| 1,024 | SUM/COUNT/AVG, N groups | 4.204 / 6.140 | 1.418 | 10.0 |
+| 1,024 | Scalar SUM | 3.664 / 5.545 | 1.148 | 9.3 |
+| 32,768 | AVG, one group | 40.647 / 44.270 | 30.395 | 79.5 |
+| 32,768 | COUNT/SUM/MIN/MAX, 1,024 groups | 51.895 / 59.484 | 38.333 | 106.5 |
+| 32,768 | SUM/COUNT/AVG, N groups | 46.745 / 53.571 | 34.350 | 86.0 |
+| 32,768 | Scalar SUM | 37.153 / 39.518 | 27.066 | 75.0 |
+
+This characterizes newly supported SQL without claiming a latency improvement
+over main, which cannot execute those extended aggregate shapes. The full
+repetition summary is `/mnt/d/qlb-pr2-bench/t07-rpc-summary.json`.
+
+
+## Fresh native-versus-local RPC comparison
+
+T08 completed five repetitions at each cardinality: 110 variant measurements and
+6,600 timed logical iterations. It uses the same schedules as T07 above. The
+individual-native controls time complete two/three-request bundles; their latency
+and CPU are not costs of a single native request.
+
+| Raw rows | Shape | Pushdown p50 ms | Local p50 ms | p50 ratio | Allocation ratio | CPU ratio |
+|---:|---|---:|---:|---:|---:|---:|
+| 1,024 | Float AVG | 4.803 | 4.107 | 1.169 | 1.006 | 1.327 |
+| 1,024 | Shared float SUM/COUNT/AVG | 5.011 | 3.999 | 1.253 | 0.969 | 1.474 |
+| 1,024 | Integer MIN/MAX/COUNT | 7.284 | 4.255 | 1.712 | 1.453 | 2.061 |
+| 1,024 | Integer AVG fallback control | 4.333 | 4.122 | 1.051 | 1.013 | 1.031 |
+| 32,768 | Float AVG | 23.927 | 43.968 | 0.544 | 0.327 | 0.822 |
+| 32,768 | Shared float SUM/COUNT/AVG | 24.643 | 45.082 | 0.547 | 0.326 | 0.764 |
+| 32,768 | Integer MIN/MAX/COUNT | 38.779 | 43.472 | 0.892 | 0.509 | 1.229 |
+| 32,768 | Integer AVG fallback control | 43.307 | 41.443 | 1.045 | 1.013 | 1.060 |
+
+Ratios divide the five-run pushdown median by the corresponding local median.
+At 32,768 rows, float AVG/shared calls reduce observed p50 by about 45% and
+allocated bytes by about 67%. Integer extrema improve p50 by about 11% and
+allocation by about 49%, but increase process CPU by about 23%. All three native
+shapes are slower at 1,024 rows; the integer case also allocates more and uses
+more CPU. Eligibility is not a cost model or a universal speedup guarantee.
+
+For the large float AVG/shared/integer-extrema cases, the five paired p50 ratios
+range from 0.442–0.594, 0.462–0.673 and 0.833–0.946 respectively. The fallback
+control executes the same local path in both modes and retains run variation;
+its differences are not an optimization effect. These are observations with
+small large-query sample counts, not a newly inferred statistical gate pass.
+
+## Combined benchmark provenance and reproduction
+
+The two RPC protocols completed 9,000 timed logical iterations across 150 variant
+measurements and 20 fresh cardinality processes, with 300 nonempty CPU/heap
+profiles. This comprises 7,200 QL calls and 4,200 native Query calls (11,400 actual
+timed transport calls), because individual-native controls issue bundles. All
+independent result, structural-counter and post-close cleanup checks pass.
+
+Nine captured RPC-container memory-event records and the kernel-container record
+have zero OOM/max events. T07 repetition 1's event record was not captured and is
+unavailable, rather than counted as zero. Two untimed report merges were recovered
+from retained shards after correcting the external wrapper; measured queries
+were not rerun. The retained immutable wrapper uses `env -u DQB_MODE` for merge
+mode and captures later memory-event records on exit.
+
+Timing froze source at `199d95522`. The source check confirms 2,405 tracked and
+generated source/contract files match the manifests, including 1,141 production
+files; both measured executable hashes match. Documentation updates after timing
+do not change those sources. All artifacts are under `/mnt/d/qlb-pr2-bench/`,
+including `rpc-summary.json`, `t08-comparison-summary.json`, `kernel-summary.json`,
+latency samples, profiles, `binary-sha256.txt`, source manifests and exact runners.
+
+Reproduce the kernels with `BenchmarkAggregate(Kernel|MillionGroups)` and
+`BenchmarkNativeAggregateMerge` in `pkg/query/ql`, with `-tags=querymetrics`,
+`-benchmem -benchtime=1s -count=5 -run '^$'`. The querybench `qlb_t07` and `qlb_t08`
+scenarios retain the protocols in their ticket records: cardinalities 1,024 and
+32,768, one worker, the timed/warmup schedules above, and profiling enabled.
+The existing Docker runner and its README describe the required environment.
+This refresh adds no earlier-ticket matrix or new workload.
+
+Process-wide peak reservations are not isolated native residency or a per-query
+lease size. Physical storage reads, native retained-frame bytes and total parent
+lease charges remain unavailable. No large-RPC success or historical T02
+acceptance is inferred from the operator-only million-group measurements.
diff --git a/docs/design/0.12.0/measure-ql-core-sql/t07-verification.md b/docs/design/0.12.0/measure-ql-core-sql/t07-verification.md
new file mode 100644
index 000000000..f4ce97dae
--- /dev/null
+++ b/docs/design/0.12.0/measure-ql-core-sql/t07-verification.md
@@ -0,0 +1,312 @@
+# T07 complete SQL aggregation over native batches
+
+Historical ticket-stage record. Source revisions, measurements and limitations
+below retain their original context. See the [PR 2 verification record](pr2-verification.md)
+for the current isolated aggregation branch and refreshed measurements.
+
+Implementation base: `e193815a` (T05 plus measure QL documentation), on PR #1412.
+Verification uses Go 1.26.9. No protobuf, frame format, native operator, or stored
+format changes. Final production source and generated-contract content hashes are
+recorded in `.omx/t07-source-manifest.sha256`.
+
+## Execution contract
+
+Extended measure queries lower to one complete native raw scan, optional exact
+time residual, local HashAggregate, final projection and pagination, then the
+existing response serializer. There is one logical scan with one storage leaf
+per data node. Native version/replica deduplication precedes local aggregation.
+The native request carries no aggregation/grouping, zero offset and an unbounded
+limit. The native source closes before synthesized groups are serialized.
+
+Source shorthand, aliases, WHERE time, or multiple aggregate calls select this
+execution path. Existing single-aggregate native syntax remains unchanged,
+including native MEAN/AVG and COUNT(DISTINCT) routing/stage restrictions.
+Extended COUNT(DISTINCT), TOP, TIME_BUCKET and ORDER BY are rejected explicitly.
+Scalar tag/field grouping is supported, including hidden keys; expressions,
+array grouping and group-only queries remain later tickets.
+
+COUNT returns exact int64 and ignores nulls. Numeric SUM/MIN/MAX preserve int64
+or float64; integer SUM overflow fails. AVG/MEAN use a float64 sum and exact
+non-null count. Matching calls share lanes. Integer AVG keeps a floating sum
+separate from an explicitly selected exact integer SUM, so AVG alone does not
+inherit int64-sum overflow. Float SUM/COUNT/AVG together use the same two lanes
+as AVG alone. Scalar empty input returns COUNT zero and other calls null;
+grouped empty input returns no groups. Pagination applies to finalized groups.
+LIMIT 0 skips storage; contradictory time bounds still synthesize the scalar
+empty-input row when pagination allows it.
+
+Grouping encodes type, explicit nullness, and variable-width lengths. Null,
+empty string and zero differ; signed zeros share a key, and all NaNs share one
+canonical key. Open-addressed uint32 group-ID slots retain full encoded keys to
+resolve collisions. Dense state arrays scale with actual groups and geometric
+capacity rather than input rows. The index and key headers reserve 24 bytes per
+capacity slot; key payload reservations conservatively cover allocation classes.
+Key bytes/capacity and used/allocated state bytes are reported separately.
+
+Every growing array reserves the shared query allowance before allocation,
+including overlap during growth. Native cells are borrowed only while consuming
+a batch; retained keys own their bytes. Cancellation and construction, source,
+growth, output or encoding errors close owners and return live reservations.
+The implementation adds no new memory pool.
+
+## Correctness evidence
+
+The initial planner RED is `.omx/t07-planner-red.log`: an aliased multi-aggregate
+query was rejected by typed raw execution. Operator tests independently check
+fixture F, native passthrough and constant columns, null keys, signed zero/NaN,
+selection, poisoned owners, exact COUNT above 2^53, checked sums, cancellation,
+allocation failures and batches crossing 65,536 rows.
+
+The generator preserves all 293 existing cases and 18 T05 reuse scenarios.
+T07 adds 41 aggregate scenarios with three changed bindings each, executed both
+cached and bypassed, plus seven invalid shapes. Expected values come from an
+independent scalar fixture model. No native Query RPC supplies an oracle.
+Readiness verifies every final version through bounded raw QL windows. The
+513-group fixture has 1,026 logical rows, two entity hosts, distinct non-entity
+group keys and stale/final writes; this isolates SQL groups from per-series
+native allocation costs. Its unordered output is validated with unique keys
+and sorted on both test sides before the existing typed/null/tolerance comparator.
+This comparison does not assert an ORDER BY contract.
+
+## Focused benchmark protocol
+
+P(T07) has four distributed RPC variants only: scalar SUM, four aggregates over
+1,024 nullable groups, one-group AVG, and SUM/COUNT/AVG over N integer groups.
+Final LIMIT 1 bounds N-group egress; instrumentation must still prove N input
+rows and N groups. Independent scalar checks validate values and nulls before
+timing and on retained samples. Setup and visibility are outside timing.
+
+The original successful-RPC protocol targeted 1,024, 100,000 and 1,000,000
+input rows at 100/5, 20/3 and 10/2 timed/warmup calls. The first run passed
+1,024 rows, then failed before local aggregation at 100,000 rows. The native
+limit and the revised focused measurement protocol are recorded below. This
+ticket-specific schedule replaces the shared old 1,000-call boilerplate. Ten
+samples cannot establish a stable tail distribution;
+all samples and profiles are retained. There is no old-version latency baseline
+for previously unsupported SQL, and old ticket matrices are not repeated.
+
+K(T07) compares the complete dense operator with an independent scalar map
+reference over 65,537 rows, 1/1,024/N groups, nullable input and four aggregate
+shapes, including AVG versus shared SUM/COUNT/AVG. It validates results before
+timing. Resource probes use 16/256-MiB tracker settings over 100,000 and 1,000,000 groups and
+permit only complete correct results or ResourceExhausted, followed by clean
+recovery and no owner/reservation leaks. Protector admission remains active and
+can be tighter than the configured vectorized tracker.
+
+## Final test runs
+
+Both final resource-limited topology runs passed all 215 selected specs,
+including all 48 T07 specs and the 167 earlier ticket specs. Reports:
+`.omx/t07-standalone-docker.json` and `.omx/t07-distributed-docker.json`.
+The old 293 generated cases, 18 reuse scenarios, reuse readiness and pair report
+also compare unchanged to the implementation base as decoded JSON values.
+
+Host standalone verification passed too. Host distributed verification passed
+214/215, with only the complete 513-group response rejected by admission. This
+host reports an invalid/unbounded cgroup limit, so its fallback query allowance
+is only 8 MiB (64-MiB pool divided by eight). The same full test succeeds in the
+8-GiB container with normal cgroup-based admission. No production allowance was
+changed, and the required input/group cardinalities were retained. Earlier
+readiness failures led to bounded raw visibility windows; a slow quadratic
+unordered protobuf comparison led to unique-key sorting in the test adapter.
+
+Focused packages, generator, querybench and the full gRPC suite passed race
+checks with `querymetrics`. `make lint` passed after fixing new lint findings.
+The final readiness-helper functional run passed in 6.018 seconds, its focused
+querybench race run passed in 146.562 seconds, and tagged lint reported zero issues.
+Relevant logs are `.omx/t07-aggregate-race.log`, `.omx/t07-planner-race.log`,
+`.omx/t07-grpc-race.log`, `.omx/t07-generator-race.log` and
+`.omx/t07-make-lint-complete.log`. Deterministic generator checks and whitespace checks
+passed. This ticket does not alter the nine known pre-existing generated failures.
+
+A final topology command, run in the Go benchmark image with the workspace and
+module/build caches mounted, is:
+
+```sh
+go test -tags=querymetrics ./test/integration/distributed/query \
+  -count=1 -timeout=30m -run '^TestPropertyQuery$' \
+  -ginkgo.label-filter='measure-ql && (QLB-T01 || QLB-T02 || QLB-T03 || QLB-T05 || QLB-T07)' \
+  -ginkgo.fail-on-empty
+```
+
+Use `./test/integration/standalone/query` and
+`^TestPropertyIntegrationQuery$` for the other topology. Containers use
+`--cpus 4 --memory 8g --memory-swap 8g --pids-limit 4096`.
+
+## Kernel measurements
+
+The limited-container kernel run uses the command below. All 28 variants have
+five nonempty samples and pass independent value/null checks. The table reports
+medians over five samples; the first 12 shapes use 65,537 rows and the last two
+use 1,000,000 rows.
+
+```sh
+go test -tags=querymetrics ./pkg/query/ql -run '^$' \
+  -bench '^BenchmarkAggregate(Kernel|MillionGroups)$' \
+  -benchmem -benchtime=1s -count=5 -timeout=20m
+```
+
+| Groups | Shape | Dense ms | Scalar ms | Dense allocated B/op | Scalar allocated B/op |
+| ---: | --- | ---: | ---: | ---: | ---: |
+| 1 | sum | 6.807 | 1.082 | 2,312 | 352 |
+| 1 | multiple | 6.889 | 1.098 | 3,248 | 400 |
+| 1 | avg | 6.868 | 1.082 | 2,312 | 352 |
+| 1 | sum-count-avg | 6.825 | 1.085 | 2,872 | 384 |
+| 1,024 | sum | 7.731 | 2.086 | 121,368 | 289,016 |
+| 1,024 | multiple | 8.037 | 2.204 | 179,592 | 338,168 |
+| 1,024 | avg | 7.764 | 2.093 | 121,368 | 289,016 |
+| 1,024 | sum-count-avg | 7.855 | 2.105 | 138,296 | 321,784 |
+| 65,537 | sum | 27.658 | 27.258 | 11,557,683 | 18,883,872 |
+| 65,537 | multiple | 38.652 | 27.753 | 15,777,938 | 22,029,649 |
+| 65,537 | avg | 28.773 | 28.256 | 11,557,675 | 18,883,887 |
+| 65,537 | sum-count-avg | 32.831 | 28.015 | 11,574,840 | 20,981,065 |
+| 1,000,000 | avg | 538.491 | 582.169 | 99,909,512 | 301,065,352 |
+| 1,000,000 | sum-count-avg | 583.593 | 636.658 | 99,926,680 | 333,065,352 |
+
+The scalar reference is a specialized Go map over int keys. The dense path runs
+the public operator lifecycle, general typed key codec, memory admission and
+columnar output. It is slower at low group counts; these results do not claim a
+CPU speedup. At high cardinality its allocation volume is lower. AVG and shared
+SUM/COUNT/AVG both use 16 state bytes per group; four aggregates use 32 bytes.
+At 65,537 groups, geometric capacity is 131,072, so two-lane allocated state is
+2,097,152 bytes versus 1,048,592 used bytes. Key capacity is separately
+4,194,384 bytes. This distinguishes retained state from total allocated bytes.
+The final harness collects validation-only garbage before starting the dense
+timer, excluding reclamation of independent oracle maps. The initial 24-variant
+run is retained as `.omx/t07-kernel-docker.log`; final artifacts are
+`.omx/t07-kernel-final.log` and `.omx/t07-kernel-summary-final.json`.
+
+At one million groups both AVG shapes use 16,000,000 state bytes and
+16,777,216 capacity bytes, with 41,165,888 bytes of key capacity. Their local
+operator-owned state fits a 128-MiB tracker, excluding caller-owned fixture inputs
+and the untimed scalar oracle. All million expected keys, values and nulls are
+checked exactly once before timing; post-Close reservations are zero.
+
+## Successful distributed RPC measurements
+
+All five repetitions passed their eight variants in fresh 4-CPU, 8-GiB
+containers. Each cardinality starts a fresh cluster process. The 1,024-row
+variants use five warmups and 100 timed requests; the 32,768-row variants use
+three warmups and 20 timed requests. There is one RPC worker. Warmups and full
+scalar comparisons are untimed. CPU profiling is active during measured requests;
+the heap profile and its collection run afterward.
+
+The table reports the median of each repetition's metric. MB uses decimal
+bytes. Allocation and CPU measurements cover the whole benchmark process,
+including RPC, native execution and background work; they are not local
+aggregate-only costs. These small samples describe the observed distributions
+and do not establish a stable tail-latency guarantee.
+
+| Input rows | Shape | p50 ms | p95 ms | Allocated MB/query | Process CPU ms/query |
+| ---: | --- | ---: | ---: | ---: | ---: |
+| 1,024 | AVG, one group | 3.816 | 5.359 | 1.430 | 9.7 |
+| 1,024 | COUNT/SUM/MIN/MAX, 1,024 groups | 10.761 | 16.976 | 4.769 | 21.5 |
+| 1,024 | SUM/COUNT/AVG, N groups | 4.135 | 6.034 | 1.595 | 9.8 |
+| 1,024 | Scalar SUM | 3.498 | 5.274 | 1.325 | 8.6 |
+| 32,768 | AVG, one group | 46.993 | 52.975 | 43.551 | 92.5 |
+| 32,768 | COUNT/SUM/MIN/MAX, 1,024 groups | 58.940 | 69.388 | 54.913 | 122.0 |
+| 32,768 | SUM/COUNT/AVG, N groups | 52.572 | 59.309 | 48.699 | 101.5 |
+| 32,768 | Scalar SUM | 40.620 | 44.101 | 38.014 | 81.5 |
+
+Every timed interval records exact post-deduplication input/group counts, one
+local aggregate execution per request, two native leaf calls per request (one
+per data node), no intermediate DataPoints, and zero post-Close owner, pool,
+result or reservation deltas. SUM/COUNT/AVG retains the same two state lanes as
+AVG. Plan compilation is zero during timing and every request hits the compiled
+cache. Full samples, CPU/heap profiles and metrics are in
+`.omx/bench-reports/t07-bounded-r1` through `t07-bounded-r5`; the validated
+cross-run summary is `.omx/t07-rpc-summary.json`.
+
+After these measurements, only untimed readiness/recovery helpers changed to
+prune hosts and project one field explicitly. Production execution, measured
+queries, fixtures and timing protocol stayed the same; the P5 binaries and
+profiles are retained. The resource probes below use the rebuilt helper.
+
+The report explicitly marks physical storage rows, native retained frame bytes
+and total parent-lease charges unavailable. Its process-wide peak reservation
+must not be interpreted as native residency alone. Prior-ticket latency matrices
+were not rerun; compatibility evidence here is the earlier 167 E2E specs.
+
+## Inherited native execution bound
+
+The original large-RPC performance gate is **not met**. The native execution
+boundary eagerly retains replica frames and reserves a conservative estimate
+before constructing the QL operator. In an 8-GiB container, the memory protector
+allows 75% (6 GiB), divides its query pool by four and its per-query allowance by
+eight, leaving 192 MiB for a query. This is independent of a larger vectorized
+tracker flag.
+
+A reproducible one-query diagnostic first verified all 100,000 final fixture
+rows through bounded raw QL windows, then executed the exact four-aggregate RPC.
+It failed in approximately 100 ms with zero local aggregate executions, input
+rows or state reservations. The first native frame requested 157,505,864 bytes
+while the lease had already charged 51,609,596 of its 201,326,592-byte allowance.
+The second replica frame would require another similar reservation. The failure
+is at `reserveNativeFrames` in `pkg/query/vectorized/measure/plan/distributed.go`;
+its estimate charges 128 bytes per column cell plus 128 bytes per row, 13
+additional encoded-payload copies and shared workspace. Live reservations return
+to zero after the rejection. The first original run is retained at
+`.omx/bench-reports/t07-r1` and `.omx/t07-rpc-r1.log`; exact diagnostics are
+`.omx/t07-charge-diag.log` and `.omx/t07-charge-diag-2.log`.
+
+Changing the QL aggregate cannot remove this pre-execution reservation. Native
+operators, frame accounting and protector policy are outside this QL-only ticket.
+The successful RPC measurements therefore use 1,024 and 32,768 input rows with
+the same four shapes and five fresh 4-CPU/8-GiB repetitions. Larger real RPCs at
+100,000 and 1,000,000 rows remain explicit resource outcomes, not successful SQL
+throughput results. A separate local million-group kernel run checks actual
+large aggregate execution without replacing the missing successful native RPC
+experiment. The original large-RPC requirement remains visible rather than being
+marked passed by smaller queries or a relaxed memory policy.
+
+## Resource outcomes and remaining gates
+
+Both 256-MiB distributed resource probes verified their complete fixtures before
+issuing five N-group SUM/COUNT/AVG requests. Every request returned
+ResourceExhausted before local aggregation, without a partial response. All live
+owner, pool, result and reservation checks returned to baseline, and a bounded
+raw QL recovery query passed afterward. These are successful rejection/recovery
+checks, not successful large-query performance measurements.
+
+| Input rows/groups | Tracker setting | Aggregate outcomes | Process peak reservations | Recovery |
+| ---: | ---: | --- | --- | --- |
+| 100,000 | 256 MiB | 5 ResourceExhausted; 0 aggregate executions | 558,215,922–577,992,738 B | Passed |
+| 1,000,000 | 256 MiB | 5 ResourceExhausted; 0 aggregate executions | 523,869,908–591,298,391 B | Passed |
+
+The process peak sums three independent node trackers and stays below their
+combined 768-MiB limit. At 100,000 rows, the native data-node raw-frame emitter
+rejects 258,911,011 used bytes plus 26,359,256 requested bytes against a
+268,435,456-byte tracker. At one million rows, native pipeline advancement
+rejects 264,746,587 plus 21,880,784 bytes against the same tracker. These
+data-node failures differ from the earlier liaison protector failure with a
+1-GiB tracker setting; neither reaches local aggregate state allocation.
+
+Artifacts are `.omx/bench-reports/t07-res-final-100000-256` and
+`.omx/bench-reports/t07-res-final-1000000-256`, with logs
+`.omx/t07-res-final-100000-256.log` and
+`.omx/t07-res-final-1000000-256.log`. The validated summary is
+`.omx/t07-resource-summary.json`.
+
+The 16-MiB real-RPC resource gate remains **blocked during native readiness**.
+The final setup prunes one of sixteen hosts, explicitly projects RID plus one
+INT field, and checks 1,024 rows per time window against an independent oracle.
+Even its first window needs 13,829,731 used bytes plus 3,299,800 requested bytes
+against a 16,777,216-byte native tracker. It fails before any owning aggregate
+probe or recovery query, so there is no passing resource verdict or result
+artifact for that configuration. Evidence is
+`.omx/t07-res-fixed2-100000-16.log`; prior readiness attempts are retained too.
+The million-row/16-MiB configuration was not repeated after this setup blocker.
+Functional tests verify host/window coverage, request binding and fail-fast
+handling independently; they do not replace the blocked real-RPC check.
+
+The local operator's separate 262,145-group tests exercise 16-MiB rejection,
+256-MiB success and complete release. The million-group kernel succeeds within
+128 MiB of operator-owned live reservations. Those tests establish local
+allocation behavior without implying that inherited native execution fits the
+same allowance. Thus correctness is delivered, while the original successful
+large-RPC gate and the 16-MiB real-RPC gate remain explicitly outstanding.
+
+The subsequent scope decision defers disk spilling to a later ticket, including
+its generated E2E tests and focused benchmarks. T07 retains existing memory
+protection and ResourceExhausted behavior. The measurements and limitations above
+remain unchanged; no spilling implementation is pending within T07.
diff --git a/docs/design/0.12.0/measure-ql-core-sql/t08-verification.md b/docs/design/0.12.0/measure-ql-core-sql/t08-verification.md
new file mode 100644
index 000000000..1df570ad1
--- /dev/null
+++ b/docs/design/0.12.0/measure-ql-core-sql/t08-verification.md
@@ -0,0 +1,270 @@
+# T08 native aggregate branches and keyed merge
+
+Historical ticket-stage record. Source revisions, measurements and limitations
+below retain their original context. See the [PR 2 verification record](pr2-verification.md)
+for the current isolated aggregation branch and refreshed measurements.
+
+Implementation base: `89952fae` (T07), on PR #1412. Verification uses Go 1.26.9.
+The change is confined to QL planning, the QL execution adapter and verification.
+It changes no protobuf, RPC, persisted format, native operator, frame codec or
+memory-protector policy. Disk spilling remains deferred.
+Frozen benchmark source/contract hashes are in `.omx/t08-source-manifest.sha256`;
+the final working-tree hashes are in `.omx/t08-final-source-manifest.sha256`.
+Only a resource-test helper's context-argument order changed after timing, to
+meet revive's convention. Production, benchmark and generated-contract hashes
+are unchanged between the manifests.
+
+## Execution contract
+
+An extended aggregate query keeps its complete T07 local plan. Binding can also
+construct a native alternative when the existing provider can preserve its SQL
+semantics. The liaison chooses between these alternatives after the parent
+revision gate and creation-time clamp. Selection reads current watched lifecycle
+options on every request, including cache hits, and rechecks before each branch.
+No cached template retains bound values, native sources or mutable operator state.
+
+Eligible float SUM/AVG calls lower to native SUM and non-null COUNT. Matching
+visible SUM/COUNT/AVG/MEAN calls reuse those same two branches. Integer MIN/MAX
+use their native calls and share one auxiliary COUNT. COUNT zero normalizes
+finalized native SUM/extrema sentinels to SQL null. COUNT returns int64 even
+when the native field's COUNT representation is float64. QL never invokes native
+MEAN to implement extended AVG.
+
+Branches run sequentially, with zero offset and an unbounded result limit. Each
+source closes before the next opens. The first source opens before local state
+allocation so the native processor initializes the shared tracker with its real
+configured allowance. Every branch and the merge reuse the parent context,
+deadline, admission lease and tracker; none receives a fresh full allowance.
+Child revision failures fail the query rather than returning incomplete groups.
+
+The merge indexes the complete typed group tuple, retaining owned key bytes and
+dense state arrays. It accepts independently ordered branch rows and unions
+groups missing from a source. Type/null markers and variable-width lengths
+distinguish null, zero and empty values. Presence markers reject duplicate
+finalized keys and incomplete positive-count values. Output starts only after
+every branch finishes successfully. Projection and final LIMIT/OFFSET precede
+the existing response serializer; intermediate DataPoints remain zero.
+
+Retained key/state/table growth reserves memory before allocation, including
+overlap during growth. Cancellation, source/open/close errors, caps, malformed
+schemas and allocation failures close sources and release local reservations.
+The implementation adds no pool. The existing lease is monotonic; released live
+memory does not refund its cumulative charge.
+
+## Provider bounds and fallback
+
+The native implementation has contracts that a SQL optimizer must respect:
+
+| Native behavior | T08 decision |
+| --- | --- |
+| Integer SUM wraps instead of checking overflow. | Integer SUM/AVG stays local unless a group has at most one possible timestamp. |
+| Float COUNT is returned as float64. | Prove a count bound no greater than 2^53; validate finite, integral, nonnegative results against it, then convert to int64. |
+| Native MEAN clamps sub-one results; integer MEAN truncates. | Use SUM/COUNT and QL float64 division. |
+| Native floating extrema use finite sentinels and ignore NaNs. | Float MIN/MAX stays local. |
+| Native group-key encoding does not distinguish null placeholders. | Push only entity keys proven non-null by exact EQ/IN predicates. |
+| Aggregate replica dedup keys omit measure-group and lifecycle-stage identity. Local map aggregates can also collapse multiple shards into one shard label. | Require one measure group, one provable stage and one complete entity per SQL group. |
+
+The initial native subset requires normal storage mode, one exact nonempty time
+interval, and entity grouping tags from one family. Every grouped entity tag
+must have a positive exact EQ/IN constraint. Every entity tag omitted from
+GROUP BY must have one exact bound value. Scalar aggregation requires one
+complete bound entity. Non-entity keys, tag aggregates, residual time filters,
+multi-group requests, index mode and uncertain stage selection use the complete
+T07 fallback. More than 32 distinct native leaves also selects that fallback.
+
+Normal writes are millisecond-aligned; native raw scans deduplicate versions at
+(complete entity, timestamp). A group's count therefore cannot exceed the number
+of millisecond positions in its half-open bound interval. Binding computes that
+bound without signed subtraction overflow and requires it to fit exactly in
+float64. A result LIMIT cannot provide this proof because it limits groups.
+
+Single-stage selection permits implicit hot storage when the group defines no
+stages, or one explicitly configured non-hot stage with a nonempty selector.
+Explicit/default hot selectors, unknown or multiple stages and missing watched
+options fall back. Tests replace watched options while retaining the same bound
+plan to verify that this decision is not cached.
+
+The first real E2E run exposed a separate native predicate limitation: an AND
+of entity IN lists discarded valid Cartesian selections and failed with
+`series is required`. QL now lowers these conjunctions to a balanced OR of
+complete assignments for both native and local paths. Repeated constraints
+intersect; contradictions and literal empty IN produce empty SQL input.
+Expansion is capped at 512 tuples. Existing empty-array parameter rejection
+remains unchanged. The native entity parser itself is unchanged.
+
+## Generated E2E evidence
+
+The generator adds 30 T08 scenarios with three bound windows each, one integer
+overflow rejection and 20 raw QL visibility witnesses over two fixtures. Every
+expected result comes from fixture arithmetic, never a native Query RPC. Cached,
+bypassed and forced-local executions use the same SQL contracts. The fixture
+includes versioned writes, two entity keys, fractional/negative/all-null values,
+large precise integers, and 129 groups to detect native default truncation.
+Indexed non-entity keys are constant within each entity, matching their native
+index substitution contract.
+
+The scenarios check native branch reuse, exact COUNT normalization, hidden and
+qualified tuple keys, scalar/all-null/empty results, weighted averages, unsafe
+fallbacks, LIMIT 0, cap failure, first/later branch errors, cancellation,
+authorization revocation, child revision failures and successful recovery.
+Structural counters distinguish one QL merge from its two/three native batch
+requests and from one local raw scan. An added Compile/Bind contract keeps the
+residual-time scenario disjoint for every changed binding.
+
+Both final 4 CPU/8 GiB Docker topology runs pass all 246 selected specs: 31 T08
+and 215 earlier ticket specs. Reports are `.omx/t08-standalone-final.json` and
+`.omx/t08-distributed-final.json`. The full tagged planner/operator/liaison suites
+also pass with race detection. Lifecycle guard tests run in normal and tagged
+builds. Final validation logs are listed below.
+
+The merge has an operator test retaining a bucket timestamp in its composite
+key. Extended QL TIME_BUCKET remains explicitly rejected by T07; selectable
+bucket planning and its real-RPC coverage belong to T16. Thus the original T08
+`bucket-and-multiple-keys` E2E item is not claimed as passed here. Multiple entity
+keys are covered through both real topologies now. Separate native branches do
+not establish a common snapshot during concurrent writes.
+
+## Focused benchmark protocol
+
+K(T08), `BenchmarkNativeAggregateMerge`, compares the complete batch merge with
+an independent scalar map reference for SUM/COUNT/AVG. It varies only 1/1,024/
+65,537 finalized groups and aligned/permuted native branch ordering. All keys,
+values, nulls and shared-lane counts are checked before timing. The column path
+reports two branches, 2N input group rows and 3N merge visits, plus state/key
+capacity and allocation cost. Run five one-second samples per variant in the
+same 4 CPU/8 GiB Docker limit.
+
+P(T08), `qlb_t08`, compares native and forced-local execution of identical QL,
+plus equivalent individual native Query RPC bundles. It has only four shapes:
+float AVG, shared float SUM/COUNT/AVG with a repeated AVG, integer MIN/MAX/COUNT,
+and an integer AVG fallback control. Eleven variants run at 1,024 and 32,768 raw
+rows over 16 entity hosts. The frozen schedule is 100 timed/5 warmup queries at
+1,024 and 20 timed/3 warmup queries at 32,768, one worker, five fresh processes
+per cardinality, a 256-MiB query tracker, and CPU/heap profiles. Setup/readiness
+and complete independent value checks are outside timing; retained timed samples
+are checked afterward. The individual control measures an entire sequence of
+two/three RPCs rather than one native call. These controls are not E2E oracles.
+
+The benchmark validates actual branch/scan counts, aggregate/merge state size,
+input/output visits, serialization counts and post-close reservations, native
+results, owner pins and flatten-pool references. Native source/frame counters
+are logical instrumented work, not physical storage reads. Total parent lease
+charges and native retained frame bytes are unavailable and remain labeled so.
+
+No earlier ticket benchmark matrices are repeated, and no universal pushdown
+speedup or retrospective T02 compatibility pass is assumed.
+
+K(T08) completed all 12 variants with five samples each in
+`.omx/t08-kernel-docker-final.log`; medians are in
+`.omx/t08-kernel-median.json`. No other test or benchmark ran concurrently.
+
+| Finalized groups | Branch row order | Batch merge | Scalar reference | Batch allocated bytes |
+| --- | --- | --- | --- | --- |
+| 1 | aligned | 5.713 us | 0.127 us | 4,096 |
+| 1 | permuted | 5.715 us | 0.127 us | 4,096 |
+| 1,024 | aligned | 703.622 us | 229.004 us | 160,472 |
+| 1,024 | permuted | 703.077 us | 234.317 us | 160,472 |
+| 65,537 | aligned | 54.480 ms | 24.642 ms | 12,632,832 |
+| 65,537 | permuted | 56.306 ms | 24.608 ms | 12,632,837 |
+
+The general merge is slower than this specialized two-int-key scalar map.
+It performs typed key encoding, tracked allocations, source validation and
+batch output construction. The scalar reference uses concrete Go struct keys
+and has no source/batch lifecycle. These measurements do not establish a kernel
+speedup. They verify independence from branch order and linear work: two native
+branches, 2N input group rows and 3N total merge visits. At 65,537 groups, used
+state is 1,179,666 bytes and allocated state is 2,359,296 bytes, including presence
+markers. Key capacity is 5,242,976 bytes. All tracked reservations return to zero.
+
+P(T08) completed five fresh repetitions for each cardinality: 110 measured
+variants in total, with all independent value checks, structural counters and
+post-close resource checks passing. There are 220 nonempty CPU/heap profiles.
+Reports and raw latency samples are under `.omx/bench-reports/t08-r1` through
+`t08-r5`; `.omx/t08-rpc-summary.json` preserves per-run summaries. The following
+numbers are medians of the five per-run measurements. A ratio below one favors
+pushdown. The fallback control uses the same local path in both modes.
+
+| Raw rows | Shape | Pushdown p50 ms | Local p50 ms | p50 ratio | Allocation ratio | CPU/query ratio |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1,024 | float AVG | 4.407 | 3.933 | 1.120 | 0.757 | 1.337 |
+| 1,024 | shared float SUM/COUNT/AVG | 4.275 | 4.130 | 1.035 | 0.777 | 1.295 |
+| 1,024 | integer MIN/MAX/COUNT | 5.839 | 3.849 | 1.517 | 1.128 | 1.856 |
+| 1,024 | integer AVG fallback | 3.760 | 3.804 | 0.988 | 1.002 | 0.989 |
+| 32,768 | float AVG | 20.669 | 49.449 | 0.418 | 0.213 | 0.577 |
+| 32,768 | shared float SUM/COUNT/AVG | 20.126 | 49.499 | 0.407 | 0.211 | 0.572 |
+| 32,768 | integer MIN/MAX/COUNT | 31.067 | 50.043 | 0.621 | 0.322 | 0.846 |
+| 32,768 | integer AVG fallback | 50.301 | 52.228 | 0.963 | 1.005 | 0.958 |
+
+At 32,768 rows, the five paired p50 ratios range from 0.406 to 0.432 for float
+AVG, 0.392 to 0.452 for shared float calls and 0.588 to 0.648 for integer extrema.
+Float allocation drops from roughly 48.9 MB to 10.3 MB/query; integer extrema
+drops from 48.5 MB to 15.6 MB/query. This is the benefit of returning finalized
+native groups instead of raw values to QL, despite the repeated native scans.
+
+Small queries expose branch startup and repeated scan costs. Their paired p50
+ratios range from 0.969 to 1.211 for float AVG, 0.975 to 1.165 for shared float
+calls and 1.481 to 1.632 for integer extrema. The latter is a consistent
+regression: three native branches cost more than one small raw scan. Safe native
+eligibility is not a cost model, and T08 does not claim that every eligible query
+is faster. The identical fallback controls range from 0.940 to 1.034 at 1,024
+rows and 0.947 to 1.034 at 32,768, illustrating remaining run variation. Only
+20 latency samples per large variant are retained in each run, so these runs
+do not establish a precise tail-latency acceptance interval.
+
+Individual native controls measure two/three serial Query RPCs, not one call:
+
+| Raw rows | Float AVG bundle p50 ms | Shared float bundle p50 ms | Integer extrema bundle p50 ms |
+| --- | --- | --- | --- |
+| 1,024 | 4.480 | 4.648 | 6.475 |
+| 32,768 | 20.627 | 19.742 | 32.615 |
+
+Every pushed request merges 32 finalized input rows for two branches, or 48 for
+three, into 16 groups. The corresponding merge visits are 48 and 64. Shared
+float calls still use two state lanes and two branches; integer AVG still uses
+one raw scan. Reservations/release totals match and all measured post-close
+reservation, owner, result and pool-reference deltas are zero. Structural T08
+work/resource checks pass; the original bucket E2E item remains T16 work and
+prior tickets' compatibility decisions are unchanged.
+
+## Final validation
+
+- Both real topologies: 246 selected specs each, including 31 T08 specs, under
+  4 CPU/8 GiB limits (`.omx/t08-{standalone,distributed}-final.json`).
+- Full normal planner, operator, liaison, generator and querybench packages:
+  `.omx/t08-normal-final.log`.
+- The same full packages with `-race -tags=querymetrics`:
+  `.omx/t08-race-final.log`. The context-argument-only test fix was checked again
+  with the focused resource race test (`.omx/t08-resource-style-final.log`).
+- `make lint`: `.omx/t08-make-lint-verified.log`; formatter diff is empty in
+  `.omx/t08-format-diff-final.log`.
+- `make check-ql-test-cases license-check`:
+  `.omx/t08-generated-license-final.log`. License-eye selected zero files in this
+  local invocation; all added Go files have the repository's ASF header.
+- Decoded generated cases, reuse/aggregate scenarios and readiness, rejections
+  and the pair report are unchanged from `89952fae`. T08 contracts are additive.
+
+`make check` validates license headers and repository formatting against a clean
+committed tree. Runtime reports, raw samples, profiles and source manifests listed
+here are local verification artifacts; this document retains their measured
+outcomes in the repository.
+
+## Resource probes
+
+Public MergeNativeAggregate tests execute 100,000 and 1,000,000 finalized groups
+at 16/256-MiB tracker limits. Successful output is checked key by key against an
+independent scalar oracle. A rejected query must return the budget error before
+any output; Close must return live reservations to zero and a small query must
+succeed using the same tracker afterward.
+
+| Finalized groups | 16 MiB | 256 MiB |
+| --- | --- | --- |
+| 100,000 | Complete and correct | Complete and correct |
+| 1,000,000 | Budget rejection at 131,072 retained groups; no output | Complete and correct |
+
+All four cases pass in normal and tagged race builds. Evidence:
+`.omx/t08-merge-resource-normal.log` and `.omx/t08-merge-resource-race.log`.
+The caller-owned input fixture and oracle are outside this operator tracker;
+these probes verify retained merge/output state, not native frame accounting.
+Real RPC cap/error/cancellation cases and the P(T08) resource counters verify the
+integration boundary separately. Prior T07 native readiness/resource blockers
+and the deferred spilling decision remain unchanged.
diff --git a/docs/interacting/bydbql.md b/docs/interacting/bydbql.md
index 9bef9a0eb..3880b8251 100644
--- a/docs/interacting/bydbql.md
+++ b/docs/interacting/bydbql.md
@@ -42,7 +42,7 @@ Abstract Syntax Tree (AST)
 - **Lexer**: Breaks the query string into a sequence of tokens.
 - **Parser**: Builds an Abstract Syntax Tree (AST) from the tokens, validating the query's syntax.
 - **Parameter Binding**: Binds `?` placeholders to the request's `params` (see section 2.6) without changing the query text; a query holding unbound placeholders is rejected.
-- **Transformer**: Resolves columns against the schema and produces the appropriate native request. Extended raw measure queries also use a QL plan for projection, time filtering, and pagination before encoding the existing measure response.
+- **Transformer**: Resolves columns against the schema and produces the appropriate native request. Extended measure queries also use a QL plan for projection, time filtering, local aggregation, and pagination before encoding the existing measure response.
 
 ### 2.2. Distinguishing Query Types
 
@@ -581,7 +581,7 @@ The `SELECT` clause for measures is highly flexible, allowing for the selection
 - `SELECT <identifier>::field, <identifier>::tag`: If a field and a tag share the same name, the `::field` or `::tag` syntax **must** be used to disambiguate the identifier's type.
 - `SELECT <column> AS <name>`: Renames a raw output column. Family-qualified tags such as `default.host` select a particular tag family; aliases retain the original tag family or field container.
 - `SELECT time, time AS observed_at`: Selects the built-in timestamp. Its response mapping is described in section 5.5.
-- The clause also supports one native aggregation function (`SUM`, `MEAN`/`AVG`, `COUNT`, `MAX`, `MIN`) per query and a `TOP N` clause for ranked results. Raw aliases and the other new raw extensions cannot currently be combined with aggregation or grouping.
+- Legacy queries support one native aggregation function (`SUM`, `MEAN`/`AVG`, `COUNT`, `MAX`, `MIN`) per query and a `TOP N` clause for ranked results. Extended measure queries support multiple aggregate outputs and aliases, as described in section 5.8.
 - An aggregate function's column resolves against both tags and fields, not just fields — `SUM(latency)` aggregates a field, `COUNT(DISTINCT user_id)` aggregates a tag. `DISTINCT` is only accepted inside `COUNT`; `SUM(DISTINCT x)` and similar are rejected as a parse error.
 
 ### 5.3. Mapping to `measure.v1.QueryRequest`
@@ -811,7 +811,7 @@ A raw query uses the extended behavior when it uses source shorthand, an `AS` al
 - `OFFSET` and `LIMIT` apply after the complete time filter, including disjoint ranges. Legacy queries without these raw extensions keep their existing pagination defaults.
 - Timestamp ordering, including an unambiguous timestamp alias such as `ORDER BY observed_at DESC`, is supported. Native index-rule ordering remains available. General sorting by tag/field output columns, multiple sort keys, and explicit NULL ordering are not implemented yet.
 
-The current raw extensions cannot be combined with aggregate projections, `GROUP BY`, or `SELECT TOP`. Legacy native aggregation and grouping retain their existing syntax and behavior. Multiple aggregate outputs, post-aggregation HAVING, field-value WHERE filters, arithmetic/casts, CASE/COALESCE, SELECT DISTINCT, and computed grouping are planned capabilities. An `OR` that mixes time and tag/field predicates is also rejected; combining a pure-time subtree with a tag predicate using `AND` [...]
+The current raw extensions cannot be combined with `SELECT TOP`. Aggregate projections and named scalar grouping use the extended aggregation behavior in section 5.8. Legacy native aggregation and grouping retain their existing syntax and behavior. Post-aggregation HAVING, field-value WHERE filters, arithmetic/casts, CASE/COALESCE, SELECT DISTINCT, and computed grouping are planned capabilities. An `OR` that mixes time and tag/field predicates is also rejected; combining a pure-time subt [...]
 
 Aliases, built-in time projections, `now()`, timestamp `INTERVAL` arithmetic, and source shorthand in this section apply to measure SELECT. They do not extend stream, trace, property, or SHOW TOP syntax.
 
@@ -856,6 +856,47 @@ Plan reuse reduces repeated planning work; end-to-end latency/CPU effects depend
 on execution costs. The [current PR 1 measurements](../design/0.12.0/measure-ql-core-sql/pr1-verification.md)
 show lower planning time and allocations but no established RPC latency gain.
 
+### 5.8. Extended measure aggregation
+
+Measure queries support multiple `COUNT`, `SUM`, `MIN`, `MAX`, `AVG`, and `MEAN` outputs over named columns. Aggregation uses this extended behavior when the query has multiple aggregate calls, source shorthand, an alias, or a `WHERE time` predicate. Existing single-aggregate queries using `FROM MEASURE ... IN ...` without these extensions keep their native behavior, including legacy `MEAN`/`AVG` results and count-distinct restrictions.
+
+```sql
+SELECT host AS node, COUNT(value) AS samples, SUM(value) AS total,
+       MIN(value) AS minimum, MAX(value) AS maximum, AVG(value) AS average
+FROM metrics.service_cpm
+WHERE time >= ? AND time < ?
+GROUP BY host
+```
+
+All aggregate calls use the complete, deduplicated input after the time and tag filters. Repeated calls share work while retaining their separate output aliases. Aggregates appear as named fields in the existing response; without an alias, their names are `COUNT(value)`, `SUM(value)`, and so on. Output field names must be unique. Synthesized aggregate rows omit raw-row timestamps, SIDs, and versions.
+
+The extended aggregate rules are:
+
+- `COUNT(column)` counts non-null values and returns an exact int64, including for floating-point inputs. It accepts scalar tags and fields.
+- `SUM`, `MIN`, and `MAX` accept numeric columns and preserve int64 or float64 values. Integer SUM overflow fails the query.
+- `AVG` and `MEAN` return float64 sum divided by non-null count. Fractional and sub-one averages are preserved; shard populations contribute their values and counts before finalization.
+- Scalar empty input returns one row: COUNT is zero, and the other aggregates are null. Grouped empty input returns no rows. All-null groups also have COUNT zero and null SUM/MIN/MAX/AVG.
+- `GROUP BY` accepts named scalar tag and field columns, including keys omitted from SELECT. Every selected non-aggregate column must be a grouping key. Aliases apply to output and cannot replace source names in GROUP BY. Null, zero, and empty-string keys remain distinct.
+- Omitted LIMIT returns all groups, with no implicit 100-group cap. OFFSET and LIMIT apply after complete aggregation. LIMIT 0 returns no rows without scanning; contradictory time bounds still produce the scalar empty-input row unless pagination removes it.
+
+```sql
+SELECT COUNT(value) AS samples, AVG(value) AS average
+FROM MEASURE service_cpm IN metrics
+WHERE time >= ? AND time < ?
+```
+
+Extended aggregate queries currently reject COUNT(DISTINCT), TOP, TIME_BUCKET, array grouping, and ORDER BY. The existing legacy native forms of these supported operations remain available. Group-only queries without an aggregate, expressions as aggregate arguments or grouping keys, and HAVING are not implemented yet.
+
+#### Native aggregate execution
+
+The planner can execute eligible calls through the existing native aggregate operators and combine their batch results by the complete grouping tuple. For example, `SUM(f)`, `COUNT(f)`, and repeated `AVG(f)` outputs over a floating-point field share two native requests: SUM and COUNT. QL computes AVG from their sum and non-null count; it does not use the legacy native MEAN result. Integer MIN, MAX, and COUNT use their matching native requests, sharing the auxiliary COUNT needed to distin [...]
+
+This optimization currently requires one measure group in normal storage mode, one exact time interval, and a single provable lifecycle stage. Grouping keys must be entity tags from one tag family, constrained to non-null values by equality or IN predicates. Each remaining entity tag must be bound to one value, so every SQL group identifies one full entity. A scalar aggregate requires one bound full entity too. This also bounds COUNT by the number of possible stored millisecond timestamp [...]
+
+Other supported queries use one complete raw scan and local SQL aggregation. These include nullable or non-entity keys, disjoint time ranges, multiple measure groups or stages, tag aggregates, floating-point MIN/MAX, and integer SUM/AVG spanning more than one possible timestamp. The fallback preserves checked integer overflow and SQL null/average semantics. Predicate conjunctions over entity IN lists are normalized before either execution path; expansion is limited to 512 entity tuples.
+
+Native branches run sequentially under the parent query's deadline and memory allowance. Final LIMIT/OFFSET applies after all branches merge. A branch failure or exhausted allowance fails the query without partial results. Separate native requests do not provide a shared snapshot during concurrent writes. Memory limits remain enforced; disk spilling is not implemented.
+
 ## 6. BydbQL for Top-N
 
 Top-N queries use a specialized, command-like syntax for clarity and to reflect the optimized nature of the underlying `banyandb.measure.v1.TopNRequest`. The `FROM` clause is mandatory.
diff --git a/pkg/bydbql/plan_measure.go b/pkg/bydbql/plan_measure.go
index 91a7d356e..fe2eb0001 100644
--- a/pkg/bydbql/plan_measure.go
+++ b/pkg/bydbql/plan_measure.go
@@ -34,6 +34,7 @@ import (
 	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
 	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
 	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
 )
 
 // ErrInvalidMeasureQuery identifies invalid typed measure syntax or bound values.
@@ -42,12 +43,14 @@ var ErrInvalidMeasureQuery = errors.New("invalid measure query")
 // MeasureTemplate contains immutable, schema-resolved measure planning metadata.
 // Each Bind creates independent requests and operators; no parameter or clock value is retained.
 type MeasureTemplate struct {
-	statement  *PreparedStatement
-	request    *measurev1.QueryRequest
-	predicate  *measurePredicate
-	legacyTags map[string]*tagSpecWithFamily
-	slots      []ql.Slot
-	extended   bool
+	statement      *PreparedStatement
+	request        *measurev1.QueryRequest
+	predicate      *measurePredicate
+	legacyTags     map[string]*tagSpecWithFamily
+	aggregate      *ql.AggregatePlan
+	nativeEntities []vectorized.ColumnDef
+	slots          []ql.Slot
+	extended       bool
 }
 
 // MeasureExecution is one fresh binding of a measure template.
@@ -134,8 +137,14 @@ func (t *Transformer) compileMeasure(
 	}
 	template := &MeasureTemplate{statement: statement, extended: extended}
 	if template.extended {
-		if err := template.compileRaw(snapshots); err != nil {
-			return nil, invalidMeasure(err)
+		var compileErr error
+		if measureAggregateCount(selectStmt) > 0 {
+			compileErr = template.compileAggregate(snapshots)
+		} else {
+			compileErr = template.compileRaw(snapshots)
+		}
+		if compileErr != nil {
+			return nil, invalidMeasure(compileErr)
 		}
 	} else {
 		if err := template.compileLegacy(run, snapshots); err != nil {
@@ -230,6 +239,11 @@ func (m *MeasureTemplate) Bind(bound *BoundQuery, now time.Time) (*MeasureExecut
 		return &MeasureExecution{Request: request}, nil
 	}
 	plan := &ql.RawPlan{Slots: slices.Clone(m.slots)}
+	if m.aggregate != nil {
+		plan.Aggregate = &ql.AggregatePlan{
+			GroupBy: slices.Clone(m.aggregate.GroupBy), Calls: slices.Clone(m.aggregate.Calls), Outputs: slices.Clone(m.aggregate.Outputs),
+		}
+	}
 	if stmt.Limit != nil {
 		limit := uint32(run.resolveCount(stmt.Limit.Value, stmt.Limit.Param, stmt.Limit.ParamIndex))
 		plan.Limit = &limit
@@ -265,11 +279,15 @@ func (m *MeasureTemplate) Bind(bound *BoundQuery, now time.Time) (*MeasureExecut
 	// Final SQL pagination belongs after the complete native scan and residual.
 	request.Offset = 0
 	request.Limit = math.MaxUint32
+	plan.NativeAggregate = m.bindNativeAggregate(request, plan)
+	if err = m.bindMeasureEntities(request, plan); err != nil {
+		return nil, invalidMeasure(err)
+	}
 	return &MeasureExecution{Request: request, Plan: plan}, nil
 }
 
 func requiresMeasurePlan(stmt *GrammarSelectStatement) bool {
-	if stmt.From.Shorthand != nil {
+	if stmt.From.Shorthand != nil || measureAggregateCount(stmt) > 1 {
 		return true
 	}
 	if stmt.Projection != nil {
@@ -289,6 +307,19 @@ func requiresMeasurePlan(stmt *GrammarSelectStatement) bool {
 	return whereHasTime(stmt.Where)
 }
 
+func measureAggregateCount(stmt *GrammarSelectStatement) int {
+	if stmt.Projection == nil {
+		return 0
+	}
+	count := 0
+	for _, column := range stmt.Projection.Columns {
+		if column.Aggregate != nil {
+			count++
+		}
+	}
+	return count
+}
+
 func whereHasTime(where *GrammarSelectWhereClause) bool {
 	if where == nil {
 		return false
diff --git a/pkg/bydbql/plan_measure_aggregate.go b/pkg/bydbql/plan_measure_aggregate.go
new file mode 100644
index 000000000..cc3341a0f
--- /dev/null
+++ b/pkg/bydbql/plan_measure_aggregate.go
@@ -0,0 +1,198 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package bydbql
+
+import (
+	"fmt"
+	"strings"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func (m *MeasureTemplate) compileAggregate(schemas map[string]*databasev1.Measure) error {
+	stmt := m.statement.template.Select
+	if stmt.Projection.TopN != nil {
+		return fmt.Errorf("extended aggregation does not support TOP")
+	}
+	if stmt.OrderBy != nil {
+		return fmt.Errorf("extended aggregation requires post-aggregate column sorting, which is not supported yet")
+	}
+	columns, err := resolveMeasureColumns(stmt.From.In.Groups, schemas)
+	if err != nil {
+		return err
+	}
+	plan := &ql.AggregatePlan{}
+	var keys map[measureColumnKey]int
+	inputs := make(map[measureColumnKey]bool)
+	addInput := func(source vectorized.ColumnDef) {
+		key := columnIdentity(source)
+		if !inputs[key] {
+			inputs[key] = true
+			m.slots = append(m.slots, ql.Slot{ID: ql.SlotID(len(m.slots)), Source: source, Output: source, Nullable: true})
+		}
+	}
+	plan.GroupBy, keys, err = compileAggregateGroups(stmt.GroupBy, columns)
+	if err != nil {
+		return err
+	}
+	for _, source := range plan.GroupBy {
+		addInput(source)
+	}
+	outputs := make(map[measureColumnKey]bool)
+	for _, column := range stmt.Projection.Columns {
+		var output ql.AggregateOutput
+		if column.Aggregate == nil {
+			name, nameErr := column.Identifier.ToString(column.TypeSpec != nil)
+			if nameErr != nil {
+				return nameErr
+			}
+			source, sourceErr := columns.resolve(name, column.TypeSpec)
+			if sourceErr != nil {
+				return sourceErr
+			}
+			index, grouped := keys[columnIdentity(source)]
+			if !grouped {
+				return fmt.Errorf("projected column %q must appear in GROUP BY", name)
+			}
+			output = ql.AggregateOutput{Column: source, Index: index, IsGroup: true}
+		} else {
+			aggregate := column.Aggregate
+			if aggregate.Distinct {
+				return fmt.Errorf("extended COUNT(DISTINCT) is not supported; existing native routing and stage restrictions still apply")
+			}
+			name, nameErr := aggregate.Column.ToString(true)
+			if nameErr != nil {
+				return nameErr
+			}
+			source, sourceErr := columns.resolve(name, column.TypeSpec)
+			if sourceErr != nil {
+				return sourceErr
+			}
+			function, functionErr := sqlAggregateFunction(aggregate.Function)
+			if functionErr != nil {
+				return functionErr
+			}
+			if !scalarAggregateType(source.Type) || function != ql.AggregateCount &&
+				source.Type != vectorized.ColumnTypeInt64 && source.Type != vectorized.ColumnTypeFloat64 {
+				return fmt.Errorf("%s does not support column %q of type %s", aggregate.Function, name, source.Type)
+			}
+			call := ql.AggregateCall{Source: source, Function: function}
+			index := -1
+			for candidate, existing := range plan.Calls {
+				if existing == call {
+					index = candidate
+					break
+				}
+			}
+			if index < 0 {
+				index = len(plan.Calls)
+				plan.Calls = append(plan.Calls, call)
+			}
+			valueType := source.Type
+			switch function {
+			case ql.AggregateCount:
+				valueType = vectorized.ColumnTypeInt64
+			case ql.AggregateAvg:
+				valueType = vectorized.ColumnTypeFloat64
+			default:
+			}
+
+			output = ql.AggregateOutput{Index: index, Column: vectorized.ColumnDef{
+				Name: strings.ToUpper(aggregate.Function) + "(" + name + ")", Role: vectorized.RoleField, Type: valueType,
+			}}
+			addInput(source)
+		}
+		if column.Alias != nil {
+			output.Column.Name, err = column.Alias.ToString(true)
+			if err != nil {
+				return err
+			}
+		}
+		key := columnIdentity(output.Column)
+		if outputs[key] {
+			return fmt.Errorf("duplicate output name %q in container %q", key.name, key.family)
+		}
+		outputs[key] = true
+		plan.Outputs = append(plan.Outputs, output)
+	}
+	m.aggregate = plan
+	m.request = baseMeasureRequest(stmt)
+	m.request.TagProjection, m.request.FieldProjection = rawScanProjection(m.slots)
+	if stmt.Where != nil {
+		m.predicate, err = compileMeasureOr(stmt.Where.Expr, columns)
+	}
+	m.compileNativeAggregate(schemas, columns)
+	return err
+}
+
+func compileAggregateGroups(groupBy *GrammarGroupByClause, columns *measureColumns) ([]vectorized.ColumnDef, map[measureColumnKey]int, error) {
+	keys := make(map[measureColumnKey]int)
+	var groups []vectorized.ColumnDef
+	if groupBy != nil {
+		for _, group := range groupBy.Columns {
+			if group.TimeBucket != nil {
+				return nil, nil, fmt.Errorf("extended aggregation supports named scalar GROUP BY columns; TIME_BUCKET requires native legacy execution")
+			}
+			name, nameErr := group.Identifier.ToString(group.TypeSpec != nil)
+			if nameErr != nil {
+				return nil, nil, nameErr
+			}
+			source, sourceErr := columns.resolve(name, group.TypeSpec)
+			if sourceErr != nil {
+				return nil, nil, sourceErr
+			}
+			if !scalarAggregateType(source.Type) {
+				return nil, nil, fmt.Errorf("GROUP BY column %q requires a scalar type", name)
+			}
+			key := columnIdentity(source)
+			if _, exists := keys[key]; !exists {
+				keys[key] = len(groups)
+				groups = append(groups, source)
+			}
+		}
+	}
+	return groups, keys, nil
+}
+
+func columnIdentity(column vectorized.ColumnDef) measureColumnKey {
+	return measureColumnKey{family: column.TagFamily, name: column.Name, role: column.Role}
+}
+
+func scalarAggregateType(columnType vectorized.ColumnType) bool {
+	return columnType == vectorized.ColumnTypeInt64 || columnType == vectorized.ColumnTypeFloat64 ||
+		columnType == vectorized.ColumnTypeString || columnType == vectorized.ColumnTypeBytes
+}
+
+func sqlAggregateFunction(name string) (ql.AggregateFunction, error) {
+	switch strings.ToUpper(name) {
+	case "COUNT":
+		return ql.AggregateCount, nil
+	case "SUM":
+		return ql.AggregateSum, nil
+	case "MIN":
+		return ql.AggregateMin, nil
+	case "MAX":
+		return ql.AggregateMax, nil
+	case "AVG", "MEAN":
+		return ql.AggregateAvg, nil
+	default:
+		return 0, fmt.Errorf("unsupported SQL aggregation function %q", name)
+	}
+}
diff --git a/pkg/bydbql/plan_measure_aggregate_test.go b/pkg/bydbql/plan_measure_aggregate_test.go
new file mode 100644
index 000000000..a11291806
--- /dev/null
+++ b/pkg/bydbql/plan_measure_aggregate_test.go
@@ -0,0 +1,113 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package bydbql_test
+
+import (
+	"context"
+	"math"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/bydbql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func TestMeasureSQLAggregationUsesOneCompleteRawScan(t *testing.T) {
+	execution := typedExecution(t, "SELECT COUNT(v) AS c, SUM(v) AS s, MIN(v) AS lo, MAX(v) AS hi, AVG(f) AS a FROM g.m LIMIT 1 OFFSET 2")
+	require.NotNil(t, execution.Plan)
+	require.Nil(t, execution.Request.Agg)
+	require.Nil(t, execution.Request.GroupBy)
+	require.Equal(t, uint32(math.MaxUint32), execution.Request.Limit)
+	require.Zero(t, execution.Request.Offset)
+	require.Equal(t, []string{"v", "f"}, execution.Request.FieldProjection.Names)
+	require.Equal(t, uint32(1), *execution.Plan.Limit)
+	require.Equal(t, uint32(2), execution.Plan.Offset)
+}
+
+func TestMeasureSQLAggregateGroupingAndSharedCalls(t *testing.T) {
+	execution := typedExecution(t, "SELECT host AS node, COUNT(v) AS c, SUM(v) AS s, AVG(v) AS a, MEAN(v) AS b, SUM(v) AS again FROM MEASURE m IN g GROUP BY host, f::FIELD")
+	require.NotNil(t, execution.Plan.Aggregate)
+	require.Len(t, execution.Plan.Aggregate.GroupBy, 2)
+	require.Len(t, execution.Plan.Aggregate.Calls, 3)
+	require.Len(t, execution.Plan.Aggregate.Outputs, 6)
+	require.Equal(t, "node", execution.Plan.Aggregate.Outputs[0].Column.Name)
+	require.True(t, execution.Plan.Aggregate.Outputs[0].IsGroup)
+	require.Equal(t, execution.Plan.Aggregate.Outputs[3].Index, execution.Plan.Aggregate.Outputs[4].Index)
+	require.Equal(t, execution.Plan.Aggregate.Outputs[2].Index, execution.Plan.Aggregate.Outputs[5].Index)
+	require.Equal(t, vectorized.ColumnTypeFloat64, execution.Plan.Aggregate.Outputs[3].Column.Type)
+	require.Equal(t, []string{"f", "v"}, execution.Request.FieldProjection.Names)
+
+	unnamed := typedExecution(t, "SELECT COUNT(f), AVG(f) FROM MEASURE m IN g")
+	require.Equal(t, "COUNT(f)", unnamed.Plan.Aggregate.Outputs[0].Column.Name)
+	require.Equal(t, "AVG(f)", unnamed.Plan.Aggregate.Outputs[1].Column.Name)
+	require.Nil(t, unnamed.Request.Agg)
+	legacy := typedExecution(t, "SELECT host, MEAN(v) FROM MEASURE m IN g GROUP BY host")
+	require.Nil(t, legacy.Plan)
+	require.Equal(t, modelv1.AggregationFunction_AGGREGATION_FUNCTION_MEAN, legacy.Request.Agg.Function)
+}
+
+func TestMeasureSQLAggregateRejectsUnsupportedShapesBeforeScan(t *testing.T) {
+	queries := []string{
+		"SELECT host, SUM(v) AS s FROM g.m",
+		"SELECT host, SUM(v) AS s FROM g.m GROUP BY rid",
+		"SELECT SUM(host) FROM g.m",
+		"SELECT MIN(host) FROM g.m",
+		"SELECT COUNT(DISTINCT host) FROM g.m",
+		"SELECT COUNT(DISTINCT v) AS c FROM MEASURE m IN g ON (warm,cold) STAGES",
+		"SELECT SUM(v) AS x, COUNT(v) AS x FROM g.m",
+		"SELECT SUM(v), SUM(v) FROM g.m",
+		"SELECT COUNT(clash) FROM g.m",
+		"SELECT COUNT(missing) FROM g.m",
+		"SELECT host AS node, SUM(v) AS s FROM g.m GROUP BY node",
+		"SELECT SUM(v) FROM g.m GROUP BY TIME_BUCKET('2s')",
+		"SELECT SUM(v) FROM g.m ORDER BY time",
+	}
+	for _, query := range queries {
+		t.Run(query, func(t *testing.T) {
+			statement, err := bydbql.Prepare(query)
+			require.NoError(t, err)
+			_, err = typedTransformer().CompileMeasure(context.Background(), statement)
+			require.ErrorIs(t, err, bydbql.ErrInvalidMeasureQuery)
+		})
+	}
+}
+
+func TestMeasureSQLAggregateBindingDoesNotMutateCachedTemplate(t *testing.T) {
+	statement, err := bydbql.Prepare("SELECT host AS node, SUM(v) AS total FROM g.m WHERE host = ? GROUP BY host LIMIT ?")
+	require.NoError(t, err)
+	template, err := typedTransformer().CompileMeasure(context.Background(), statement)
+	require.NoError(t, err)
+	bytes := template.EstimatedSize()
+	for index := int64(0); index < 3; index++ {
+		execution, bindErr := template.BindParams([]*modelv1.TagValue{
+			{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "a"}}},
+			{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: index}}},
+		}, time.UnixMilli(index))
+		require.NoError(t, bindErr)
+		require.Equal(t, "host", execution.Plan.Aggregate.GroupBy[0].Name)
+		require.Equal(t, "v", execution.Plan.Aggregate.Calls[0].Source.Name)
+		require.Equal(t, "total", execution.Plan.Aggregate.Outputs[1].Column.Name)
+		execution.Plan.Aggregate.GroupBy[0].Name = "poison"
+		execution.Plan.Aggregate.Calls[0].Source.Name = "poison"
+		execution.Plan.Aggregate.Outputs[1].Column.Name = "poison"
+	}
+	require.Equal(t, bytes, template.EstimatedSize())
+}
diff --git a/pkg/bydbql/plan_measure_entities.go b/pkg/bydbql/plan_measure_entities.go
new file mode 100644
index 000000000..723715882
--- /dev/null
+++ b/pkg/bydbql/plan_measure_entities.go
@@ -0,0 +1,266 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package bydbql
+
+import (
+	"errors"
+	"fmt"
+
+	"google.golang.org/protobuf/proto"
+
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+const maxMeasureEntityTuples = 512
+
+var errEmptyMeasureEntities = errors.New("empty measure entity selection")
+
+type measureEntityValue struct {
+	text    string
+	number  int64
+	integer bool
+}
+
+func (m *MeasureTemplate) bindMeasureEntities(request *measurev1.QueryRequest, plan *ql.RawPlan) error {
+	if plan.Empty || len(m.nativeEntities) == 0 {
+		return nil
+	}
+	normalized, err := normalizeMeasureEntities(request.Criteria, m.nativeEntities)
+	if errors.Is(err, errEmptyMeasureEntities) {
+		plan.Empty, plan.NativeAggregate = true, nil
+		return nil
+	}
+	if err != nil {
+		return err
+	}
+	if normalized == request.Criteria {
+		return nil
+	}
+	request.Criteria = normalized
+	if plan.NativeAggregate != nil {
+		for index := range plan.NativeAggregate.Leaves {
+			plan.NativeAggregate.Leaves[index].Request.Criteria = proto.Clone(normalized).(*modelv1.Criteria)
+		}
+	}
+	return nil
+}
+
+// normalizeMeasureEntities lowers an entity conjunction into alternatives that
+// the existing native entity parser can merge without dropping IN values.
+func normalizeMeasureEntities(criteria *modelv1.Criteria, entities []vectorized.ColumnDef) (*modelv1.Criteria, error) {
+	if criteria == nil || len(entities) == 0 {
+		return criteria, nil
+	}
+	names := make(map[string]bool, len(entities))
+	for _, entity := range entities {
+		names[entity.Name] = true
+	}
+	var terms []*modelv1.Criteria
+	flattenMeasureEntityAnd(criteria, &terms)
+	sets := make(map[string][]measureEntityValue)
+	var remaining []*modelv1.Criteria
+	constraints := 0
+	for _, term := range terms {
+		condition := term.GetCondition()
+		values, exact := measureEntityValues(condition)
+		if condition == nil || !names[condition.Name] || !exact {
+			remaining = append(remaining, term)
+			continue
+		}
+		constraints++
+		values = distinctMeasureEntityValues(values)
+		if previous, found := sets[condition.Name]; found {
+			values = intersectMeasureEntityValues(previous, values)
+		}
+		if len(values) == 0 {
+			return nil, fmt.Errorf("%w: constraints for %q have no common value", errEmptyMeasureEntities, condition.Name)
+		}
+		sets[condition.Name] = values
+	}
+	multiple := false
+	for _, values := range sets {
+		multiple = multiple || len(values) > 1
+	}
+	if constraints == len(sets) && (!multiple || len(sets) < 2) {
+		return criteria, nil
+	}
+	for _, term := range remaining {
+		if term.GetLe() != nil && containsMeasureEntity(term, names) {
+			return nil, fmt.Errorf("entity IN conjunction cannot be combined with an entity OR predicate")
+		}
+	}
+	if len(sets) == 1 {
+		for name, values := range sets {
+			return combineMeasureEntityTerms(modelv1.LogicalExpression_LOGICAL_OP_AND,
+				append([]*modelv1.Criteria{measureEntityCondition(name, values)}, remaining...)), nil
+		}
+	}
+	tupleCount := 1
+	for _, values := range sets {
+		if len(values) > maxMeasureEntityTuples/tupleCount {
+			return nil, fmt.Errorf("entity conjunction expands beyond %d tuples", maxMeasureEntityTuples)
+		}
+		tupleCount *= len(values)
+	}
+	tuples := []*modelv1.Criteria{nil}
+	for _, entity := range entities {
+		values, constrained := sets[entity.Name]
+		if !constrained {
+			continue
+		}
+		next := make([]*modelv1.Criteria, 0, len(tuples)*len(values))
+		for _, tuple := range tuples {
+			for _, value := range values {
+				next = append(next, combineMeasureCriteria(tuple, measureEntityCondition(entity.Name, []measureEntityValue{value}), false))
+			}
+		}
+		tuples = next
+	}
+	assignments := combineMeasureEntityTerms(modelv1.LogicalExpression_LOGICAL_OP_OR, tuples)
+	return combineMeasureEntityTerms(modelv1.LogicalExpression_LOGICAL_OP_AND, append([]*modelv1.Criteria{assignments}, remaining...)), nil
+}
+
+func flattenMeasureEntityAnd(criteria *modelv1.Criteria, terms *[]*modelv1.Criteria) {
+	if expression := criteria.GetLe(); expression != nil && expression.Op == modelv1.LogicalExpression_LOGICAL_OP_AND {
+		flattenMeasureEntityAnd(expression.Left, terms)
+		flattenMeasureEntityAnd(expression.Right, terms)
+	} else if criteria != nil {
+		*terms = append(*terms, criteria)
+	}
+}
+
+func measureEntityValues(condition *modelv1.Condition) ([]measureEntityValue, bool) {
+	if condition == nil || condition.Value == nil {
+		return nil, false
+	}
+	switch condition.Op {
+	case modelv1.Condition_BINARY_OP_EQ:
+		switch value := condition.Value.Value.(type) {
+		case *modelv1.TagValue_Str:
+			if value.Str != nil {
+				return []measureEntityValue{{text: value.Str.Value}}, true
+			}
+		case *modelv1.TagValue_Int:
+			if value.Int != nil {
+				return []measureEntityValue{{number: value.Int.Value, integer: true}}, true
+			}
+		default:
+		}
+	case modelv1.Condition_BINARY_OP_IN:
+		switch value := condition.Value.Value.(type) {
+		case *modelv1.TagValue_StrArray:
+			if value.StrArray != nil {
+				values := make([]measureEntityValue, 0, len(value.StrArray.Value))
+				for _, text := range value.StrArray.Value {
+					values = append(values, measureEntityValue{text: text})
+				}
+				return values, true
+			}
+		case *modelv1.TagValue_IntArray:
+			if value.IntArray != nil {
+				values := make([]measureEntityValue, 0, len(value.IntArray.Value))
+				for _, number := range value.IntArray.Value {
+					values = append(values, measureEntityValue{number: number, integer: true})
+				}
+				return values, true
+			}
+		default:
+		}
+	default:
+	}
+	return nil, false
+}
+
+func distinctMeasureEntityValues(values []measureEntityValue) []measureEntityValue {
+	seen := make(map[measureEntityValue]bool, len(values))
+	result := values[:0]
+	for _, value := range values {
+		if !seen[value] {
+			seen[value] = true
+			result = append(result, value)
+		}
+	}
+	return result
+}
+
+func intersectMeasureEntityValues(left, right []measureEntityValue) []measureEntityValue {
+	wanted := make(map[measureEntityValue]bool, len(right))
+	for _, value := range right {
+		wanted[value] = true
+	}
+	result := left[:0]
+	for _, value := range left {
+		if wanted[value] {
+			result = append(result, value)
+		}
+	}
+	return result
+}
+
+func measureEntityCondition(name string, values []measureEntityValue) *modelv1.Criteria {
+	condition := &modelv1.Condition{Name: name, Op: modelv1.Condition_BINARY_OP_EQ, Value: &modelv1.TagValue{}}
+	if len(values) == 1 {
+		if values[0].integer {
+			condition.Value.Value = &modelv1.TagValue_Int{Int: &modelv1.Int{Value: values[0].number}}
+		} else {
+			condition.Value.Value = &modelv1.TagValue_Str{Str: &modelv1.Str{Value: values[0].text}}
+		}
+	} else {
+		condition.Op = modelv1.Condition_BINARY_OP_IN
+		if values[0].integer {
+			numbers := make([]int64, 0, len(values))
+			for _, value := range values {
+				numbers = append(numbers, value.number)
+			}
+			condition.Value.Value = &modelv1.TagValue_IntArray{IntArray: &modelv1.IntArray{Value: numbers}}
+		} else {
+			texts := make([]string, 0, len(values))
+			for _, value := range values {
+				texts = append(texts, value.text)
+			}
+			condition.Value.Value = &modelv1.TagValue_StrArray{StrArray: &modelv1.StrArray{Value: texts}}
+		}
+	}
+	return &modelv1.Criteria{Exp: &modelv1.Criteria_Condition{Condition: condition}}
+}
+
+func combineMeasureEntityTerms(op modelv1.LogicalExpression_LogicalOp, terms []*modelv1.Criteria) *modelv1.Criteria {
+	if len(terms) == 0 {
+		return nil
+	}
+	if len(terms) == 1 {
+		return terms[0]
+	}
+	middle := len(terms) / 2
+	return &modelv1.Criteria{Exp: &modelv1.Criteria_Le{Le: &modelv1.LogicalExpression{
+		Op: op, Left: combineMeasureEntityTerms(op, terms[:middle]), Right: combineMeasureEntityTerms(op, terms[middle:]),
+	}}}
+}
+
+func containsMeasureEntity(criteria *modelv1.Criteria, names map[string]bool) bool {
+	if condition := criteria.GetCondition(); condition != nil {
+		return names[condition.Name]
+	}
+	if expression := criteria.GetLe(); expression != nil {
+		return containsMeasureEntity(expression.Left, names) || containsMeasureEntity(expression.Right, names)
+	}
+	return false
+}
diff --git a/pkg/bydbql/plan_measure_entities_test.go b/pkg/bydbql/plan_measure_entities_test.go
new file mode 100644
index 000000000..9a8758869
--- /dev/null
+++ b/pkg/bydbql/plan_measure_entities_test.go
@@ -0,0 +1,219 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package bydbql_test
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/proto"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/bydbql"
+	pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+	"github.com/apache/skywalking-banyandb/pkg/query/logical"
+	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+)
+
+func TestMeasureAggregateEntityConjunctionPreservesAllRequestedTuples(t *testing.T) {
+	execution := bindEntityAggregate(t, "host IN ('a','b','c') AND region IN ('east','west')")
+	require.NotNil(t, execution.Plan.NativeAggregate)
+	require.Equal(t, []string{"a/east", "a/west", "b/east", "b/west", "c/east", "c/west"}, nativeEntityTuples(t, execution.Request.Criteria))
+	for _, leaf := range execution.Plan.NativeAggregate.Leaves {
+		require.True(t, proto.Equal(execution.Request.Criteria, leaf.Request.Criteria), "native and local scans must request the same entity tuples")
+		require.Equal(t, []string{"a/east", "a/west", "b/east", "b/west", "c/east", "c/west"}, nativeEntityTuples(t, leaf.Request.Criteria))
+	}
+}
+
+func TestMeasureAggregateEntitySetsIntersectAndDeduplicate(t *testing.T) {
+	cases := []struct {
+		predicate string
+		want      []string
+	}{
+		{"host IN ('a','a','b') AND region IN ('east','east','west')", []string{"a/east", "a/west", "b/east", "b/west"}},
+		{"host IN ('a','b','c') AND host IN ('b','c','d') AND region IN ('east','west')", []string{"b/east", "b/west", "c/east", "c/west"}},
+		{"host IN ('','a') AND region IN ('','east')", []string{"/", "/east", "a/", "a/east"}},
+		{"host IN ('a','b') AND host = 'b' AND region = 'east'", []string{"b/east"}},
+	}
+	for _, example := range cases {
+		t.Run(example.predicate, func(t *testing.T) {
+			execution := bindEntityAggregate(t, example.predicate)
+			require.Equal(t, example.want, nativeEntityTuples(t, execution.Request.Criteria))
+		})
+	}
+}
+
+func TestMeasureAggregateEntityNormalizationPreservesSimpleShapes(t *testing.T) {
+	single := bindEntityAggregate(t, "host IN ('a','b')")
+	require.NotNil(t, single.Request.Criteria.GetCondition())
+	require.Equal(t, modelv1.Condition_BINARY_OP_IN, single.Request.Criteria.GetCondition().Op)
+	require.Equal(t, []string{"a/*", "b/*"}, nativeEntityTuples(t, single.Request.Criteria))
+	exact := bindEntityAggregate(t, "host = 'a' AND region = 'east'")
+	require.Equal(t, modelv1.LogicalExpression_LOGICAL_OP_AND, exact.Request.Criteria.GetLe().Op)
+	require.NotNil(t, exact.Request.Criteria.GetLe().Left.GetCondition())
+	require.NotNil(t, exact.Request.Criteria.GetLe().Right.GetCondition())
+	require.Equal(t, []string{"a/east"}, nativeEntityTuples(t, exact.Request.Criteria))
+}
+
+func TestMeasureAggregateEntityNormalizationAlsoAppliesToLocalFallback(t *testing.T) {
+	predicate := "host IN ('a','b','c') AND region IN ('east','west')"
+	native := bindEntityAggregate(t, predicate)
+	schema := entityAggregateSchema()
+	schema.Fields[1].FieldType = databasev1.FieldType_FIELD_TYPE_INT
+	template := entityAggregateTemplateWithSchema(t, predicate, schema)
+	local, err := template.BindParams(nil, time.UnixMilli(0))
+	require.NoError(t, err)
+	require.Nil(t, local.Plan.NativeAggregate, "integer SUM retains the local checked-overflow implementation")
+	require.True(t, proto.Equal(native.Request.Criteria, local.Request.Criteria))
+	require.Equal(t, []string{"a/east", "a/west", "b/east", "b/west", "c/east", "c/west"}, nativeEntityTuples(t, local.Request.Criteria))
+}
+
+func TestMeasureAggregateEntityNormalizationRetainsIntegerZero(t *testing.T) {
+	schema := entityAggregateSchema()
+	schema.TagFamilies[0].Tags[len(schema.TagFamilies[0].Tags)-1].Type = databasev1.TagType_TAG_TYPE_INT
+	template := entityAggregateTemplateWithSchema(t, "host IN ('','a') AND region IN (0,1)", schema)
+	execution, err := template.BindParams(nil, time.UnixMilli(0))
+	require.NoError(t, err)
+	require.Equal(t, []string{"/0", "/1", "a/0", "a/1"}, nativeEntityTuples(t, execution.Request.Criteria))
+}
+
+func TestMeasureAggregateEntityNormalizationRejectsUnsafeOrUnboundedExpansion(t *testing.T) {
+	cases := []struct {
+		predicate string
+		message   string
+	}{
+		{"host IN ('a','b') AND region IN ('east','west') AND (host = 'a' OR host = 'b')", "entity OR predicate"},
+		{entityInPredicate("host", 23) + " AND " + entityInPredicate("region", 23), "512 tuples"},
+	}
+	for _, example := range cases {
+		t.Run(example.message+example.predicate[:12], func(t *testing.T) {
+			template := entityAggregateTemplate(t, example.predicate)
+			_, err := template.BindParams(nil, time.UnixMilli(0))
+			require.ErrorIs(t, err, bydbql.ErrInvalidMeasureQuery)
+			require.ErrorContains(t, err, example.message)
+		})
+	}
+	boundary := bindEntityAggregate(t, entityInPredicate("host", 16)+" AND "+entityInPredicate("region", 32))
+	require.Len(t, nativeEntityTuples(t, boundary.Request.Criteria), 512)
+}
+
+func TestMeasureAggregateEntityContradictionsProduceEmptyInput(t *testing.T) {
+	for _, predicate := range []string{
+		"host = 'a' AND host = 'b' AND region = 'east'",
+		"host IN ('a','b') AND host IN ('c','d') AND region = 'east'",
+	} {
+		t.Run(predicate, func(t *testing.T) {
+			execution := bindEntityAggregate(t, predicate)
+			require.True(t, execution.Plan.Empty)
+			require.Nil(t, execution.Plan.NativeAggregate)
+			require.NotNil(t, execution.Plan.Aggregate)
+		})
+	}
+	statement, err := bydbql.Prepare("SELECT COUNT(f) AS c FROM g.m WHERE host IN ()")
+	require.NoError(t, err)
+	template, err := bydbql.NewTransformer(nil).CompileMeasureWithSchemas(context.Background(), statement,
+		map[bydbql.MeasureSource]*databasev1.Measure{{Group: "g", Name: "m"}: entityAggregateSchema()})
+	require.NoError(t, err)
+	execution, err := template.BindParams(nil, time.UnixMilli(0))
+	require.NoError(t, err)
+	require.True(t, execution.Plan.Empty)
+	require.Nil(t, execution.Plan.NativeAggregate)
+	require.Empty(t, execution.Plan.Aggregate.GroupBy)
+	require.Equal(t, ql.AggregateCount, execution.Plan.Aggregate.Calls[0].Function)
+}
+
+func entityInPredicate(name string, count int) string {
+	values := make([]string, count)
+	for index := range values {
+		values[index] = fmt.Sprintf("'%s%02d'", name, index)
+	}
+	return name + " IN (" + strings.Join(values, ",") + ")"
+}
+
+func bindEntityAggregate(t *testing.T, predicate string) *bydbql.MeasureExecution {
+	t.Helper()
+	template := entityAggregateTemplate(t, predicate)
+	execution, err := template.BindParams(nil, time.UnixMilli(0))
+	require.NoError(t, err)
+	return execution
+}
+
+func entityAggregateTemplate(t *testing.T, predicate string) *bydbql.MeasureTemplate {
+	t.Helper()
+	return entityAggregateTemplateWithSchema(t, predicate, entityAggregateSchema())
+}
+
+func entityAggregateSchema() *databasev1.Measure {
+	schema := nativeAggregateSchema()
+	schema.Entity.TagNames = []string{"host", "region"}
+	schema.TagFamilies[0].Tags = append(schema.TagFamilies[0].Tags, &databasev1.TagSpec{Name: "region", Type: databasev1.TagType_TAG_TYPE_STRING})
+	return schema
+}
+
+func entityAggregateTemplateWithSchema(t *testing.T, predicate string, schema *databasev1.Measure) *bydbql.MeasureTemplate {
+	t.Helper()
+	statement, err := bydbql.Prepare("SELECT host, region, SUM(f) AS s, COUNT(f) AS c FROM g.m WHERE " + predicate +
+		" AND time >= '1970-01-01T00:00:00Z' AND time < '1970-01-01T00:00:06Z' GROUP BY host, region")
+	require.NoError(t, err)
+	template, err := bydbql.NewTransformer(nil).CompileMeasureWithSchemas(context.Background(), statement,
+		map[bydbql.MeasureSource]*databasev1.Measure{{Group: "g", Name: "m"}: schema})
+	require.NoError(t, err)
+	return template
+}
+
+// nativeEntityTuples drives the existing public native entity parser with the
+// actual bound request. Expected tuple lists are independently specified above.
+func nativeEntityTuples(t *testing.T, criteria *modelv1.Criteria) []string {
+	t.Helper()
+	base := []*modelv1.TagValue{pbv1.AnyTagValue, pbv1.AnyTagValue}
+	var parse func(*modelv1.Criteria) [][]*modelv1.TagValue
+	parse = func(current *modelv1.Criteria) [][]*modelv1.TagValue {
+		if current == nil {
+			return [][]*modelv1.TagValue{base}
+		}
+		if expression := current.GetLe(); expression != nil {
+			return logical.ParseEntities(expression.Op, base, parse(expression.Left), parse(expression.Right))
+		}
+		_, tuples, err := logical.ParseExprOrEntity(map[string]int{"host": 0, "region": 1}, base, current.GetCondition())
+		require.NoError(t, err)
+		return tuples
+	}
+	var result []string
+	for _, tuple := range parse(criteria) {
+		values := make([]string, len(tuple))
+		for index, value := range tuple {
+			if value == pbv1.AnyTagValue {
+				values[index] = "*"
+			} else if integer, ok := value.GetValue().(*modelv1.TagValue_Int); ok {
+				values[index] = strconv.FormatInt(integer.Int.GetValue(), 10)
+			} else {
+				values[index] = value.GetStr().GetValue()
+			}
+		}
+		result = append(result, strings.Join(values, "/"))
+	}
+	sort.Strings(result)
+	return result
+}
diff --git a/pkg/bydbql/plan_measure_pushdown.go b/pkg/bydbql/plan_measure_pushdown.go
new file mode 100644
index 000000000..a72304300
--- /dev/null
+++ b/pkg/bydbql/plan_measure_pushdown.go
@@ -0,0 +1,219 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package bydbql
+
+import (
+	"math"
+
+	"google.golang.org/protobuf/proto"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func (m *MeasureTemplate) compileNativeAggregate(schemas map[string]*databasev1.Measure, columns *measureColumns) {
+	if len(m.request.Groups) != 1 {
+		return
+	}
+	schema := schemas[m.request.Groups[0]]
+	if schema == nil || schema.GetIndexMode() || len(schema.GetEntity().GetTagNames()) == 0 {
+		return
+	}
+	names := make(map[string]int)
+	for _, source := range columns.ordered {
+		if source.Role == vectorized.RoleTag {
+			names[source.Name]++
+		}
+	}
+	var entities []vectorized.ColumnDef
+	for _, source := range columns.ordered {
+		if source.Entity {
+			// Native entity predicates have unqualified names. Ambiguous names
+			// cannot establish a unique non-null entity assignment.
+			if names[source.Name] != 1 {
+				return
+			}
+			entities = append(entities, source)
+		}
+	}
+	if len(entities) == len(schema.GetEntity().GetTagNames()) {
+		m.nativeEntities = entities
+	}
+}
+
+func (m *MeasureTemplate) bindNativeAggregate(request *measurev1.QueryRequest, plan *ql.RawPlan) *ql.NativeAggregatePlan {
+	if plan.Aggregate == nil || plan.Empty || plan.Residual || len(plan.Ranges) != 1 || len(m.nativeEntities) == 0 {
+		return nil
+	}
+	// Normal writes are millisecond-aligned and native scans deduplicate
+	// (complete entity, timestamp). Each group below identifies at most one
+	// entity, bounding even native float COUNT by timestamp positions.
+	interval := plan.Ranges[0]
+	if interval.End <= interval.Begin {
+		return nil
+	}
+	countBound := uint64(interval.End) - uint64(interval.Begin)
+	if countBound > 1<<53 {
+		return nil
+	}
+	grouped := make(map[measureColumnKey]bool)
+	var family string
+	for index, key := range plan.Aggregate.GroupBy {
+		if key.Role != vectorized.RoleTag || !key.Entity || (index > 0 && family != key.TagFamily) {
+			return nil
+		}
+		family = key.TagFamily
+		proved, _ := exactEntityConstraint(request.Criteria, key.Name)
+		if !proved {
+			return nil
+		}
+		grouped[columnIdentity(key)] = true
+	}
+	for _, entity := range m.nativeEntities {
+		if !grouped[columnIdentity(entity)] {
+			proved, single := exactEntityConstraint(request.Criteria, entity.Name)
+			if !proved || !single {
+				return nil
+			}
+		}
+	}
+	native := &ql.NativeAggregatePlan{Aggregate: plan.Aggregate, MaxCount: int64(countBound)}
+	seen := make(map[ql.AggregateCall]bool)
+	add := func(source vectorized.ColumnDef, function ql.AggregateFunction) {
+		call := ql.AggregateCall{Source: source, Function: function}
+		if seen[call] {
+			return
+		}
+		seen[call] = true
+		native.Leaves = append(native.Leaves, ql.NativeAggregateLeaf{
+			Source: source, Function: function, Request: nativeAggregateRequest(request, plan.Aggregate.GroupBy, call),
+		})
+	}
+	for _, call := range plan.Aggregate.Calls {
+		if call.Source.Role != vectorized.RoleField {
+			return nil
+		}
+		switch call.Function {
+		case ql.AggregateCount:
+			add(call.Source, ql.AggregateCount)
+		case ql.AggregateSum, ql.AggregateAvg:
+			// Native integer SUM wraps; SQL SUM checks overflow and AVG uses
+			// a float accumulator. A one-position group cannot overflow.
+			if call.Source.Type == vectorized.ColumnTypeInt64 && countBound > 1 {
+				return nil
+			}
+			add(call.Source, ql.AggregateSum)
+			add(call.Source, ql.AggregateCount)
+		case ql.AggregateMin, ql.AggregateMax:
+			// Native floating extrema use finite sentinels and ignore NaNs.
+			if call.Source.Type != vectorized.ColumnTypeInt64 {
+				return nil
+			}
+			add(call.Source, call.Function)
+			add(call.Source, ql.AggregateCount)
+		default:
+			return nil
+		}
+	}
+	if len(native.Leaves) > 32 {
+		return nil
+	}
+	return native
+}
+
+// exactEntityConstraint proves a non-null entity value set, and optionally a
+// single assignment. Native entity encodings distinguish null from zero/empty.
+// AND can only narrow a proven set; OR is left to the complete local fallback.
+func exactEntityConstraint(criteria *modelv1.Criteria, name string) (proved, single bool) {
+	if criteria == nil {
+		return false, false
+	}
+	if logical := criteria.GetLe(); logical != nil {
+		if logical.Op != modelv1.LogicalExpression_LOGICAL_OP_AND {
+			return false, false
+		}
+		left, leftSingle := exactEntityConstraint(logical.Left, name)
+		right, rightSingle := exactEntityConstraint(logical.Right, name)
+		return left || right, left && leftSingle || right && rightSingle
+	}
+	condition := criteria.GetCondition()
+	if condition == nil || condition.Name != name || condition.Value == nil {
+		return false, false
+	}
+	switch condition.Op {
+	case modelv1.Condition_BINARY_OP_EQ:
+		switch value := condition.Value.Value.(type) {
+		case *modelv1.TagValue_Str:
+			return value.Str != nil, value.Str != nil
+		case *modelv1.TagValue_Int:
+			return value.Int != nil, value.Int != nil
+		default:
+			return false, false
+		}
+	case modelv1.Condition_BINARY_OP_IN:
+		switch value := condition.Value.Value.(type) {
+		case *modelv1.TagValue_StrArray:
+			if value.StrArray != nil && len(value.StrArray.Value) > 0 {
+				return true, len(value.StrArray.Value) == 1
+			}
+		case *modelv1.TagValue_IntArray:
+			if value.IntArray != nil && len(value.IntArray.Value) > 0 {
+				return true, len(value.IntArray.Value) == 1
+			}
+		default:
+		}
+	default:
+	}
+	return false, false
+}
+
+func nativeAggregateRequest(request *measurev1.QueryRequest, keys []vectorized.ColumnDef, call ql.AggregateCall) *measurev1.QueryRequest {
+	leaf := proto.Clone(request).(*measurev1.QueryRequest)
+	leaf.Offset, leaf.Limit = 0, math.MaxUint32
+	leaf.OrderBy, leaf.Top = nil, nil
+	var slots []ql.Slot
+	for _, key := range keys {
+		slots = append(slots, ql.Slot{Source: key})
+	}
+	if len(slots) > 0 {
+		projection, _ := rawScanProjection(slots)
+		leaf.GroupBy = &measurev1.QueryRequest_GroupBy{TagProjection: projection}
+	}
+	slots = append(slots, ql.Slot{Source: call.Source})
+	leaf.TagProjection, leaf.FieldProjection = rawScanProjection(slots)
+	function := modelv1.AggregationFunction_AGGREGATION_FUNCTION_COUNT
+	switch call.Function {
+	case ql.AggregateSum:
+		function = modelv1.AggregationFunction_AGGREGATION_FUNCTION_SUM
+	case ql.AggregateMin:
+		function = modelv1.AggregationFunction_AGGREGATION_FUNCTION_MIN
+	case ql.AggregateMax:
+		function = modelv1.AggregationFunction_AGGREGATION_FUNCTION_MAX
+	default:
+	}
+	leaf.Agg = &measurev1.QueryRequest_Aggregation{Function: function}
+	if call.Source.Role == vectorized.RoleField {
+		leaf.Agg.FieldName = call.Source.Name
+	} else {
+		leaf.Agg.TagFamily, leaf.Agg.TagName = call.Source.TagFamily, call.Source.Name
+	}
+	return leaf
+}
diff --git a/pkg/bydbql/plan_measure_pushdown_test.go b/pkg/bydbql/plan_measure_pushdown_test.go
new file mode 100644
index 000000000..c059a3f1a
--- /dev/null
+++ b/pkg/bydbql/plan_measure_pushdown_test.go
@@ -0,0 +1,151 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package bydbql_test
+
+import (
+	"context"
+	"math"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/bydbql"
+	"github.com/apache/skywalking-banyandb/pkg/query/ql"
+)
+
+func nativeAggregateSchema() *databasev1.Measure {
+	return &databasev1.Measure{
+		Metadata: &commonv1.Metadata{Name: "m", Group: "g"}, Entity: &databasev1.Entity{TagNames: []string{"host"}},
+		TagFamilies: []*databasev1.TagFamilySpec{{Name: "default", Tags: []*databasev1.TagSpec{
+			{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+			{Name: "zone", Type: databasev1.TagType_TAG_TYPE_STRING},
+		}}},
+		Fields: []*databasev1.FieldSpec{
+			{Name: "v", FieldType: databasev1.FieldType_FIELD_TYPE_INT},
+			{Name: "f", FieldType: databasev1.FieldType_FIELD_TYPE_FLOAT},
+		},
+	}
+}
+
+func nativeAggregateExecution(t *testing.T, query string, schema *databasev1.Measure) *bydbql.MeasureExecution {
+	t.Helper()
+	statement, err := bydbql.Prepare(query)
+	require.NoError(t, err)
+	template, err := bydbql.NewTransformer(nil).CompileMeasureWithSchemas(context.Background(), statement,
+		map[bydbql.MeasureSource]*databasev1.Measure{{Group: "g", Name: "m"}: schema})
+	require.NoError(t, err)
+	execution, err := template.BindParams(nil, time.UnixMilli(0))
+	require.NoError(t, err)
+	return execution
+}
+
+func TestMeasureNativeAggregationSharesSumCountAverage(t *testing.T) {
+	execution := nativeAggregateExecution(t, "SELECT host, SUM(f) AS s, COUNT(f) AS c, AVG(f) AS a, AVG(f) AS again "+
+		"FROM g.m WHERE host IN ('a','b') AND time >= '1970-01-01T00:00:00Z' AND time < '1970-01-01T00:00:06Z' GROUP BY host LIMIT 1 OFFSET 2",
+		nativeAggregateSchema())
+	plan := execution.Plan.NativeAggregate
+	require.NotNil(t, plan)
+	require.Len(t, plan.Leaves, 2)
+	require.EqualValues(t, 6000, plan.MaxCount)
+	require.Equal(t, ql.AggregateSum, plan.Leaves[0].Function)
+	require.Equal(t, ql.AggregateCount, plan.Leaves[1].Function)
+	for _, leaf := range plan.Leaves {
+		require.NotNil(t, leaf.Request.Agg)
+		require.NotEqual(t, modelv1.AggregationFunction_AGGREGATION_FUNCTION_MEAN, leaf.Request.Agg.Function)
+		require.Equal(t, "f", leaf.Request.Agg.FieldName)
+		require.Equal(t, uint32(math.MaxUint32), leaf.Request.Limit)
+		require.Zero(t, leaf.Request.Offset)
+		require.Equal(t, []string{"host"}, leaf.Request.GroupBy.TagProjection.TagFamilies[0].Tags)
+	}
+	require.Nil(t, execution.Request.Agg)
+	require.Equal(t, uint32(1), *execution.Plan.Limit)
+	require.Equal(t, uint32(2), execution.Plan.Offset)
+}
+
+func TestMeasureNativeAggregationKeepsUnsafeShapesLocal(t *testing.T) {
+	for _, test := range []struct {
+		name, projection, predicate, groups string
+	}{
+		{"unchecked_int_sum", "SUM(v) AS n", "host = 'a'", " GROUP BY host"},
+		{"int_average_uses_float_sum", "AVG(v) AS n", "host = 'a'", " GROUP BY host"},
+		{"float_min_infinity", "MIN(f) AS n", "host = 'a'", " GROUP BY host"},
+		{"float_max_nan", "MAX(f) AS n", "host = 'a'", " GROUP BY host"},
+		{"unpruned_nullable_key", "AVG(f) AS n", "", " GROUP BY host"},
+		{"multiple_scalar_entities", "AVG(f) AS n", "host IN ('a','b')", ""},
+		{"nonentity_group", "AVG(f) AS n", "host = 'a' AND zone = 'x'", " GROUP BY host, zone"},
+		{"field_group", "COUNT(f) AS n", "host = 'a'", " GROUP BY host, v::FIELD"},
+		{"tag_count", "COUNT(host) AS n", "host = 'a'", " GROUP BY host"},
+		{"null_key", "COUNT(f) AS n", "host = NULL", " GROUP BY host"},
+		{"residual_time", "AVG(f) AS n", "host = 'a' AND (time = '1970-01-01T00:00:01Z' OR time = '1970-01-01T00:00:03Z')", " GROUP BY host"},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			query := "SELECT " + test.projection + " FROM g.m"
+			if test.predicate != "" {
+				query += " WHERE " + test.predicate
+			}
+			execution := nativeAggregateExecution(t, query+test.groups, nativeAggregateSchema())
+			require.Nil(t, execution.Plan.NativeAggregate)
+			require.NotNil(t, execution.Plan.Aggregate)
+			require.Nil(t, execution.Request.Agg)
+		})
+	}
+	indexed := nativeAggregateSchema()
+	indexed.IndexMode = true
+	execution := nativeAggregateExecution(t, "SELECT AVG(f) AS n FROM g.m WHERE host = 'a' GROUP BY host", indexed)
+	require.Nil(t, execution.Plan.NativeAggregate)
+}
+
+func TestMeasureNativeAggregationProvesIntegerOnePositionAndScalarEntity(t *testing.T) {
+	for _, function := range []string{"SUM", "AVG"} {
+		execution := nativeAggregateExecution(t, "SELECT "+function+"(v) AS n FROM g.m WHERE host = 'a' "+
+			"AND time = '1970-01-01T00:00:01Z'", nativeAggregateSchema())
+		require.NotNil(t, execution.Plan.NativeAggregate)
+		require.Len(t, execution.Plan.NativeAggregate.Leaves, 2)
+		require.EqualValues(t, 1, execution.Plan.NativeAggregate.MaxCount)
+		require.Nil(t, execution.Plan.NativeAggregate.Leaves[0].Request.GroupBy)
+	}
+	count := nativeAggregateExecution(t, "SELECT COUNT(f) AS n FROM g.m WHERE host = '' GROUP BY host", nativeAggregateSchema())
+	require.NotNil(t, count.Plan.NativeAggregate)
+	require.Greater(t, count.Plan.NativeAggregate.MaxCount, int64(0))
+	require.LessOrEqual(t, count.Plan.NativeAggregate.MaxCount, int64(1<<53))
+}
+
+func TestMeasureNativeAggregationBindsIndependentCachedRecipes(t *testing.T) {
+	statement, err := bydbql.Prepare("SELECT AVG(f) AS n FROM g.m WHERE host = ? GROUP BY host")
+	require.NoError(t, err)
+	template, err := bydbql.NewTransformer(nil).CompileMeasureWithSchemas(context.Background(), statement,
+		map[bydbql.MeasureSource]*databasev1.Measure{{Group: "g", Name: "m"}: nativeAggregateSchema()})
+	require.NoError(t, err)
+	bytes := template.EstimatedSize()
+	for _, host := range []string{"a", "b", "c"} {
+		execution, bindErr := template.BindParams([]*modelv1.TagValue{{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: host}}}}, time.UnixMilli(0))
+		require.NoError(t, bindErr)
+		native := execution.Plan.NativeAggregate
+		require.NotNil(t, native)
+		require.Equal(t, host, native.Leaves[0].Request.Criteria.GetCondition().Value.GetStr().Value)
+		native.Leaves[0].Request.Criteria.GetCondition().Value.GetStr().Value = "poison"
+		native.Aggregate.Calls[0].Source.Name = "poison"
+		require.Equal(t, host, execution.Request.Criteria.GetCondition().Value.GetStr().Value)
+		require.Equal(t, host, native.Leaves[1].Request.Criteria.GetCondition().Value.GetStr().Value)
+	}
+	require.Equal(t, bytes, template.EstimatedSize())
+}
diff --git a/pkg/bydbql/plan_measure_test.go b/pkg/bydbql/plan_measure_test.go
index 26eac3dbb..73d77fb6b 100644
--- a/pkg/bydbql/plan_measure_test.go
+++ b/pkg/bydbql/plan_measure_test.go
@@ -176,7 +176,7 @@ func TestTypedMeasureNamesFailBeforeExecution(t *testing.T) {
 		"SELECT missing FROM g.m",
 		"SELECT host AS node FROM g.m WHERE node = 'a'",
 		"SELECT host FROM g.m WHERE time >= now() OR host = 'a'",
-		"SELECT SUM(v) AS total FROM MEASURE m IN g",
+		"SELECT host, SUM(v) AS total FROM MEASURE m IN g",
 		"SELECT host FROM g.m LIMIT -1",
 		"SELECT host FROM g.m WHERE time = 1",
 		"SELECT host FROM g.m WHERE time = '2500-01-01T00:00:00Z'",
diff --git a/pkg/query/ql/aggregate.go b/pkg/query/ql/aggregate.go
new file mode 100644
index 000000000..8e49bf953
--- /dev/null
+++ b/pkg/query/ql/aggregate.go
@@ -0,0 +1,405 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"fmt"
+	"math"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+// AggregateFunction identifies an aggregate over one named scalar column.
+type AggregateFunction uint8
+
+// Aggregate functions use SQL non-null counting and empty-input semantics.
+const (
+	AggregateCount AggregateFunction = iota
+	AggregateSum
+	AggregateMin
+	AggregateMax
+	AggregateAvg
+)
+
+// AggregateCall describes one aggregate input independently of output aliases.
+type AggregateCall struct {
+	Source   vectorized.ColumnDef
+	Function AggregateFunction
+}
+
+// AggregateOutput names one selected grouping key or aggregate call.
+type AggregateOutput struct {
+	Column  vectorized.ColumnDef
+	Index   int
+	IsGroup bool
+}
+
+// AggregatePlan is an immutable recipe for complete local aggregation.
+type AggregatePlan struct {
+	GroupBy []vectorized.ColumnDef
+	Calls   []AggregateCall
+	Outputs []AggregateOutput
+}
+
+// AggregateStats separates retained group keys and fixed-width state from native input residency.
+type AggregateStats struct {
+	InputRows     uint64
+	Groups        uint64
+	GroupCapacity uint64
+	KeyBytes      uint64
+	KeyCapacity   uint64
+	StateBytes    uint64 // Used fixed-width state bytes for actual groups.
+	StateCapacity uint64 // Reserved fixed-width state capacity in bytes.
+	StateLanes    uint64
+}
+
+type aggregateCallRef struct {
+	state    int
+	function AggregateFunction
+}
+
+type aggregateOperator struct {
+	source           vectorized.PullOperator
+	tracker          *vectorized.MemoryTracker
+	schema           *vectorized.BatchSchema
+	output           *vectorized.RecordBatch
+	states           []aggregateState
+	calls            []aggregateCallRef
+	outputs          []AggregateOutput
+	groupValues      []aggregateValue
+	groupReaders     []aggregateReader
+	stateReaders     []aggregateReader
+	groups           aggregateGroups
+	stats            AggregateStats
+	metadataBytes    int64
+	outputBytes      int64
+	outputValueBytes int64
+	position         int
+	complete         bool
+	closed           bool
+	sourceClosed     bool
+}
+
+// WrapAggregate takes source ownership, including on construction failure.
+// It consumes every active input row before emitting synthesized groups, without native identity metadata.
+func WrapAggregate(ctx context.Context, source vectorized.PullOperator, plan *AggregatePlan) (_ vectorized.PullOperator, err error) {
+	op := &aggregateOperator{source: source}
+	defer func() {
+		if err != nil {
+			_ = op.Close()
+		}
+	}()
+	if err = ctx.Err(); err != nil {
+		return nil, err
+	}
+	if source == nil || source.OutputSchema() == nil || plan == nil || len(plan.Outputs) == 0 {
+		return nil, fmt.Errorf("aggregate requires a source schema, plan and output columns")
+	}
+	bytes, boundErr := rawDescriptorBound(len(plan.GroupBy)+len(plan.Calls)+len(plan.Outputs), 0, 0)
+	if boundErr != nil {
+		return nil, boundErr
+	}
+	op.tracker = vectorized.QueryMemoryTracker(ctx, 256<<20)
+	if err = op.reserve(bytes); err != nil {
+		return nil, err
+	}
+	op.metadataBytes = bytes
+	op.groups = aggregateGroups{tracker: op.tracker}
+	op.groupValues = make([]aggregateValue, len(plan.GroupBy))
+	op.groupReaders = make([]aggregateReader, len(plan.GroupBy))
+	for index, def := range plan.GroupBy {
+		if !aggregateScalarType(def.Type) {
+			return nil, fmt.Errorf("aggregate grouping column %q has unsupported type %s", def.Name, def.Type)
+		}
+		reader, readerErr := newAggregateReader(source.OutputSchema(), def)
+		if readerErr != nil {
+			return nil, readerErr
+		}
+		op.groupReaders[index] = reader
+	}
+	if err = op.compileCalls(source.OutputSchema(), plan.Calls); err != nil {
+		return nil, err
+	}
+	defs := make([]vectorized.ColumnDef, len(plan.Outputs))
+	op.outputs = append([]AggregateOutput(nil), plan.Outputs...)
+	for index, output := range op.outputs {
+		if output.Column.Role != vectorized.RoleTag && output.Column.Role != vectorized.RoleField {
+			return nil, fmt.Errorf("aggregate output %q must be a tag or field", output.Column.Name)
+		}
+		var expected vectorized.ColumnType
+		if output.IsGroup {
+			if output.Index < 0 || output.Index >= len(plan.GroupBy) {
+				return nil, fmt.Errorf("aggregate group output index %d is absent", output.Index)
+			}
+			expected = plan.GroupBy[output.Index].Type
+		} else {
+			if output.Index < 0 || output.Index >= len(plan.Calls) {
+				return nil, fmt.Errorf("aggregate call output index %d is absent", output.Index)
+			}
+			expected = aggregateOutputType(plan.Calls[output.Index])
+		}
+		if output.Column.Type != expected {
+			return nil, fmt.Errorf("aggregate output %q has type %s; expected %s", output.Column.Name, output.Column.Type, expected)
+		}
+		defs[index] = output.Column
+		defs[index].Entity = false
+	}
+	op.schema = vectorized.NewBatchSchema(defs)
+	return op, nil
+}
+
+func (o *aggregateOperator) compileCalls(schema *vectorized.BatchSchema, calls []AggregateCall) error {
+	o.calls = make([]aggregateCallRef, len(calls))
+	bySource := make(map[vectorized.ColumnDef]int)
+	for index, call := range calls {
+		if call.Function > AggregateAvg || !aggregateScalarType(call.Source.Type) {
+			return fmt.Errorf("unsupported aggregate function or source type for %q", call.Source.Name)
+		}
+		if call.Function != AggregateCount && call.Source.Type != vectorized.ColumnTypeInt64 && call.Source.Type != vectorized.ColumnTypeFloat64 {
+			return fmt.Errorf("aggregate on %q requires a numeric column", call.Source.Name)
+		}
+		def := call.Source
+		def.Entity = false
+		state, exists := bySource[def]
+		if !exists {
+			reader, err := newAggregateReader(schema, def)
+			if err != nil {
+				return err
+			}
+			state = len(o.states)
+			bySource[def] = state
+			o.states = append(o.states, aggregateState{source: def})
+			o.stateReaders = append(o.stateReaders, reader)
+		}
+		o.states[state].require(call.Function)
+		o.calls[index] = aggregateCallRef{state: state, function: call.Function}
+	}
+	return nil
+}
+
+func aggregateOutputType(call AggregateCall) vectorized.ColumnType {
+	switch call.Function {
+	case AggregateCount:
+		return vectorized.ColumnTypeInt64
+	case AggregateAvg:
+		return vectorized.ColumnTypeFloat64
+	default:
+		return call.Source.Type
+	}
+}
+
+// AggregateStatistics returns the structural state of a local aggregate, including after Close.
+func AggregateStatistics(source vectorized.PullOperator) (AggregateStats, bool) {
+	op, ok := source.(*aggregateOperator)
+	if !ok {
+		if raw, rawOK := source.(*rawOperator); rawOK {
+			return AggregateStatistics(raw.source)
+		}
+		return AggregateStats{}, false
+	}
+	return op.statistics(), true
+}
+
+func (o *aggregateOperator) statistics() AggregateStats {
+	stats := o.stats
+	if o.closed {
+		return stats
+	}
+	stats.Groups = uint64(len(o.groups.keys))
+	stats.GroupCapacity = uint64(o.groups.capacity)
+	stats.KeyBytes = uint64(o.groups.keyBytes)
+	stats.KeyCapacity = uint64(o.groups.tableBytes + o.groups.keyReserved + o.groups.scratchBytes)
+	for index := range o.states {
+		stats.StateLanes += uint64(o.states[index].lanes())
+	}
+	stats.StateBytes = stats.Groups * stats.StateLanes * 8
+	stats.StateCapacity = stats.GroupCapacity * stats.StateLanes * 8
+	return stats
+}
+
+func (o *aggregateOperator) OutputSchema() *vectorized.BatchSchema { return o.schema }
+
+func (o *aggregateOperator) Init(ctx context.Context) error {
+	if err := ctx.Err(); err != nil {
+		_ = o.Close()
+		return err
+	}
+	if o.closed {
+		return fmt.Errorf("aggregate is closed")
+	}
+	if err := o.source.Init(ctx); err != nil {
+		_ = o.Close()
+		return err
+	}
+	return nil
+}
+
+func (o *aggregateOperator) closeSource() error {
+	if o.sourceClosed || o.source == nil {
+		return nil
+	}
+	o.sourceClosed = true
+	return o.source.Close()
+}
+
+func (o *aggregateOperator) Close() error {
+	if o.closed {
+		return nil
+	}
+	o.stats = o.statistics()
+	o.closed = true
+	o.groups.close()
+	for index := range o.states {
+		o.states[index] = aggregateState{}
+	}
+	o.states = nil
+	o.groupValues, o.groupReaders, o.stateReaders = nil, nil, nil
+	o.output, o.schema, o.outputs, o.calls = nil, nil, nil, nil
+	o.release(o.metadataBytes + o.outputBytes + o.outputValueBytes + int64(o.stats.StateCapacity))
+	o.metadataBytes, o.outputBytes, o.outputValueBytes = 0, 0, 0
+	return o.closeSource()
+}
+
+func (o *aggregateOperator) reserve(bytes int64) error {
+	if err := o.tracker.Reserve(bytes); err != nil {
+		return err
+	}
+	recordAggregateReserve(bytes)
+	return nil
+}
+
+func (o *aggregateOperator) release(bytes int64) {
+	if bytes != 0 && o.tracker != nil {
+		o.tracker.Release(bytes)
+		recordAggregateRelease(bytes)
+	}
+}
+
+func (o *aggregateOperator) NextBatch(ctx context.Context) (_ *vectorized.RecordBatch, err error) {
+	defer func() {
+		if err != nil {
+			_ = o.Close()
+		}
+	}()
+	if err = ctx.Err(); err != nil {
+		return nil, err
+	}
+	if o.closed {
+		return nil, nil
+	}
+	if !o.complete {
+		if err = o.consumeAll(ctx); err != nil {
+			return nil, err
+		}
+	}
+	if o.position >= len(o.groups.keys) {
+		return nil, nil
+	}
+	return o.emit(ctx)
+}
+
+func (o *aggregateOperator) consumeAll(ctx context.Context) error {
+	if len(o.groupReaders) == 0 {
+		if _, err := o.groupID(nil); err != nil {
+			return err
+		}
+	}
+	for {
+		if err := ctx.Err(); err != nil {
+			return err
+		}
+		batch, err := o.source.NextBatch(ctx)
+		if err != nil {
+			return err
+		}
+		if batch == nil {
+			break
+		}
+		if batch.Len > math.MaxUint16+1 || batch.Len < 0 {
+			return fmt.Errorf("aggregate source batch has invalid row count %d", batch.Len)
+		}
+		if err = o.consumeBatch(ctx, batch); err != nil {
+			return err
+		}
+	}
+	// Source columns can borrow native decode buffers. Only encoded group keys
+	// and fixed-width state survive the scan; discard the readers before egress.
+	clear(o.groupValues)
+	for index := range o.groupReaders {
+		o.groupReaders[index].column = nil
+	}
+	for index := range o.stateReaders {
+		o.stateReaders[index].column = nil
+	}
+	if err := o.closeSource(); err != nil {
+		return err
+	}
+	o.complete = true
+	recordAggregateExecution(o.statistics())
+	return nil
+}
+
+func (o *aggregateOperator) consumeBatch(ctx context.Context, batch *vectorized.RecordBatch) error {
+	for index := range o.groupReaders {
+		if err := o.groupReaders[index].bind(batch); err != nil {
+			return err
+		}
+	}
+	for index := range o.stateReaders {
+		if err := o.stateReaders[index].bind(batch); err != nil {
+			return err
+		}
+	}
+	for position := 0; position < batch.ActiveLen(); position++ {
+		if position%1024 == 0 {
+			if err := ctx.Err(); err != nil {
+				return err
+			}
+		}
+		row := position
+		if batch.Selection != nil {
+			row = int(batch.Selection[position])
+		}
+		if row >= batch.Len {
+			return fmt.Errorf("aggregate selection row %d exceeds batch length %d", row, batch.Len)
+		}
+		for index := range o.groupReaders {
+			value, err := o.groupReaders[index].value(row)
+			if err != nil {
+				return err
+			}
+			o.groupValues[index] = value
+		}
+		group, err := o.groupID(o.groupValues)
+		if err != nil {
+			return err
+		}
+		for index := range o.states {
+			value, valueErr := o.stateReaders[index].value(row)
+			if valueErr != nil {
+				return valueErr
+			}
+			if updateErr := o.states[index].update(group, value); updateErr != nil {
+				return updateErr
+			}
+		}
+		o.stats.InputRows++
+	}
+	return nil
+}
diff --git a/pkg/query/ql/aggregate_benchmark_test.go b/pkg/query/ql/aggregate_benchmark_test.go
new file mode 100644
index 000000000..d51794434
--- /dev/null
+++ b/pkg/query/ql/aggregate_benchmark_test.go
@@ -0,0 +1,292 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+	"testing"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+const aggregateKernelRows = 65537
+
+type aggregateKernelInput struct {
+	key   int64
+	value float64
+	null  bool
+}
+
+type aggregateKernelCase struct {
+	name      string
+	functions []AggregateFunction
+}
+
+var aggregateKernelSink float64
+
+// BenchmarkAggregateKernel compares the real batch operator with an independent
+// scalar map over the same nullable rows, including the uint16 batch boundary.
+func BenchmarkAggregateKernel(b *testing.B) {
+	for _, groups := range []int{1, 1024, aggregateKernelRows} {
+		inputs, batches, defs := aggregateKernelFixture(aggregateKernelRows, groups)
+		cases := []aggregateKernelCase{
+			{name: "sum", functions: []AggregateFunction{AggregateSum}},
+			{name: "multiple", functions: []AggregateFunction{AggregateCount, AggregateSum, AggregateMin, AggregateMax}},
+			{name: "avg", functions: []AggregateFunction{AggregateAvg}},
+			{name: "sum-count-avg", functions: []AggregateFunction{AggregateSum, AggregateCount, AggregateAvg}},
+		}
+		for _, testCase := range cases {
+			benchmarkAggregateKernelCase(b, inputs, batches, defs, testCase, groups)
+		}
+	}
+}
+
+// BenchmarkAggregateMillionGroups verifies the local operator at one million
+// typed rows and groups. It does not exercise native RPC framing or admission.
+func BenchmarkAggregateMillionGroups(b *testing.B) {
+	const rows = 1_000_000
+	inputs, batches, defs := aggregateKernelFixture(rows, rows)
+	for _, testCase := range []aggregateKernelCase{
+		{name: "avg", functions: []AggregateFunction{AggregateAvg}},
+		{name: "sum-count-avg", functions: []AggregateFunction{AggregateSum, AggregateCount, AggregateAvg}},
+	} {
+		benchmarkAggregateKernelCase(b, inputs, batches, defs, testCase, rows)
+	}
+}
+
+func benchmarkAggregateKernelCase(b *testing.B, inputs []aggregateKernelInput, batches []*vectorized.RecordBatch,
+	defs []vectorized.ColumnDef, testCase aggregateKernelCase, groups int,
+) {
+	const budget = 128 << 20
+	plan := aggregateKernelPlan(testCase, defs)
+	b.Run(fmt.Sprintf("groups=%d/%s/dense", groups, testCase.name), func(b *testing.B) {
+		stats := validateAggregateKernel(b, inputs, batches, plan, testCase.functions, budget)
+		// Exclude reclamation of the independent validation maps from measured execution.
+		runtime.GC()
+		b.ReportAllocs()
+		b.SetBytes(int64(len(inputs)) * 16)
+		b.ResetTimer()
+		for iteration := 0; iteration < b.N; iteration++ {
+			source := &rawSource{schema: batches[0].Schema, batches: append([]*vectorized.RecordBatch(nil), batches...)}
+			ctx := vectorized.WithQueryMemoryTracker(context.Background(), budget)
+			op, err := WrapAggregate(ctx, source, plan)
+			if err != nil {
+				b.Fatal(err)
+			}
+			if err = op.Init(ctx); err != nil {
+				b.Fatal(err)
+			}
+			for {
+				batch, pullErr := op.NextBatch(ctx)
+				if pullErr != nil {
+					b.Fatal(pullErr)
+				}
+				if batch == nil {
+					break
+				}
+				aggregateKernelSink += float64(batch.Len)
+			}
+			if err = op.Close(); err != nil {
+				b.Fatal(err)
+			}
+		}
+		b.ReportMetric(float64(stats.InputRows), "input-rows/op")
+		b.ReportMetric(float64(stats.Groups), "groups/op")
+		b.ReportMetric(float64(stats.StateBytes), "state-B/op")
+		b.ReportMetric(float64(stats.StateCapacity), "state-cap-B/op")
+		b.ReportMetric(float64(stats.KeyCapacity), "key-B/op")
+		b.ReportMetric(float64(stats.StateLanes), "lanes/op")
+		b.ReportMetric(budget, "budget-B")
+	})
+	b.Run(fmt.Sprintf("groups=%d/%s/scalar", groups, testCase.name), func(b *testing.B) {
+		b.ReportAllocs()
+		b.SetBytes(int64(len(inputs)) * 16)
+		for iteration := 0; iteration < b.N; iteration++ {
+			result := aggregateKernelReference(inputs, testCase.functions)
+			aggregateKernelSink += float64(len(result))
+		}
+	})
+}
+
+func validateAggregateKernel(b *testing.B, inputs []aggregateKernelInput, batches []*vectorized.RecordBatch,
+	plan *AggregatePlan, functions []AggregateFunction, budget int64,
+) AggregateStats {
+	b.Helper()
+	expected := aggregateKernelReference(inputs, functions)
+	expectedGroups := uint64(len(expected))
+	source := &rawSource{schema: batches[0].Schema, batches: append([]*vectorized.RecordBatch(nil), batches...)}
+	ctx := vectorized.WithQueryMemoryTracker(context.Background(), budget)
+	op, err := WrapAggregate(ctx, source, plan)
+	if err != nil {
+		b.Fatal(err)
+	}
+	defer func() {
+		if closeErr := op.Close(); closeErr != nil {
+			b.Fatal(closeErr)
+		}
+	}()
+	if err = op.Init(ctx); err != nil {
+		b.Fatal(err)
+	}
+	for {
+		batch, pullErr := op.NextBatch(ctx)
+		if pullErr != nil {
+			b.Fatal(pullErr)
+		}
+		if batch == nil {
+			break
+		}
+		keys := batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()
+		for row, key := range keys {
+			want, exists := expected[key]
+			if !exists {
+				b.Fatal("aggregate emitted an unexpected or repeated group")
+			}
+			for index, value := range want {
+				column := batch.Columns[index+1]
+				if column.IsNull(row) != value.null {
+					b.Fatal("aggregate null differs from scalar reference")
+				}
+				if value.null {
+					continue
+				}
+				if functions[index] == AggregateCount {
+					if float64(column.(*vectorized.TypedColumn[int64]).Data()[row]) != value.value {
+						b.Fatal("count differs from scalar reference")
+					}
+				} else if column.(*vectorized.TypedColumn[float64]).Data()[row] != value.value {
+					b.Fatal("aggregate differs from scalar reference")
+				}
+			}
+			delete(expected, key)
+		}
+	}
+	if len(expected) != 0 {
+		b.Fatal("aggregate omitted expected groups")
+	}
+	stats, ok := AggregateStatistics(op)
+	if !ok || stats.InputRows != uint64(len(inputs)) {
+		b.Fatal("aggregate did not consume the complete typed input")
+	}
+	if stats.Groups != expectedGroups {
+		b.Fatal("aggregate group-state cardinality differs from the independent reference")
+	}
+	if err = op.Close(); err != nil {
+		b.Fatal(err)
+	}
+	if vectorized.QueryMemoryTracker(ctx, 0).Used() != 0 {
+		b.Fatal("aggregate retained memory after Close")
+	}
+	return stats
+}
+
+func aggregateKernelFixture(rows, groups int) ([]aggregateKernelInput, []*vectorized.RecordBatch, []vectorized.ColumnDef) {
+	defs := []vectorized.ColumnDef{aggregateField("key", vectorized.ColumnTypeInt64), aggregateField("f", vectorized.ColumnTypeFloat64)}
+	schema := vectorized.NewBatchSchema(defs)
+	inputs := make([]aggregateKernelInput, rows)
+	var batches []*vectorized.RecordBatch
+	for begin := 0; begin < rows; begin += 65536 {
+		end := min(begin+65536, rows)
+		batch := vectorized.NewRecordBatch(schema, end-begin)
+		for row := begin; row < end; row++ {
+			input := aggregateKernelInput{key: int64(row % groups), value: float64(row%23-11) / 4, null: row%5 == 0}
+			inputs[row] = input
+			batch.Columns[0].(*vectorized.TypedColumn[int64]).Append(input.key)
+			if input.null {
+				batch.Columns[1].AppendNull()
+			} else {
+				batch.Columns[1].(*vectorized.TypedColumn[float64]).Append(input.value)
+			}
+		}
+		batch.Len = end - begin
+		batches = append(batches, batch)
+	}
+	return inputs, batches, defs
+}
+
+func aggregateKernelPlan(testCase aggregateKernelCase, defs []vectorized.ColumnDef) *AggregatePlan {
+	plan := &AggregatePlan{GroupBy: defs[:1], Outputs: []AggregateOutput{{Column: defs[0], IsGroup: true}}}
+	for index, function := range testCase.functions {
+		call := AggregateCall{Source: defs[1], Function: function}
+		plan.Calls = append(plan.Calls, call)
+		plan.Outputs = append(plan.Outputs, AggregateOutput{Column: aggregateField(fmt.Sprintf("output_%d", index), aggregateOutputType(call)), Index: index})
+	}
+	return plan
+}
+
+// The oracle deliberately uses ordinary Go values and a map keyed by the input
+// integer, without the production key codec, state arrays or finalization helper.
+func aggregateKernelReference(inputs []aggregateKernelInput, functions []AggregateFunction) map[int64][]aggregateKernelExpected {
+	type state struct {
+		count         int64
+		sum, min, max float64
+	}
+	states := make(map[int64]state)
+	var needsSum, needsMin, needsMax bool
+	for _, function := range functions {
+		needsSum = needsSum || function == AggregateSum || function == AggregateAvg
+		needsMin = needsMin || function == AggregateMin
+		needsMax = needsMax || function == AggregateMax
+	}
+	for _, input := range inputs {
+		current := states[input.key]
+		if !input.null {
+			if needsMin && (current.count == 0 || input.value < current.min) {
+				current.min = input.value
+			}
+			if needsMax && (current.count == 0 || input.value > current.max) {
+				current.max = input.value
+			}
+			current.count++
+			if needsSum {
+				current.sum += input.value
+			}
+		}
+		states[input.key] = current
+	}
+	results := make(map[int64][]aggregateKernelExpected, len(states))
+	for key, current := range states {
+		values := make([]aggregateKernelExpected, len(functions))
+		for index, function := range functions {
+			values[index].null = current.count == 0 && function != AggregateCount
+			switch function {
+			case AggregateCount:
+				values[index].value = float64(current.count)
+			case AggregateSum:
+				values[index].value = current.sum
+			case AggregateMin:
+				values[index].value = current.min
+			case AggregateMax:
+				values[index].value = current.max
+			case AggregateAvg:
+				if current.count != 0 {
+					values[index].value = current.sum / float64(current.count)
+				}
+			}
+		}
+		results[key] = values
+	}
+	return results
+}
+
+type aggregateKernelExpected struct {
+	value float64
+	null  bool
+}
diff --git a/pkg/query/ql/aggregate_group.go b/pkg/query/ql/aggregate_group.go
new file mode 100644
index 000000000..d88219922
--- /dev/null
+++ b/pkg/query/ql/aggregate_group.go
@@ -0,0 +1,269 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math"
+
+	"github.com/cespare/xxhash/v2"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+type aggregateGroups struct {
+	tracker      *vectorized.MemoryTracker
+	keys         []string
+	scratch      []byte
+	index        []uint32
+	capacity     int
+	tableBytes   int64
+	keyBytes     int64
+	keyReserved  int64
+	scratchBytes int64
+}
+
+// appendGroupValue is the common QL grouping codec. Each cell includes its logical
+// type and null marker; variable-width payloads include their length. A null has
+// no payload, signed zeros share +0, and all NaN representations share one key.
+func appendGroupValue(dst []byte, value aggregateValue) []byte {
+	dst = append(dst, byte(value.typ))
+	if value.null {
+		return append(dst, 0)
+	}
+	dst = append(dst, 1)
+	switch value.typ {
+	case vectorized.ColumnTypeInt64:
+		return binary.LittleEndian.AppendUint64(dst, uint64(value.i64))
+	case vectorized.ColumnTypeFloat64:
+		return binary.LittleEndian.AppendUint64(dst, canonicalGroupFloat(value.f64))
+	case vectorized.ColumnTypeString:
+		dst = binary.LittleEndian.AppendUint64(dst, uint64(len(value.text)))
+		return append(dst, value.text...)
+	case vectorized.ColumnTypeBytes:
+		dst = binary.LittleEndian.AppendUint64(dst, uint64(len(value.raw)))
+		return append(dst, value.raw...)
+	default:
+		panic("unsupported QL group key type")
+	}
+}
+
+func canonicalGroupFloat(value float64) uint64 {
+	if value == 0 {
+		return 0
+	}
+	if math.IsNaN(value) {
+		return 0x7ff8000000000000
+	}
+	return math.Float64bits(value)
+}
+
+func groupValueBytes(value aggregateValue) int64 {
+	if value.null {
+		return 2
+	}
+	switch value.typ {
+	case vectorized.ColumnTypeString:
+		return 10 + int64(len(value.text))
+	case vectorized.ColumnTypeBytes:
+		return 10 + int64(len(value.raw))
+	default:
+		return 10
+	}
+}
+
+func (g *aggregateGroups) encode(values []aggregateValue) error {
+	var bytes int64
+	for _, value := range values {
+		size := groupValueBytes(value)
+		if size > math.MaxInt64-bytes {
+			return fmt.Errorf("%w: aggregate key size overflows", vectorized.ErrMemoryBudgetExceeded)
+		}
+		bytes += size
+	}
+	if bytes > int64(cap(g.scratch)) {
+		capacity, capacityErr := groupAllocationBytes(max(int64(64), bytes))
+		if capacityErr != nil {
+			return capacityErr
+		}
+		if err := g.tracker.Reserve(capacity); err != nil {
+			return err
+		}
+		recordAggregateReserve(capacity)
+		g.scratch = make([]byte, 0, int(capacity))
+		g.tracker.Release(g.scratchBytes)
+		recordAggregateRelease(g.scratchBytes)
+		g.scratchBytes = capacity
+	}
+	g.scratch = g.scratch[:0]
+	for _, value := range values {
+		g.scratch = appendGroupValue(g.scratch, value)
+	}
+	return nil
+}
+
+func (o *aggregateOperator) groupID(values []aggregateValue) (int, error) {
+	g := &o.groups
+	if err := g.encode(values); err != nil {
+		return 0, err
+	}
+	hash := xxhash.Sum64(g.scratch)
+	id, slot, found := g.lookup(g.scratch, hash)
+	if found {
+		return id, nil
+	}
+	if len(g.keys) == g.capacity {
+		if err := o.growGroups(); err != nil {
+			return 0, err
+		}
+		_, slot, _ = g.lookup(g.scratch, hash)
+	}
+	keyBytes := int64(len(g.scratch))
+	reserved, allocationErr := groupAllocationBytes(keyBytes)
+	if allocationErr != nil {
+		return 0, allocationErr
+	}
+	if err := o.reserve(reserved); err != nil {
+		return 0, err
+	}
+	key := string(g.scratch)
+	id = len(g.keys)
+	g.keys = append(g.keys, key)
+	g.index[slot] = uint32(id) + 1
+	g.keyBytes += keyBytes
+	g.keyReserved += reserved
+	return id, nil
+}
+
+func (g *aggregateGroups) lookup(key []byte, hash uint64) (id, slot int, found bool) {
+	if len(g.index) == 0 {
+		return 0, 0, false
+	}
+	mask := uint64(len(g.index) - 1)
+	position := hash & mask
+	for {
+		entry := g.index[position]
+		if entry == 0 {
+			return 0, int(position), false
+		}
+		id = int(entry - 1)
+		if g.keys[id] == string(key) {
+			return id, int(position), true
+		}
+		position = (position + 1) & mask
+	}
+}
+
+func groupAllocationBytes(bytes int64) (int64, error) {
+	if bytes == 0 {
+		return 0, nil
+	}
+	// Rounding to a power of two bounds Go's small-object size classes and
+	// large-object page rounding without depending on runtime internals.
+	capacity := int64(8)
+	for capacity < bytes {
+		if capacity > math.MaxInt64/2 {
+			return 0, fmt.Errorf("%w: aggregate key allocation size overflows", vectorized.ErrMemoryBudgetExceeded)
+		}
+		capacity *= 2
+	}
+	return capacity, nil
+}
+
+func (o *aggregateOperator) growGroups() error {
+	g := &o.groups
+	if g.capacity > math.MaxUint32/2 || g.capacity > math.MaxInt/4 {
+		return fmt.Errorf("%w: aggregate group count exceeds supported index size", vectorized.ErrMemoryBudgetExceeded)
+	}
+	capacity := max(1, g.capacity*2)
+	var lanes int64
+	for index := range o.states {
+		lanes += int64(o.states[index].lanes())
+	}
+	// A 16-byte key header and two 4-byte index slots per possible group keep
+	// the open-addressed index at most half full. The old table and states stay
+	// charged until the replacement has been allocated and copied.
+	const tableSlotBytes int64 = 24
+	if int64(capacity) > math.MaxInt64/(tableSlotBytes+lanes*8) {
+		return fmt.Errorf("%w: aggregate group capacity overflows", vectorized.ErrMemoryBudgetExceeded)
+	}
+	newTableBytes := int64(capacity) * tableSlotBytes
+	newStateBytes := int64(capacity) * lanes * 8
+	if err := o.reserve(newTableBytes + newStateBytes); err != nil {
+		return err
+	}
+	keys := make([]string, len(g.keys), capacity)
+	copy(keys, g.keys)
+	index := make([]uint32, capacity*2)
+	mask := uint64(len(index) - 1)
+	for id, key := range keys {
+		position := xxhash.Sum64String(key) & mask
+		for index[position] != 0 {
+			position = (position + 1) & mask
+		}
+		index[position] = uint32(id) + 1
+	}
+	for index := range o.states {
+		o.states[index].grow(capacity)
+	}
+	o.release(g.tableBytes + int64(g.capacity)*lanes*8)
+	g.index, g.keys, g.capacity, g.tableBytes = index, keys, capacity, newTableBytes
+	return nil
+}
+
+func (g *aggregateGroups) close() {
+	bytes := g.tableBytes + g.keyReserved + g.scratchBytes
+	if bytes != 0 {
+		g.tracker.Release(bytes)
+		recordAggregateRelease(bytes)
+	}
+	g.index, g.keys, g.scratch = nil, nil, nil
+	g.capacity, g.tableBytes, g.keyBytes, g.keyReserved, g.scratchBytes = 0, 0, 0, 0, 0
+}
+
+func decodeGroupValues(key string, values []aggregateValue) {
+	position := 0
+	for index := range values {
+		value := aggregateValue{typ: vectorized.ColumnType(key[position]), null: key[position+1] == 0}
+		position += 2
+		if !value.null {
+			number := groupUint64(key[position : position+8])
+			position += 8
+			switch value.typ {
+			case vectorized.ColumnTypeInt64:
+				value.i64 = int64(number)
+			case vectorized.ColumnTypeFloat64:
+				value.f64 = math.Float64frombits(number)
+			case vectorized.ColumnTypeString, vectorized.ColumnTypeBytes:
+				// Byte output conversion happens only at egress; retaining this string
+				// keeps the key-owned bytes immutable until operator Close.
+				value.text = key[position : position+int(number)]
+				position += int(number)
+			default:
+				panic("unsupported encoded QL group type")
+			}
+		}
+		values[index] = value
+	}
+}
+
+func groupUint64(value string) uint64 {
+	return uint64(value[0]) | uint64(value[1])<<8 | uint64(value[2])<<16 | uint64(value[3])<<24 |
+		uint64(value[4])<<32 | uint64(value[5])<<40 | uint64(value[6])<<48 | uint64(value[7])<<56
+}
diff --git a/pkg/query/ql/aggregate_index_test.go b/pkg/query/ql/aggregate_index_test.go
new file mode 100644
index 000000000..244b50b12
--- /dev/null
+++ b/pkg/query/ql/aggregate_index_test.go
@@ -0,0 +1,103 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func TestAggregateCollidingKeysSurviveGrowthAndRepeatedLookup(t *testing.T) {
+	defs := []vectorized.ColumnDef{aggregateField("key", vectorized.ColumnTypeInt64), aggregateField("v", vectorized.ColumnTypeInt64)}
+	schema := vectorized.NewBatchSchema(defs)
+	batch := vectorized.NewRecordBatch(schema, 6)
+	// These logical integer keys collide in the first three low hash bits. They
+	// exercise equality probing before and after the group index grows.
+	batch.Columns[0].(*vectorized.TypedColumn[int64]).AppendSlice([]int64{0, 3, 4, 4, 0, 3})
+	batch.Columns[1] = vectorized.NewConstInt64(1, 6)
+	batch.Len = 6
+	source := &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	op, err := WrapAggregate(ctx, source, aggregateCountPlan(defs[:1], defs[1]))
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	out, err := op.NextBatch(ctx)
+	require.NoError(t, err)
+	require.Equal(t, []int64{0, 3, 4}, out.Columns[0].(*vectorized.TypedColumn[int64]).Data())
+	require.Equal(t, []int64{2, 2, 2}, out.Columns[1].(*vectorized.TypedColumn[int64]).Data())
+	require.NoError(t, op.Close())
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
+
+func TestAggregateHighCardinalityRespectsQueryMemoryLimits(t *testing.T) {
+	const rows = 262145
+	for _, budget := range []int64{16 << 20, 256 << 20} {
+		source, defs := aggregateUniqueKeysSource(rows)
+		ctx := vectorized.WithQueryMemoryTracker(t.Context(), budget)
+		op, err := WrapAggregate(ctx, source, aggregateCountPlan(defs[:1], defs[1]))
+		require.NoError(t, err)
+		require.NoError(t, op.Init(ctx))
+		var seen int
+		for {
+			var batch *vectorized.RecordBatch
+			batch, err = op.NextBatch(ctx)
+			if err != nil || batch == nil {
+				break
+			}
+			seen += batch.Len
+			for _, count := range batch.Columns[1].(*vectorized.TypedColumn[int64]).Data() {
+				require.EqualValues(t, 1, count)
+			}
+		}
+		if budget == 16<<20 {
+			require.ErrorIs(t, err, vectorized.ErrMemoryBudgetExceeded)
+			require.Zero(t, seen)
+		} else {
+			require.NoError(t, err)
+			require.Equal(t, rows, seen)
+			stats, ok := AggregateStatistics(op)
+			require.True(t, ok)
+			require.EqualValues(t, rows, stats.Groups)
+			require.EqualValues(t, rows*8, stats.StateBytes)
+			require.LessOrEqual(t, stats.StateCapacity, stats.StateBytes*2)
+			require.Less(t, stats.KeyCapacity, uint64(rows*80))
+		}
+		require.NoError(t, op.Close())
+		require.Equal(t, 1, source.closes)
+		require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+	}
+}
+
+func aggregateUniqueKeysSource(rows int) (*rawSource, []vectorized.ColumnDef) {
+	defs := []vectorized.ColumnDef{aggregateField("key", vectorized.ColumnTypeInt64), aggregateField("v", vectorized.ColumnTypeInt64)}
+	schema := vectorized.NewBatchSchema(defs)
+	source := &rawSource{schema: schema}
+	for begin := 0; begin < rows; begin += 65536 {
+		end := min(begin+65536, rows)
+		key := vectorized.NewInt64Column(end - begin)
+		for row := begin; row < end; row++ {
+			key.Append(int64(row))
+		}
+		batch := &vectorized.RecordBatch{Schema: schema, Columns: []vectorized.Column{key, vectorized.NewConstInt64(1, end-begin)}, Len: end - begin}
+		source.batches = append(source.batches, batch)
+	}
+	return source, defs
+}
diff --git a/pkg/query/ql/aggregate_lifecycle_test.go b/pkg/query/ql/aggregate_lifecycle_test.go
new file mode 100644
index 000000000..beff9311c
--- /dev/null
+++ b/pkg/query/ql/aggregate_lifecycle_test.go
@@ -0,0 +1,86 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"math"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+type aggregateCancelSource struct {
+	*rawSource
+	cancel context.CancelFunc
+}
+
+func (s *aggregateCancelSource) NextBatch(ctx context.Context) (*vectorized.RecordBatch, error) {
+	batch, err := s.rawSource.NextBatch(ctx)
+	if s.pulls == 2 {
+		s.cancel()
+	}
+	return batch, err
+}
+
+func TestAggregateCancellationAfterRetainingGroupsReleasesState(t *testing.T) {
+	source, defs := aggregateUniqueKeysSource(65537)
+	budget := vectorized.WithQueryMemoryTracker(t.Context(), 16<<20)
+	ctx, cancel := context.WithCancel(budget)
+	defer cancel()
+	wrapped := &aggregateCancelSource{rawSource: source, cancel: cancel}
+	op, err := WrapAggregate(ctx, wrapped, aggregateCountPlan(defs[:1], defs[1]))
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	batch, err := op.NextBatch(ctx)
+	require.ErrorIs(t, err, context.Canceled)
+	require.Nil(t, batch)
+	stats, ok := AggregateStatistics(op)
+	require.True(t, ok)
+	require.EqualValues(t, 65536, stats.Groups)
+	require.NoError(t, op.Close())
+	require.Equal(t, 1, source.closes)
+	require.Zero(t, vectorized.QueryMemoryTracker(budget, 0).Used())
+}
+
+func TestAggregateIntegerSumOverflowAndFloatAverage(t *testing.T) {
+	for _, function := range []AggregateFunction{AggregateSum, AggregateAvg} {
+		source, defs := aggregateFixture(false)
+		source.batches[0].Columns[1] = vectorized.NewColumnFromSlice(vectorized.ColumnTypeInt64, []int64{math.MaxInt64, math.MaxInt64})
+		source.batches[0].Len = 2
+		call := AggregateCall{Source: defs[1], Function: function}
+		plan := &AggregatePlan{Calls: []AggregateCall{call}, Outputs: []AggregateOutput{{Column: aggregateField("value", aggregateOutputType(call))}}}
+		ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+		op, err := WrapAggregate(ctx, source, plan)
+		require.NoError(t, err)
+		require.NoError(t, op.Init(ctx))
+		batch, err := op.NextBatch(ctx)
+		if function == AggregateSum {
+			require.ErrorContains(t, err, "exceeds int64")
+			require.Nil(t, batch)
+		} else {
+			require.NoError(t, err)
+			require.Equal(t, float64(math.MaxInt64), batch.Columns[0].(*vectorized.TypedColumn[float64]).Data()[0])
+		}
+		require.NoError(t, op.Close())
+		require.Equal(t, 1, source.closes)
+		require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+	}
+}
diff --git a/pkg/query/ql/aggregate_merge.go b/pkg/query/ql/aggregate_merge.go
new file mode 100644
index 000000000..efeb8c144
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge.go
@@ -0,0 +1,284 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"fmt"
+
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+// NativeAggregateLeaf is one finalized native aggregate over an original source column.
+type NativeAggregateLeaf struct {
+	Request  *measurev1.QueryRequest
+	Source   vectorized.ColumnDef
+	Function AggregateFunction
+}
+
+// NativeAggregatePlan combines native leaves using the complete grouping tuple.
+// MaxCount is a planner-proven upper bound on non-null values in each group.
+type NativeAggregatePlan struct {
+	Aggregate *AggregatePlan
+	Leaves    []NativeAggregateLeaf
+	MaxCount  int64
+	MaxGroups uint64 // Zero permits every group that fits in the memory budget.
+}
+
+// NativeAggregateSource opens one native leaf using the query's shared memory tracker.
+type NativeAggregateSource func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error)
+
+type nativeAggregateState struct {
+	// Low bits mark rows seen by COUNT/SUM/MIN/MAX; high bits mark non-null values.
+	presence []uint16
+	aggregateState
+	required uint16
+}
+
+type nativeAggregateBranch struct {
+	leaf  NativeAggregateLeaf
+	state int
+}
+
+type nativeAggregateOperator struct {
+	open        NativeAggregateSource
+	current     vectorized.PullOperator
+	tracker     *vectorized.MemoryTracker
+	schema      *vectorized.BatchSchema
+	output      *vectorized.RecordBatch
+	groupDefs   []vectorized.ColumnDef
+	groupValues []aggregateValue
+	states      []nativeAggregateState
+	calls       []aggregateCallRef
+	outputs     []AggregateOutput
+	branches    []nativeAggregateBranch
+	keys        []string
+	index       []uint32
+	scratch     []byte
+	stats       NativeAggregateStats
+	maxCount    int64
+	maxGroups   uint64
+	metadata    int64
+	tableBytes  int64
+	stateBytes  int64
+	keyBytes    int64
+	keyCapacity int64
+	scratchSize int64
+	outputBytes int64
+	valueBytes  int64
+	capacity    int
+	position    int
+	complete    bool
+	closed      bool
+}
+
+// MergeNativeAggregate executes leaves sequentially and owns each opened source.
+// All leaves finish before any final SQL group is emitted.
+func MergeNativeAggregate(ctx context.Context, plan *NativeAggregatePlan, open NativeAggregateSource) (_ vectorized.PullOperator, err error) {
+	o := &nativeAggregateOperator{open: open}
+	defer func() {
+		if err != nil {
+			_ = o.Close()
+		}
+	}()
+	if err = ctx.Err(); err != nil {
+		return nil, err
+	}
+	if plan == nil || plan.Aggregate == nil || len(plan.Aggregate.Outputs) == 0 || len(plan.Aggregate.Calls) == 0 || open == nil {
+		return nil, fmt.Errorf("native aggregate requires a plan, calls, outputs and a source opener")
+	}
+	if plan.MaxCount < 0 || plan.MaxCount > 1<<53 {
+		return nil, fmt.Errorf("native aggregate COUNT bound must be within [0, 2^53]")
+	}
+	o.tracker = vectorized.QueryMemoryTracker(ctx, 256<<20)
+	metadata, boundErr := rawDescriptorBound(len(plan.Aggregate.GroupBy)+len(plan.Aggregate.Calls)+len(plan.Aggregate.Outputs)+len(plan.Leaves), 0, 0)
+	if boundErr != nil {
+		return nil, boundErr
+	}
+	if err = o.reserve(metadata); err != nil {
+		return nil, err
+	}
+	o.metadata, o.maxCount, o.maxGroups = metadata, plan.MaxCount, plan.MaxGroups
+	o.groupDefs = append([]vectorized.ColumnDef(nil), plan.Aggregate.GroupBy...)
+	o.groupValues = make([]aggregateValue, len(o.groupDefs))
+	for _, def := range o.groupDefs {
+		if !aggregateScalarType(def.Type) || (def.Role != vectorized.RoleTag && def.Role != vectorized.RoleField && def.Role != vectorized.RoleTimestamp) {
+			return nil, fmt.Errorf("native aggregate grouping column %q has unsupported type or role", def.Name)
+		}
+	}
+	if err = o.compile(plan); err != nil {
+		return nil, err
+	}
+	return o, nil
+}
+
+func (o *nativeAggregateOperator) compile(plan *NativeAggregatePlan) error {
+	o.calls = make([]aggregateCallRef, len(plan.Aggregate.Calls))
+	for index, call := range plan.Aggregate.Calls {
+		if call.Function > AggregateAvg || !aggregateScalarType(call.Source.Type) ||
+			(call.Function != AggregateCount && call.Source.Type != vectorized.ColumnTypeInt64 && call.Source.Type != vectorized.ColumnTypeFloat64) {
+			return fmt.Errorf("unsupported native aggregate call for %q", call.Source.Name)
+		}
+		if call.Source.Type == vectorized.ColumnTypeInt64 && (call.Function == AggregateSum || call.Function == AggregateAvg) && o.maxCount > 1 {
+			return fmt.Errorf("native integer SUM/AVG requires a count bound at most one")
+		}
+		state := o.findState(call.Source)
+		if state < 0 {
+			def := call.Source
+			def.Entity = false
+			state = len(o.states)
+			o.states = append(o.states, nativeAggregateState{aggregateState: aggregateState{source: def}, required: 1 << AggregateCount})
+		}
+		o.states[state].require(call.Function)
+		function := call.Function
+		if function == AggregateAvg {
+			function = AggregateSum
+		}
+		o.states[state].required |= 1 << function
+		o.calls[index] = aggregateCallRef{state: state, function: call.Function}
+	}
+	seen := make([]uint16, len(o.states))
+	for _, leaf := range plan.Leaves {
+		state := o.findState(leaf.Source)
+		if state < 0 || leaf.Function > AggregateMax {
+			return fmt.Errorf("native aggregate leaf for %q is absent from the aggregate calls", leaf.Source.Name)
+		}
+		bit := uint16(1) << leaf.Function
+		if o.states[state].required&bit == 0 || seen[state]&bit != 0 {
+			return fmt.Errorf("native aggregate leaf for %q is unnecessary or repeated", leaf.Source.Name)
+		}
+		seen[state] |= bit
+		o.branches = append(o.branches, nativeAggregateBranch{leaf: leaf, state: state})
+	}
+	for index := range o.states {
+		if seen[index] != o.states[index].required {
+			return fmt.Errorf("native aggregate leaves for %q are incomplete", o.states[index].source.Name)
+		}
+	}
+	o.outputs = append([]AggregateOutput(nil), plan.Aggregate.Outputs...)
+	defs := make([]vectorized.ColumnDef, len(o.outputs))
+	for index, output := range o.outputs {
+		if output.Column.Role != vectorized.RoleTag && output.Column.Role != vectorized.RoleField {
+			return fmt.Errorf("native aggregate output %q must be a tag or field", output.Column.Name)
+		}
+		var expected vectorized.ColumnType
+		if output.IsGroup {
+			if output.Index < 0 || output.Index >= len(o.groupDefs) {
+				return fmt.Errorf("native aggregate grouping output index %d is absent", output.Index)
+			}
+			expected = o.groupDefs[output.Index].Type
+		} else {
+			if output.Index < 0 || output.Index >= len(o.calls) {
+				return fmt.Errorf("native aggregate call output index %d is absent", output.Index)
+			}
+			expected = aggregateOutputType(plan.Aggregate.Calls[output.Index])
+		}
+		if output.Column.Type != expected {
+			return fmt.Errorf("native aggregate output %q has type %s; expected %s", output.Column.Name, output.Column.Type, expected)
+		}
+		defs[index] = output.Column
+		defs[index].Entity = false
+	}
+	o.schema = vectorized.NewBatchSchema(defs)
+	return nil
+}
+
+func (o *nativeAggregateOperator) findState(def vectorized.ColumnDef) int {
+	def.Entity = false
+	for index := range o.states {
+		if o.states[index].source == def {
+			return index
+		}
+	}
+	return -1
+}
+
+func (o *nativeAggregateOperator) OutputSchema() *vectorized.BatchSchema { return o.schema }
+
+func (o *nativeAggregateOperator) Init(ctx context.Context) error {
+	if err := ctx.Err(); err != nil {
+		_ = o.Close()
+		return err
+	}
+	if o.closed {
+		return fmt.Errorf("native aggregate is closed")
+	}
+	return nil
+}
+
+func (o *nativeAggregateOperator) closeCurrent() error {
+	if o.current == nil {
+		return nil
+	}
+	source := o.current
+	o.current = nil
+	return source.Close()
+}
+
+func (o *nativeAggregateOperator) Close() error {
+	if o.closed {
+		return nil
+	}
+	o.stats = o.statistics()
+	o.closed = true
+	err := o.closeCurrent()
+	o.release(o.metadata + o.tableBytes + o.stateBytes + o.keyCapacity + o.scratchSize + o.outputBytes + o.valueBytes)
+	o.states, o.calls, o.outputs, o.branches = nil, nil, nil, nil
+	o.keys, o.index, o.scratch = nil, nil, nil
+	o.groupDefs, o.groupValues, o.output, o.schema, o.open = nil, nil, nil, nil, nil
+	return err
+}
+
+func (o *nativeAggregateOperator) reserve(bytes int64) error {
+	if err := o.tracker.Reserve(bytes); err != nil {
+		return err
+	}
+	recordNativeAggregateReserve(bytes)
+	return nil
+}
+
+func (o *nativeAggregateOperator) release(bytes int64) {
+	if bytes != 0 && o.tracker != nil {
+		o.tracker.Release(bytes)
+		recordNativeAggregateRelease(bytes)
+	}
+}
+
+func (o *nativeAggregateOperator) NextBatch(ctx context.Context) (_ *vectorized.RecordBatch, err error) {
+	defer func() {
+		if err != nil {
+			_ = o.Close()
+		}
+	}()
+	if err = ctx.Err(); err != nil {
+		return nil, err
+	}
+	if o.closed {
+		return nil, nil
+	}
+	if !o.complete {
+		if err = o.consume(ctx); err != nil {
+			return nil, err
+		}
+	}
+	if o.position == len(o.keys) {
+		return nil, nil
+	}
+	return o.emit(ctx)
+}
diff --git a/pkg/query/ql/aggregate_merge_benchmark_test.go b/pkg/query/ql/aggregate_merge_benchmark_test.go
new file mode 100644
index 000000000..b10066964
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_benchmark_test.go
@@ -0,0 +1,216 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+	"testing"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+type nativeMergeKernelKey struct{ high, low int64 }
+
+type nativeMergeKernelCell struct {
+	key   nativeMergeKernelKey
+	value float64
+}
+
+type nativeMergeKernelResult struct {
+	count int64
+	sum   float64
+}
+
+type nativeMergeKernelFixture struct {
+	plan    *NativeAggregatePlan
+	sums    []nativeMergeKernelCell
+	counts  []nativeMergeKernelCell
+	batches [2][]*vectorized.RecordBatch
+}
+
+var nativeMergeKernelSink float64
+
+// BenchmarkNativeAggregateMerge compares the public keyed column merge with an
+// independent scalar reference. Only group count and native branch order vary.
+func BenchmarkNativeAggregateMerge(b *testing.B) {
+	for _, groups := range []int{1, 1024, 65537} {
+		for _, permuted := range []bool{false, true} {
+			fixture := makeNativeMergeKernelFixture(groups, permuted)
+			name := fmt.Sprintf("groups=%d/permuted=%t", groups, permuted)
+			b.Run(name+"/column", func(b *testing.B) {
+				stats := validateNativeMergeKernel(b, fixture)
+				runtime.GC()
+				b.ReportAllocs()
+				b.ResetTimer()
+				for iteration := 0; iteration < b.N; iteration++ {
+					_, total := runNativeMergeKernel(b, fixture, nil)
+					nativeMergeKernelSink += total
+				}
+				b.ReportMetric(float64(stats.Branches), "branches/op")
+				b.ReportMetric(float64(stats.InputRows), "input-groups/op")
+				b.ReportMetric(float64(stats.Groups), "groups/op")
+				b.ReportMetric(float64(stats.StateBytes), "state-B/op")
+				b.ReportMetric(float64(stats.StateCapacity), "state-cap-B/op")
+				b.ReportMetric(float64(stats.KeyCapacity), "key-cap-B/op")
+				b.ReportMetric(float64(stats.MergeVisits), "merge-visits/op")
+			})
+			b.Run(name+"/scalar", func(b *testing.B) {
+				b.ReportAllocs()
+				for iteration := 0; iteration < b.N; iteration++ {
+					result := nativeMergeKernelReference(fixture)
+					var total float64
+					for _, value := range result {
+						total += float64(value.count) + value.sum
+						if value.count != 0 {
+							total += value.sum / float64(value.count)
+						}
+					}
+					nativeMergeKernelSink += total
+				}
+			})
+		}
+	}
+}
+
+func makeNativeMergeKernelFixture(groups int, permuted bool) nativeMergeKernelFixture {
+	defs := []vectorized.ColumnDef{aggregateField("high", vectorized.ColumnTypeInt64), aggregateField("low", vectorized.ColumnTypeInt64)}
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	fixture := nativeMergeKernelFixture{plan: nativeMergePlan(defs, value, AggregateSum, AggregateCount, AggregateAvg)}
+	fixture.plan.MaxCount = 3
+	fixture.plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateSum}, {Source: value, Function: AggregateCount}}
+	if permuted {
+		fixture.plan.Leaves[0], fixture.plan.Leaves[1] = fixture.plan.Leaves[1], fixture.plan.Leaves[0]
+	}
+	for row := range groups {
+		group := row
+		if permuted {
+			group = groups - row - 1
+		}
+		key := nativeMergeKernelKey{high: int64(group / 16), low: int64(group % 16)}
+		sum := float64(group%23+1) / 4
+		if group != 0 && group%11 == 0 {
+			sum = 0
+		}
+		fixture.sums = append(fixture.sums, nativeMergeKernelCell{key: key, value: sum})
+		fixture.counts = append(fixture.counts, nativeMergeKernelCell{
+			key: nativeMergeKernelKey{high: int64(row / 16), low: int64(row % 16)}, value: float64(1 + row%3),
+		})
+		if row != 0 && row%11 == 0 {
+			fixture.counts[row].value = 0
+		}
+	}
+	schema := vectorized.NewBatchSchema(append(defs, value))
+	for branch, cells := range [][]nativeMergeKernelCell{fixture.sums, fixture.counts} {
+		for begin := 0; begin < groups; begin += 65536 {
+			end := min(begin+65536, groups)
+			batch := vectorized.NewRecordBatch(schema, end-begin)
+			for _, cell := range cells[begin:end] {
+				batch.Columns[0].(*vectorized.TypedColumn[int64]).Append(cell.key.high)
+				batch.Columns[1].(*vectorized.TypedColumn[int64]).Append(cell.key.low)
+				batch.Columns[2].(*vectorized.TypedColumn[float64]).Append(cell.value)
+			}
+			batch.Len = end - begin
+			fixture.batches[branch] = append(fixture.batches[branch], batch)
+		}
+	}
+	return fixture
+}
+
+func nativeMergeKernelReference(fixture nativeMergeKernelFixture) map[nativeMergeKernelKey]nativeMergeKernelResult {
+	result := make(map[nativeMergeKernelKey]nativeMergeKernelResult)
+	for _, cell := range fixture.sums {
+		result[cell.key] = nativeMergeKernelResult{sum: cell.value}
+	}
+	for _, cell := range fixture.counts {
+		value := result[cell.key]
+		value.count = int64(cell.value)
+		result[cell.key] = value
+	}
+	return result
+}
+
+func validateNativeMergeKernel(tb testing.TB, fixture nativeMergeKernelFixture) NativeAggregateStats {
+	tb.Helper()
+	expected := nativeMergeKernelReference(fixture)
+	stats, _ := runNativeMergeKernel(tb, fixture, expected)
+	if len(expected) != 0 {
+		tb.Fatal("native merge omitted expected groups")
+	}
+	if stats.Groups != uint64(len(fixture.sums)) || stats.Branches != 2 || stats.InputRows != stats.Groups*2 || stats.MergeVisits != stats.InputRows+stats.Groups {
+		tb.Fatalf("unexpected native merge structure: %+v", stats)
+	}
+	return stats
+}
+
+func runNativeMergeKernel(tb testing.TB, fixture nativeMergeKernelFixture,
+	expected map[nativeMergeKernelKey]nativeMergeKernelResult,
+) (NativeAggregateStats, float64) {
+	tb.Helper()
+	ctx := vectorized.WithQueryMemoryTracker(context.Background(), 128<<20)
+	op, err := MergeNativeAggregate(ctx, fixture.plan, func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		branch := 0
+		if leaf.Function == AggregateCount {
+			branch = 1
+		}
+		batches := fixture.batches[branch]
+		return &rawSource{schema: batches[0].Schema, batches: append([]*vectorized.RecordBatch(nil), batches...)}, nil
+	})
+	if err != nil {
+		tb.Fatal(err)
+	}
+	if err = op.Init(ctx); err != nil {
+		tb.Fatal(err)
+	}
+	var total float64
+	for {
+		batch, pullErr := op.NextBatch(ctx)
+		if pullErr != nil {
+			tb.Fatal(pullErr)
+		}
+		if batch == nil {
+			break
+		}
+		for row := range batch.Len {
+			key := nativeMergeKernelKey{
+				high: batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[row], low: batch.Columns[1].(*vectorized.TypedColumn[int64]).Data()[row],
+			}
+			count := batch.Columns[3].(*vectorized.TypedColumn[int64]).Data()[row]
+			sum := batch.Columns[2].(*vectorized.TypedColumn[float64]).Data()[row]
+			avg := batch.Columns[4].(*vectorized.TypedColumn[float64]).Data()[row]
+			total += float64(count) + sum + avg
+			if expected != nil {
+				want, exists := expected[key]
+				if !exists || count != want.count || (count != 0 && (sum != want.sum || avg != want.sum/float64(count))) ||
+					batch.Columns[2].IsNull(row) != (count == 0) || batch.Columns[4].IsNull(row) != (count == 0) {
+					tb.Fatalf("native merge differs from independent reference for %+v", key)
+				}
+				delete(expected, key)
+			}
+		}
+	}
+	stats, _ := NativeAggregateStatistics(op)
+	if err = op.Close(); err != nil {
+		tb.Fatal(err)
+	}
+	if vectorized.QueryMemoryTracker(ctx, 0).Used() != 0 {
+		tb.Fatal("native merge retained its query budget after Close")
+	}
+	return stats, total
+}
diff --git a/pkg/query/ql/aggregate_merge_contract_test.go b/pkg/query/ql/aggregate_merge_contract_test.go
new file mode 100644
index 000000000..bc23d8e8f
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_contract_test.go
@@ -0,0 +1,237 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"math"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func nativeMergeScalarSource(def vectorized.ColumnDef, values ...float64) *rawSource {
+	schema := vectorized.NewBatchSchema([]vectorized.ColumnDef{def})
+	batch := vectorized.NewRecordBatch(schema, len(values))
+	for _, value := range values {
+		if def.Type == vectorized.ColumnTypeFloat64 {
+			batch.Columns[0].(*vectorized.TypedColumn[float64]).Append(value)
+		} else {
+			batch.Columns[0].(*vectorized.TypedColumn[int64]).Append(int64(value))
+		}
+	}
+	batch.Len = len(values)
+	return &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}
+}
+
+func TestNativeAggregateMergeRejectsMalformedCountsAndGroups(t *testing.T) {
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	for _, testCase := range []struct {
+		name   string
+		values []float64
+	}{
+		{name: "negative", values: []float64{-1}},
+		{name: "fraction", values: []float64{1.5}},
+		{name: "nan", values: []float64{math.NaN()}},
+		{name: "infinity", values: []float64{math.Inf(1)}},
+		{name: "above-bound", values: []float64{101}},
+		{name: "duplicate-final-group", values: []float64{1, 1}},
+	} {
+		t.Run(testCase.name, func(t *testing.T) {
+			plan := nativeMergePlan(nil, value, AggregateCount)
+			plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateCount}}
+			source := nativeMergeScalarSource(value, testCase.values...)
+			ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+			op, err := MergeNativeAggregate(ctx, plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) { return source, nil })
+			require.NoError(t, err)
+			require.NoError(t, op.Init(ctx))
+			batch, err := op.NextBatch(ctx)
+			require.Error(t, err)
+			require.Nil(t, batch)
+			require.NoError(t, op.Close())
+			require.Equal(t, 1, source.closes)
+			require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+		})
+	}
+}
+
+func TestNativeAggregateMergeExactCountBoundaryAndIntegerExtrema(t *testing.T) {
+	floatDef := aggregateField("f", vectorized.ColumnTypeFloat64)
+	plan := nativeMergePlan(nil, floatDef, AggregateCount)
+	plan.MaxCount = 1 << 53
+	plan.Leaves = []NativeAggregateLeaf{{Source: floatDef, Function: AggregateCount}}
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	op, err := MergeNativeAggregate(ctx, plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		return nativeMergeScalarSource(floatDef, 1<<53), nil
+	})
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	batch, err := op.NextBatch(ctx)
+	require.NoError(t, err)
+	require.Equal(t, int64(1<<53), batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+	require.NoError(t, op.Close())
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+
+	intDef := aggregateField("v", vectorized.ColumnTypeInt64)
+	plan = nativeMergePlan(nil, intDef, AggregateMin, AggregateMax, AggregateCount)
+	plan.Leaves = []NativeAggregateLeaf{{Source: intDef, Function: AggregateMin}, {Source: intDef, Function: AggregateMax}, {Source: intDef, Function: AggregateCount}}
+	op, err = MergeNativeAggregate(ctx, plan, func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		var actual int64 = 1
+		switch leaf.Function {
+		case AggregateMin:
+			actual = 1<<53 + 1
+		case AggregateMax:
+			actual = math.MaxInt64
+		default:
+		}
+		source := nativeMergeScalarSource(intDef, 0)
+		source.batches[0].Columns[0] = vectorized.NewColumnFromSlice(vectorized.ColumnTypeInt64, []int64{actual})
+		return source, nil
+	})
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	batch, err = op.NextBatch(ctx)
+	require.NoError(t, err)
+	require.Equal(t, int64(1<<53+1), batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+	require.Equal(t, int64(math.MaxInt64), batch.Columns[1].(*vectorized.TypedColumn[int64]).Data()[0])
+	require.NoError(t, op.Close())
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
+
+func TestNativeAggregateMergeMissingValuesAndEmptySemantics(t *testing.T) {
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	key := aggregateField("key", vectorized.ColumnTypeInt64)
+	for _, grouped := range []bool{false, true} {
+		for _, count := range []float64{0, 1} {
+			plan := nativeMergePlan(nil, value, AggregateCount, AggregateSum, AggregateAvg)
+			if grouped {
+				plan.Aggregate.GroupBy = []vectorized.ColumnDef{key}
+			}
+			plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateCount}, {Source: value, Function: AggregateSum}}
+			ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+			op, err := MergeNativeAggregate(ctx, plan, func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+				defs := append(append([]vectorized.ColumnDef(nil), plan.Aggregate.GroupBy...), value)
+				schema := vectorized.NewBatchSchema(defs)
+				if leaf.Function == AggregateSum {
+					return &rawSource{schema: schema}, nil
+				}
+				batch := vectorized.NewRecordBatch(schema, 1)
+				if grouped {
+					batch.Columns[0].(*vectorized.TypedColumn[int64]).Append(7)
+				}
+				batch.Columns[len(defs)-1].(*vectorized.TypedColumn[float64]).Append(count)
+				batch.Len = 1
+				return &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}, nil
+			})
+			require.NoError(t, err)
+			require.NoError(t, op.Init(ctx))
+			batch, err := op.NextBatch(ctx)
+			if count > 0 {
+				require.ErrorContains(t, err, "positive COUNT")
+				require.Nil(t, batch)
+			} else {
+				require.NoError(t, err)
+				require.Equal(t, 1, batch.Len)
+				require.Zero(t, batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+				require.True(t, batch.Columns[1].IsNull(0))
+				require.True(t, batch.Columns[2].IsNull(0))
+			}
+			require.NoError(t, op.Close())
+			require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+		}
+	}
+	for _, grouped := range []bool{false, true} {
+		plan := nativeMergePlan(nil, value, AggregateCount, AggregateSum, AggregateAvg)
+		if grouped {
+			plan.Aggregate.GroupBy = []vectorized.ColumnDef{key}
+		}
+		plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateCount}, {Source: value, Function: AggregateSum}}
+		op, err := MergeNativeAggregate(t.Context(), plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) {
+			return &rawSource{schema: vectorized.NewBatchSchema(append(append([]vectorized.ColumnDef(nil), plan.Aggregate.GroupBy...), value))}, nil
+		})
+		require.NoError(t, err)
+		require.NoError(t, op.Init(t.Context()))
+		batch, err := op.NextBatch(t.Context())
+		require.NoError(t, err)
+		if grouped {
+			require.Nil(t, batch)
+		} else {
+			require.Equal(t, 1, batch.Len)
+			require.Zero(t, batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+			require.True(t, batch.Columns[1].IsNull(0))
+			require.True(t, batch.Columns[2].IsNull(0))
+		}
+		require.NoError(t, op.Close())
+	}
+}
+
+func TestNativeAggregateMergeSingleIntegerSumAndAverage(t *testing.T) {
+	value := aggregateField("v", vectorized.ColumnTypeInt64)
+	plan := nativeMergePlan(nil, value, AggregateSum, AggregateCount, AggregateAvg)
+	plan.MaxCount = 1
+	plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateSum}, {Source: value, Function: AggregateCount}}
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	op, err := MergeNativeAggregate(ctx, plan, func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		number := int64(math.MaxInt64)
+		if leaf.Function == AggregateCount {
+			number = 1
+		}
+		source := nativeMergeScalarSource(value, 0)
+		source.batches[0].Columns[0] = vectorized.NewColumnFromSlice(vectorized.ColumnTypeInt64, []int64{number})
+		return source, nil
+	})
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	batch, err := op.NextBatch(ctx)
+	require.NoError(t, err)
+	require.Equal(t, int64(math.MaxInt64), batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+	require.Equal(t, int64(1), batch.Columns[1].(*vectorized.TypedColumn[int64]).Data()[0])
+	require.Equal(t, float64(math.MaxInt64), batch.Columns[2].(*vectorized.TypedColumn[float64]).Data()[0])
+	stats, ok := NativeAggregateStatistics(op)
+	require.True(t, ok)
+	require.EqualValues(t, 3, stats.StateLanes)
+	require.NoError(t, op.Close())
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+	plan.MaxCount = 2
+	_, err = MergeNativeAggregate(ctx, plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		t.Fatal("unsafe integer aggregate dispatched a native leaf")
+		return nil, nil
+	})
+	require.ErrorContains(t, err, "at most one")
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
+
+func TestNativeAggregateMergeRejectsChangingLeafSchemas(t *testing.T) {
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	plan := nativeMergePlan(nil, value, AggregateCount)
+	plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateCount}}
+	source := nativeMergeScalarSource(value, 1)
+	source.batches[0].Schema = vectorized.NewBatchSchema([]vectorized.ColumnDef{aggregateField("other", vectorized.ColumnTypeFloat64)})
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	op, err := MergeNativeAggregate(ctx, plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) { return source, nil })
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	batch, err := op.NextBatch(ctx)
+	require.ErrorContains(t, err, "schema")
+	require.Nil(t, batch)
+	require.NoError(t, op.Close())
+	require.Equal(t, 1, source.closes)
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
diff --git a/pkg/query/ql/aggregate_merge_group.go b/pkg/query/ql/aggregate_merge_group.go
new file mode 100644
index 000000000..d2391f26c
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_group.go
@@ -0,0 +1,151 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"fmt"
+	"math"
+
+	"github.com/cespare/xxhash/v2"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func (o *nativeAggregateOperator) encodeKey() error {
+	var bytes int64
+	for _, value := range o.groupValues {
+		size := groupValueBytes(value)
+		if size > math.MaxInt64-bytes {
+			return fmt.Errorf("%w: native aggregate key size overflows", vectorized.ErrMemoryBudgetExceeded)
+		}
+		bytes += size
+	}
+	if bytes > int64(cap(o.scratch)) {
+		capacity, err := groupAllocationBytes(max(int64(64), bytes))
+		if err != nil {
+			return err
+		}
+		if err = o.reserve(capacity); err != nil {
+			return err
+		}
+		o.scratch = make([]byte, 0, int(capacity))
+		o.release(o.scratchSize)
+		o.scratchSize = capacity
+	}
+	o.scratch = o.scratch[:0]
+	for _, value := range o.groupValues {
+		o.scratch = appendGroupValue(o.scratch, value)
+	}
+	return nil
+}
+
+func (o *nativeAggregateOperator) groupID() (int, error) {
+	if err := o.encodeKey(); err != nil {
+		return 0, err
+	}
+	hash := xxhash.Sum64(o.scratch)
+	id, slot, found := o.lookup(hash)
+	if found {
+		return id, nil
+	}
+	if o.maxGroups != 0 && uint64(len(o.keys)) >= o.maxGroups {
+		return 0, fmt.Errorf("%w: native aggregate group limit %d exceeded", vectorized.ErrMemoryBudgetExceeded, o.maxGroups)
+	}
+	if len(o.keys) == o.capacity {
+		if err := o.grow(); err != nil {
+			return 0, err
+		}
+		_, slot, _ = o.lookup(hash)
+	}
+	keyBytes := int64(len(o.scratch))
+	reserved, err := groupAllocationBytes(keyBytes)
+	if err != nil {
+		return 0, err
+	}
+	if err = o.reserve(reserved); err != nil {
+		return 0, err
+	}
+	id = len(o.keys)
+	o.keys = append(o.keys, string(o.scratch))
+	o.index[slot] = uint32(id) + 1
+	o.keyBytes += keyBytes
+	o.keyCapacity += reserved
+	return id, nil
+}
+
+func (o *nativeAggregateOperator) lookup(hash uint64) (id, slot int, found bool) {
+	if len(o.index) == 0 {
+		return 0, 0, false
+	}
+	mask := uint64(len(o.index) - 1)
+	position := hash & mask
+	for {
+		entry := o.index[position]
+		if entry == 0 {
+			return 0, int(position), false
+		}
+		id = int(entry - 1)
+		if o.keys[id] == string(o.scratch) {
+			return id, int(position), true
+		}
+		position = (position + 1) & mask
+	}
+}
+
+func (o *nativeAggregateOperator) grow() error {
+	if o.capacity > math.MaxUint32/2 || o.capacity > math.MaxInt/4 {
+		return fmt.Errorf("%w: native aggregate group index overflows", vectorized.ErrMemoryBudgetExceeded)
+	}
+	capacity := max(1, o.capacity*2)
+	var lanes int64
+	for index := range o.states {
+		lanes += int64(o.states[index].lanes())
+	}
+	stateWidth := lanes*8 + int64(len(o.states))*2
+	const tableWidth int64 = 24 // A key header and two uint32 index slots per group.
+	if int64(capacity) > math.MaxInt64/(tableWidth+stateWidth) {
+		return fmt.Errorf("%w: native aggregate group capacity overflows", vectorized.ErrMemoryBudgetExceeded)
+	}
+	tableBytes := int64(capacity) * tableWidth
+	// Presence slices smaller than eight bytes still use an eight-byte Go allocation.
+	stateBytes := int64(capacity)*lanes*8 + int64(len(o.states))*max(int64(8), int64(capacity)*2)
+	if err := o.reserve(tableBytes + stateBytes); err != nil {
+		return err
+	}
+	keys := make([]string, len(o.keys), capacity)
+	copy(keys, o.keys)
+	index := make([]uint32, capacity*2)
+	mask := uint64(len(index) - 1)
+	for id, key := range keys {
+		position := xxhash.Sum64String(key) & mask
+		for index[position] != 0 {
+			position = (position + 1) & mask
+		}
+		index[position] = uint32(id) + 1
+	}
+	for state := range o.states {
+		o.states[state].grow(capacity)
+		presence := make([]uint16, capacity)
+		copy(presence, o.states[state].presence)
+		o.states[state].presence = presence
+	}
+	o.release(o.tableBytes + o.stateBytes)
+	o.keys, o.index, o.capacity = keys, index, capacity
+	o.tableBytes, o.stateBytes = tableBytes, stateBytes
+	return nil
+}
diff --git a/pkg/query/ql/aggregate_merge_input.go b/pkg/query/ql/aggregate_merge_input.go
new file mode 100644
index 000000000..0d85aa230
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_input.go
@@ -0,0 +1,236 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"fmt"
+	"math"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func (o *nativeAggregateOperator) consume(ctx context.Context) error {
+	if len(o.groupDefs) == 0 {
+		if _, err := o.groupID(); err != nil {
+			return err
+		}
+	}
+	for _, branch := range o.branches {
+		if err := ctx.Err(); err != nil {
+			return err
+		}
+		source, err := o.open(ctx, branch.leaf)
+		o.current = source
+		if err != nil {
+			return err
+		}
+		if source == nil || source.OutputSchema() == nil {
+			return fmt.Errorf("native aggregate leaf for %q has no schema", branch.leaf.Source.Name)
+		}
+		o.stats.Branches++
+		if err = o.consumeLeaf(ctx, branch); err != nil {
+			return err
+		}
+		clear(o.groupValues)
+		if err = o.closeCurrent(); err != nil {
+			return err
+		}
+	}
+	if err := o.validateGroups(ctx); err != nil {
+		return err
+	}
+	o.complete = true
+	recordNativeAggregateExecution(o.statistics())
+	return nil
+}
+
+func (o *nativeAggregateOperator) consumeLeaf(ctx context.Context, branch nativeAggregateBranch) error {
+	schema := o.current.OutputSchema()
+	readers := make([]aggregateReader, len(o.groupDefs))
+	for index, def := range o.groupDefs {
+		reader, err := newAggregateReader(schema, def)
+		if err != nil {
+			return err
+		}
+		readers[index] = reader
+	}
+	valueDef := vectorized.ColumnDef{Name: branch.leaf.Source.Name, Role: vectorized.RoleField, Type: branch.leaf.Source.Type}
+	if branch.leaf.Function == AggregateCount && valueDef.Type != vectorized.ColumnTypeFloat64 {
+		valueDef.Type = vectorized.ColumnTypeInt64
+	}
+	value, err := newAggregateReader(schema, valueDef)
+	if err != nil {
+		return err
+	}
+	if err = o.current.Init(ctx); err != nil {
+		return err
+	}
+	for {
+		if err = ctx.Err(); err != nil {
+			return err
+		}
+		batch, pullErr := o.current.NextBatch(ctx)
+		if pullErr != nil {
+			return pullErr
+		}
+		if batch == nil {
+			return nil
+		}
+		if batch.Len < 0 || batch.Len > math.MaxUint16+1 {
+			return fmt.Errorf("native aggregate leaf has invalid batch length %d", batch.Len)
+		}
+		if !nativeAggregateSchemaMatches(schema, batch.Schema) {
+			return fmt.Errorf("native aggregate leaf for %q changed its batch schema", branch.leaf.Source.Name)
+		}
+		for index := range readers {
+			if err = readers[index].bind(batch); err != nil {
+				return err
+			}
+		}
+		if err = value.bind(batch); err != nil {
+			return err
+		}
+		for position := 0; position < batch.ActiveLen(); position++ {
+			if position%1024 == 0 {
+				if err = ctx.Err(); err != nil {
+					return err
+				}
+			}
+			row := position
+			if batch.Selection != nil {
+				row = int(batch.Selection[position])
+			}
+			if row >= batch.Len {
+				return fmt.Errorf("native aggregate selection row %d exceeds batch length %d", row, batch.Len)
+			}
+			for index := range readers {
+				cell, readErr := readers[index].value(row)
+				if readErr != nil {
+					return readErr
+				}
+				o.groupValues[index] = cell
+			}
+			group, groupErr := o.groupID()
+			if groupErr != nil {
+				return groupErr
+			}
+			cell, readErr := value.value(row)
+			if readErr != nil {
+				return readErr
+			}
+			if err = o.acceptValue(branch, group, cell); err != nil {
+				return err
+			}
+			o.stats.InputRows++
+			o.stats.MergeVisits++
+		}
+	}
+}
+
+func nativeAggregateSchemaMatches(expected, actual *vectorized.BatchSchema) bool {
+	if expected == actual {
+		return true
+	}
+	if actual == nil || len(expected.Columns) != len(actual.Columns) {
+		return false
+	}
+	for index, def := range expected.Columns {
+		if def != actual.Columns[index] {
+			return false
+		}
+	}
+	return true
+}
+
+func (o *nativeAggregateOperator) acceptValue(branch nativeAggregateBranch, group int, value aggregateValue) error {
+	state := &o.states[branch.state]
+	bit := uint16(1) << branch.leaf.Function
+	if state.presence[group]&bit != 0 {
+		return fmt.Errorf("native aggregate leaf for %q returned a duplicate finalized group", branch.leaf.Source.Name)
+	}
+	state.presence[group] |= bit
+	if value.null {
+		if branch.leaf.Function == AggregateCount {
+			return fmt.Errorf("native aggregate COUNT(%s) returned null", branch.leaf.Source.Name)
+		}
+		return nil
+	}
+	state.presence[group] |= bit << 4
+	switch branch.leaf.Function {
+	case AggregateCount:
+		count := value.i64
+		if value.typ == vectorized.ColumnTypeFloat64 {
+			if math.IsNaN(value.f64) || math.IsInf(value.f64, 0) || value.f64 < 0 || value.f64 > float64(o.maxCount) || math.Trunc(value.f64) != value.f64 {
+				return fmt.Errorf("native aggregate COUNT(%s) is not an exact bounded integer", branch.leaf.Source.Name)
+			}
+			count = int64(value.f64)
+		}
+		if count < 0 || count > o.maxCount {
+			return fmt.Errorf("native aggregate COUNT(%s) exceeds its proven bound", branch.leaf.Source.Name)
+		}
+		state.count[group] = count
+	case AggregateSum:
+		if len(state.sumInt) != 0 {
+			state.sumInt[group] = value.i64
+		}
+		if len(state.sumFloat) != 0 {
+			state.sumFloat[group] = value.f64
+			if value.typ == vectorized.ColumnTypeInt64 {
+				state.sumFloat[group] = float64(value.i64)
+			}
+		}
+	case AggregateMin:
+		if value.typ == vectorized.ColumnTypeInt64 {
+			state.minInt[group] = value.i64
+		} else {
+			state.minFloat[group] = value.f64
+		}
+	case AggregateMax:
+		if value.typ == vectorized.ColumnTypeInt64 {
+			state.maxInt[group] = value.i64
+		} else {
+			state.maxFloat[group] = value.f64
+		}
+	default:
+		return fmt.Errorf("unsupported native aggregate leaf function %d", branch.leaf.Function)
+	}
+	return nil
+}
+
+func (o *nativeAggregateOperator) validateGroups(ctx context.Context) error {
+	for group := range o.keys {
+		if group%1024 == 0 {
+			if err := ctx.Err(); err != nil {
+				return err
+			}
+		}
+		for index := range o.states {
+			state := &o.states[index]
+			presence := state.presence[group]
+			if presence&1 == 0 && presence>>4 != 0 {
+				return fmt.Errorf("native aggregate group for %q is missing its COUNT", state.source.Name)
+			}
+			if state.count[group] > 0 && (presence>>4)&state.required != state.required {
+				return fmt.Errorf("native aggregate group for %q has a positive COUNT but is missing a required value", state.source.Name)
+			}
+		}
+		o.stats.MergeVisits++
+	}
+	return nil
+}
diff --git a/pkg/query/ql/aggregate_merge_keys_test.go b/pkg/query/ql/aggregate_merge_keys_test.go
new file mode 100644
index 000000000..09d05b496
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_keys_test.go
@@ -0,0 +1,95 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func TestNativeAggregateMergeAcrossSelectionBoundary(t *testing.T) {
+	for _, permuted := range []bool{false, true} {
+		stats := validateNativeMergeKernel(t, makeNativeMergeKernelFixture(65537, permuted))
+		require.EqualValues(t, 65537, stats.Groups)
+		require.EqualValues(t, 2, stats.StateLanes)
+		require.EqualValues(t, 65537*18, stats.StateBytes)
+	}
+}
+
+func TestNativeAggregateMergeIncludesBucketTimestampInKey(t *testing.T) {
+	key := vectorized.ColumnDef{Name: "host", TagFamily: "default", Role: vectorized.RoleTag, Type: vectorized.ColumnTypeString}
+	bucket := vectorized.ColumnDef{Name: "timestamp", Role: vectorized.RoleTimestamp, Type: vectorized.ColumnTypeInt64}
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	plan := nativeMergePlan([]vectorized.ColumnDef{key, bucket}, value, AggregateCount)
+	plan.Aggregate.Outputs[1].Column = aggregateField("bucket", vectorized.ColumnTypeInt64)
+	plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateCount}}
+	schema := vectorized.NewBatchSchema([]vectorized.ColumnDef{key, bucket, value})
+	batch := vectorized.NewRecordBatch(schema, 2)
+	batch.Columns[0].(*vectorized.TypedColumn[string]).AppendSlice([]string{"a", "a"})
+	batch.Columns[1].(*vectorized.TypedColumn[int64]).AppendSlice([]int64{100, 200})
+	batch.Columns[2].(*vectorized.TypedColumn[float64]).AppendSlice([]float64{2, 3})
+	batch.Len = 2
+	op, err := MergeNativeAggregate(t.Context(), plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		return &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}, nil
+	})
+	require.NoError(t, err)
+	require.NoError(t, op.Init(t.Context()))
+	actual, err := op.NextBatch(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, 2, actual.Len)
+	require.Equal(t, -1, actual.Schema.TimestampIndex())
+	require.Equal(t, []int64{100, 200}, actual.Columns[1].(*vectorized.TypedColumn[int64]).Data())
+	require.Equal(t, []int64{2, 3}, actual.Columns[2].(*vectorized.TypedColumn[int64]).Data())
+	require.NoError(t, op.Close())
+}
+
+func TestNativeAggregateMergeUnionsMissingSourceGroupsAndNullKeys(t *testing.T) {
+	key := vectorized.ColumnDef{Name: "host", TagFamily: "default", Role: vectorized.RoleTag, Type: vectorized.ColumnTypeString}
+	left, right := aggregateField("left", vectorized.ColumnTypeFloat64), aggregateField("right", vectorized.ColumnTypeFloat64)
+	plan := nativeMergePlan([]vectorized.ColumnDef{key}, left, AggregateCount, AggregateCount)
+	plan.Aggregate.Calls[1].Source = right
+	plan.Leaves = []NativeAggregateLeaf{{Source: left, Function: AggregateCount}, {Source: right, Function: AggregateCount}}
+	op, err := MergeNativeAggregate(t.Context(), plan, func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		schema := vectorized.NewBatchSchema([]vectorized.ColumnDef{key, leaf.Source})
+		batch := vectorized.NewRecordBatch(schema, 2)
+		if leaf.Source.Name == "left" {
+			batch.Columns[0].AppendNull()
+			batch.Columns[0].(*vectorized.TypedColumn[string]).Append("shared")
+			batch.Columns[1].(*vectorized.TypedColumn[float64]).AppendSlice([]float64{2, 3})
+		} else {
+			batch.Columns[0].(*vectorized.TypedColumn[string]).AppendSlice([]string{"", "shared"})
+			batch.Columns[1].(*vectorized.TypedColumn[float64]).AppendSlice([]float64{4, 5})
+		}
+		batch.Len = 2
+		return &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}, nil
+	})
+	require.NoError(t, err)
+	require.NoError(t, op.Init(t.Context()))
+	actual, err := op.NextBatch(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, 3, actual.Len)
+	require.True(t, actual.Columns[0].IsNull(0))
+	require.False(t, actual.Columns[0].IsNull(2))
+	require.Equal(t, []int64{2, 3, 0}, actual.Columns[1].(*vectorized.TypedColumn[int64]).Data())
+	require.Equal(t, []int64{0, 5, 4}, actual.Columns[2].(*vectorized.TypedColumn[int64]).Data())
+	require.NoError(t, op.Close())
+}
diff --git a/pkg/query/ql/aggregate_merge_lifecycle_test.go b/pkg/query/ql/aggregate_merge_lifecycle_test.go
new file mode 100644
index 000000000..ad0919a72
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_lifecycle_test.go
@@ -0,0 +1,212 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+type nativeMergeFailSource struct {
+	*rawSource
+	nextErr  error
+	closeErr error
+	cancel   context.CancelFunc
+}
+
+func (s *nativeMergeFailSource) NextBatch(ctx context.Context) (*vectorized.RecordBatch, error) {
+	if s.cancel != nil {
+		s.cancel()
+	}
+	if s.nextErr != nil {
+		return nil, s.nextErr
+	}
+	return s.rawSource.NextBatch(ctx)
+}
+
+func (s *nativeMergeFailSource) Close() error {
+	_ = s.rawSource.Close()
+	return s.closeErr
+}
+
+func TestNativeAggregateMergeFailuresCloseEveryOpenedLeaf(t *testing.T) {
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	sentinel := errors.New("native source failed")
+	for _, stage := range []string{"open", "init", "next", "close", "cancel", "schema", "null-count"} {
+		t.Run(stage, func(t *testing.T) {
+			budget := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+			ctx, cancel := context.WithCancel(budget)
+			defer cancel()
+			plan := nativeMergePlan(nil, value, AggregateSum)
+			plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateSum}, {Source: value, Function: AggregateCount}}
+			var sources []*nativeMergeFailSource
+			op, err := MergeNativeAggregate(ctx, plan, func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+				source := &nativeMergeFailSource{rawSource: nativeMergeScalarSource(value, 2)}
+				sources = append(sources, source)
+				if leaf.Function != AggregateCount {
+					return source, nil
+				}
+				switch stage {
+				case "open":
+					return source, sentinel
+				case "init":
+					source.initErr = sentinel
+				case "next":
+					source.nextErr = sentinel
+				case "close":
+					source.closeErr = sentinel
+				case "cancel":
+					source.cancel = cancel
+				case "schema":
+					source.schema = vectorized.NewBatchSchema(nil)
+				case "null-count":
+					source.batches[0].Columns[0].Reset()
+					source.batches[0].Columns[0].AppendNull()
+				}
+				return source, nil
+			})
+			require.NoError(t, err)
+			require.NoError(t, op.Init(ctx))
+			batch, err := op.NextBatch(ctx)
+			require.Error(t, err)
+			require.Nil(t, batch)
+			if stage == "cancel" {
+				require.ErrorIs(t, err, context.Canceled)
+			} else if stage != "schema" && stage != "null-count" {
+				require.ErrorIs(t, err, sentinel)
+			}
+			require.NoError(t, op.Close())
+			require.Len(t, sources, 2)
+			for _, source := range sources {
+				require.Equal(t, 1, source.closes)
+			}
+			require.Zero(t, vectorized.QueryMemoryTracker(budget, 0).Used())
+		})
+	}
+}
+
+func TestNativeAggregateMergeDenialBeforeOpenAndGrowthReleases(t *testing.T) {
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	key := aggregateField("key", vectorized.ColumnTypeInt64)
+	for _, scenario := range []string{"constructor-budget", "canceled", "group-cap", "group-budget"} {
+		t.Run(scenario, func(t *testing.T) {
+			limit := int64(1 << 20)
+			switch scenario {
+			case "constructor-budget":
+				limit = 1
+			case "group-budget":
+				limit = 32 << 10
+			default:
+			}
+			budget := vectorized.WithQueryMemoryTracker(t.Context(), limit)
+			ctx, cancel := context.WithCancel(budget)
+			defer cancel()
+			plan := nativeMergePlan([]vectorized.ColumnDef{key}, value, AggregateCount)
+			plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateCount}}
+			if scenario == "group-cap" {
+				plan.MaxGroups = 100
+			}
+			if scenario == "canceled" {
+				cancel()
+			}
+			schema := vectorized.NewBatchSchema([]vectorized.ColumnDef{key, value})
+			batch := vectorized.NewRecordBatch(schema, 1024)
+			for row := range 1024 {
+				batch.Columns[0].(*vectorized.TypedColumn[int64]).Append(int64(row))
+				batch.Columns[1].(*vectorized.TypedColumn[float64]).Append(1)
+			}
+			batch.Len = 1024
+			source := &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}
+			opens := 0
+			op, err := MergeNativeAggregate(ctx, plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) {
+				opens++
+				return source, nil
+			})
+			if scenario == "constructor-budget" || scenario == "canceled" {
+				require.Error(t, err)
+				require.Nil(t, op)
+				require.Zero(t, opens)
+			} else {
+				require.NoError(t, err)
+				require.NoError(t, op.Init(ctx))
+				actual, pullErr := op.NextBatch(ctx)
+				require.ErrorIs(t, pullErr, vectorized.ErrMemoryBudgetExceeded)
+				require.Nil(t, actual)
+				require.NoError(t, op.Close())
+				require.Equal(t, 1, source.closes)
+			}
+			require.Zero(t, vectorized.QueryMemoryTracker(budget, 0).Used())
+		})
+	}
+}
+
+func TestNativeAggregateMergeOwnsKeysBeforeNativeClose(t *testing.T) {
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	key := vectorized.ColumnDef{Name: "key", TagFamily: "default", Role: vectorized.RoleTag, Type: vectorized.ColumnTypeBytes}
+	plan := nativeMergePlan([]vectorized.ColumnDef{key}, value, AggregateCount)
+	plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateCount}}
+	payload := []byte("first")
+	schema := vectorized.NewBatchSchema([]vectorized.ColumnDef{key, value})
+	batch := vectorized.NewRecordBatch(schema, 1)
+	batch.Columns[0].(*vectorized.TypedColumn[[]byte]).Append(payload)
+	batch.Columns[1].(*vectorized.TypedColumn[float64]).Append(2)
+	batch.Len = 1
+	source := &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}, poison: payload}
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	op, err := MergeNativeAggregate(ctx, plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) { return source, nil })
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	actual, err := op.NextBatch(ctx)
+	require.NoError(t, err)
+	require.Equal(t, []byte("xxxxx"), payload)
+	require.Equal(t, []byte("first"), actual.Columns[0].(*vectorized.TypedColumn[[]byte]).Data()[0])
+	require.Equal(t, int64(2), actual.Columns[1].(*vectorized.TypedColumn[int64]).Data()[0])
+	require.NoError(t, op.Close())
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
+
+func TestNativeAggregateMergeCancellationAfterLargeRetainedGroupSet(t *testing.T) {
+	fixture := makeNativeMergeKernelFixture(65537, false)
+	fixture.plan.Aggregate.Calls = fixture.plan.Aggregate.Calls[1:2]
+	fixture.plan.Aggregate.Outputs = []AggregateOutput{{Column: aggregateField("count", vectorized.ColumnTypeInt64)}}
+	fixture.plan.Leaves = []NativeAggregateLeaf{{Source: fixture.plan.Leaves[1].Source, Function: AggregateCount}}
+	budget := vectorized.WithQueryMemoryTracker(t.Context(), 16<<20)
+	ctx, cancel := context.WithCancel(budget)
+	defer cancel()
+	source := &aggregateCancelSource{
+		rawSource: &rawSource{schema: fixture.batches[1][0].Schema, batches: fixture.batches[1]}, cancel: cancel,
+	}
+	op, err := MergeNativeAggregate(ctx, fixture.plan, func(context.Context, NativeAggregateLeaf) (vectorized.PullOperator, error) { return source, nil })
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	batch, err := op.NextBatch(ctx)
+	require.ErrorIs(t, err, context.Canceled)
+	require.Nil(t, batch)
+	stats, ok := NativeAggregateStatistics(op)
+	require.True(t, ok)
+	require.EqualValues(t, 65536, stats.Groups)
+	require.EqualValues(t, 65536, stats.InputRows)
+	require.NoError(t, op.Close())
+	require.Equal(t, 1, source.closes)
+	require.Zero(t, vectorized.QueryMemoryTracker(budget, 0).Used())
+}
diff --git a/pkg/query/ql/aggregate_merge_metrics.go b/pkg/query/ql/aggregate_merge_metrics.go
new file mode 100644
index 000000000..12395d336
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_metrics.go
@@ -0,0 +1,37 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+// NativeAggregateExecutionCounters records successful keyed merge executions.
+// Memory counters cover successful and failed lifecycles; production builds return zero.
+type NativeAggregateExecutionCounters struct {
+	Executions    uint64
+	Branches      uint64
+	InputRows     uint64
+	Groups        uint64
+	GroupCapacity uint64
+	KeyBytes      uint64
+	KeyCapacity   uint64
+	StateBytes    uint64
+	StateCapacity uint64
+	StateLanes    uint64
+	MergeVisits   uint64
+	ReservedBytes uint64
+	ReleasedBytes uint64
+	LiveBytes     int64
+}
diff --git a/pkg/query/ql/aggregate_merge_metrics_disabled.go b/pkg/query/ql/aggregate_merge_metrics_disabled.go
new file mode 100644
index 000000000..67120216f
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_metrics_disabled.go
@@ -0,0 +1,29 @@
+//go:build !querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+// NativeAggregateExecutionStats returns zero when query instrumentation is disabled.
+func NativeAggregateExecutionStats() NativeAggregateExecutionCounters {
+	return NativeAggregateExecutionCounters{}
+}
+
+func recordNativeAggregateExecution(NativeAggregateStats) {}
+func recordNativeAggregateReserve(int64)                  {}
+func recordNativeAggregateRelease(int64)                  {}
diff --git a/pkg/query/ql/aggregate_merge_metrics_enabled.go b/pkg/query/ql/aggregate_merge_metrics_enabled.go
new file mode 100644
index 000000000..b5ac6e054
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_metrics_enabled.go
@@ -0,0 +1,75 @@
+//go:build querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import "sync/atomic"
+
+var nativeAggregateCounters struct {
+	executions    atomic.Uint64
+	branches      atomic.Uint64
+	inputRows     atomic.Uint64
+	groups        atomic.Uint64
+	groupCapacity atomic.Uint64
+	keyBytes      atomic.Uint64
+	keyCapacity   atomic.Uint64
+	stateBytes    atomic.Uint64
+	stateCapacity atomic.Uint64
+	stateLanes    atomic.Uint64
+	mergeVisits   atomic.Uint64
+	reserved      atomic.Uint64
+	released      atomic.Uint64
+	live          atomic.Int64
+}
+
+// NativeAggregateExecutionStats returns process-wide native aggregate merge counters.
+func NativeAggregateExecutionStats() NativeAggregateExecutionCounters {
+	c := &nativeAggregateCounters
+	return NativeAggregateExecutionCounters{
+		Executions: c.executions.Load(), Branches: c.branches.Load(), InputRows: c.inputRows.Load(), Groups: c.groups.Load(),
+		GroupCapacity: c.groupCapacity.Load(), KeyBytes: c.keyBytes.Load(), KeyCapacity: c.keyCapacity.Load(),
+		StateBytes: c.stateBytes.Load(), StateCapacity: c.stateCapacity.Load(), StateLanes: c.stateLanes.Load(), MergeVisits: c.mergeVisits.Load(),
+		ReservedBytes: c.reserved.Load(), ReleasedBytes: c.released.Load(), LiveBytes: c.live.Load(),
+	}
+}
+
+func recordNativeAggregateExecution(s NativeAggregateStats) {
+	c := &nativeAggregateCounters
+	c.executions.Add(1)
+	c.branches.Add(s.Branches)
+	c.inputRows.Add(s.InputRows)
+	c.groups.Add(s.Groups)
+	c.groupCapacity.Add(s.GroupCapacity)
+	c.keyBytes.Add(s.KeyBytes)
+	c.keyCapacity.Add(s.KeyCapacity)
+	c.stateBytes.Add(s.StateBytes)
+	c.stateCapacity.Add(s.StateCapacity)
+	c.stateLanes.Add(s.StateLanes)
+	c.mergeVisits.Add(s.MergeVisits)
+}
+
+func recordNativeAggregateReserve(bytes int64) {
+	nativeAggregateCounters.reserved.Add(uint64(bytes))
+	nativeAggregateCounters.live.Add(bytes)
+}
+
+func recordNativeAggregateRelease(bytes int64) {
+	nativeAggregateCounters.released.Add(uint64(bytes))
+	nativeAggregateCounters.live.Add(-bytes)
+}
diff --git a/pkg/query/ql/aggregate_merge_metrics_test.go b/pkg/query/ql/aggregate_merge_metrics_test.go
new file mode 100644
index 000000000..e2bd725b0
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_metrics_test.go
@@ -0,0 +1,41 @@
+//go:build querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestNativeAggregateMergeReportsIndependentOwnershipAndLinearVisits(t *testing.T) {
+	before, localBefore := NativeAggregateExecutionStats(), AggregateExecutionStats()
+	stats := validateNativeMergeKernel(t, makeNativeMergeKernelFixture(1024, true))
+	after, localAfter := NativeAggregateExecutionStats(), AggregateExecutionStats()
+	require.EqualValues(t, 1, after.Executions-before.Executions)
+	require.Equal(t, stats.Branches, after.Branches-before.Branches)
+	require.Equal(t, stats.InputRows, after.InputRows-before.InputRows)
+	require.Equal(t, stats.Groups, after.Groups-before.Groups)
+	require.Equal(t, stats.MergeVisits, after.MergeVisits-before.MergeVisits)
+	require.Equal(t, stats.StateCapacity, after.StateCapacity-before.StateCapacity)
+	require.Equal(t, after.ReservedBytes-before.ReservedBytes, after.ReleasedBytes-before.ReleasedBytes)
+	require.Equal(t, before.LiveBytes, after.LiveBytes)
+	require.Equal(t, localBefore, localAfter)
+}
diff --git a/pkg/query/ql/aggregate_merge_output.go b/pkg/query/ql/aggregate_merge_output.go
new file mode 100644
index 000000000..481b450b0
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_output.go
@@ -0,0 +1,118 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+// NativeAggregateStats describes keyed merge work and retained capacity.
+type NativeAggregateStats struct {
+	Branches      uint64
+	InputRows     uint64
+	Groups        uint64
+	GroupCapacity uint64
+	KeyBytes      uint64
+	KeyCapacity   uint64
+	StateBytes    uint64 // Includes two bytes of branch-presence flags per source/group.
+	StateCapacity uint64
+	StateLanes    uint64 // Fixed-width eight-byte lanes, excluding presence flags.
+	MergeVisits   uint64
+}
+
+// NativeAggregateStatistics reports keyed merge work, including after Close.
+func NativeAggregateStatistics(source vectorized.PullOperator) (NativeAggregateStats, bool) {
+	if op, ok := source.(*nativeAggregateOperator); ok {
+		return op.statistics(), true
+	}
+	if raw, ok := source.(*rawOperator); ok {
+		return NativeAggregateStatistics(raw.source)
+	}
+	return NativeAggregateStats{}, false
+}
+
+func (o *nativeAggregateOperator) statistics() NativeAggregateStats {
+	stats := o.stats
+	if o.closed {
+		return stats
+	}
+	stats.Groups, stats.GroupCapacity = uint64(len(o.keys)), uint64(o.capacity)
+	stats.KeyBytes, stats.KeyCapacity = uint64(o.keyBytes), uint64(o.tableBytes+o.keyCapacity+o.scratchSize)
+	for index := range o.states {
+		stats.StateLanes += uint64(o.states[index].lanes())
+	}
+	stats.StateBytes = stats.Groups * (stats.StateLanes*8 + uint64(len(o.states))*2)
+	stats.StateCapacity = uint64(o.stateBytes)
+	return stats
+}
+
+func (o *nativeAggregateOperator) emit(ctx context.Context) (*vectorized.RecordBatch, error) {
+	if o.output == nil {
+		capacity := min(vectorized.DefaultBatchSize, len(o.keys))
+		bytes := int64(capacity)*int64(len(o.outputs))*25 + int64(len(o.outputs))*128
+		if err := o.reserve(bytes); err != nil {
+			return nil, err
+		}
+		o.outputBytes = bytes
+		o.output = vectorized.NewRecordBatch(o.schema, capacity)
+	} else {
+		o.output.Reset()
+	}
+	o.release(o.valueBytes)
+	o.valueBytes = 0
+	end := min(o.position+vectorized.DefaultBatchSize, len(o.keys))
+	var valueBytes int64
+	for group := o.position; group < end; group++ {
+		decodeGroupValues(o.keys[group], o.groupValues)
+		for _, output := range o.outputs {
+			if output.IsGroup && output.Column.Type == vectorized.ColumnTypeBytes {
+				bytes, err := groupAllocationBytes(int64(len(o.groupValues[output.Index].text)))
+				if err != nil {
+					return nil, err
+				}
+				valueBytes += bytes
+			}
+		}
+	}
+	if err := o.reserve(valueBytes); err != nil {
+		return nil, err
+	}
+	o.valueBytes = valueBytes
+	for group := o.position; group < end; group++ {
+		if (group-o.position)%1024 == 0 {
+			if err := ctx.Err(); err != nil {
+				return nil, err
+			}
+		}
+		decodeGroupValues(o.keys[group], o.groupValues)
+		for index, output := range o.outputs {
+			var value aggregateValue
+			if output.IsGroup {
+				value = o.groupValues[output.Index]
+			} else {
+				call := o.calls[output.Index]
+				value = o.states[call.state].result(group, call.function)
+			}
+			appendAggregateValue(o.output.Columns[index], value)
+		}
+	}
+	o.output.Len, o.position = end-o.position, end
+	return o.output, nil
+}
diff --git a/pkg/query/ql/aggregate_merge_resource_test.go b/pkg/query/ql/aggregate_merge_resource_test.go
new file mode 100644
index 000000000..ebbb5bd84
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_resource_test.go
@@ -0,0 +1,121 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func TestNativeAggregateMergeResourceBoundsAndRecovery(t *testing.T) {
+	for _, groups := range []int{100_000, 1_000_000} {
+		t.Run(fmt.Sprintf("groups=%d", groups), func(t *testing.T) {
+			fixture := makeNativeMergeKernelFixture(groups, true)
+			for _, limitMiB := range []int64{16, 256} {
+				t.Run(fmt.Sprintf("memory=%dMiB", limitMiB), func(t *testing.T) {
+					ctx := vectorized.WithQueryMemoryTracker(t.Context(), limitMiB<<20)
+					stats, completed := verifyNativeMergeResourceResult(ctx, t, fixture)
+					outcome := "complete"
+					if !completed {
+						outcome = "memory-budget-exceeded"
+					}
+					t.Logf("groups=%d memory=%dMiB outcome=%s input-group-rows=%d retained-groups=%d key-capacity=%d state-capacity=%d",
+						groups, limitMiB, outcome, stats.InputRows, stats.Groups, stats.KeyCapacity, stats.StateCapacity)
+					_, recovered := verifyNativeMergeResourceResult(ctx, t, makeNativeMergeKernelFixture(3, true))
+					require.True(t, recovered, "a small query must recover using the same released memory tracker")
+				})
+			}
+		})
+	}
+}
+
+// Fixture inputs and the independent expected-value map are caller-owned. The
+// tracker limits only the real merge operator's retained and output allocations.
+func verifyNativeMergeResourceResult(ctx context.Context, t *testing.T, fixture nativeMergeKernelFixture) (NativeAggregateStats, bool) {
+	t.Helper()
+	var sources []*rawSource
+	op, err := MergeNativeAggregate(ctx, fixture.plan, func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+		branch := 0
+		if leaf.Function == AggregateCount {
+			branch = 1
+		}
+		batches := fixture.batches[branch]
+		source := &rawSource{schema: batches[0].Schema, batches: append([]*vectorized.RecordBatch(nil), batches...)}
+		sources = append(sources, source)
+		return source, nil
+	})
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	var expected map[nativeMergeKernelKey]nativeMergeKernelResult
+	var rows int
+	completed := true
+	for {
+		batch, pullErr := op.NextBatch(ctx)
+		if pullErr != nil {
+			require.True(t, errors.Is(pullErr, vectorized.ErrMemoryBudgetExceeded), "unexpected query failure: %v", pullErr)
+			require.Nil(t, batch, "a rejected merge cannot return partial data")
+			require.Zero(t, rows, "a rejected merge cannot emit earlier partial batches")
+			completed = false
+			break
+		}
+		if batch == nil {
+			break
+		}
+		if expected == nil {
+			// The scalar oracle reads the two independent native input lists;
+			// it does not inspect the merge's key encoding, lanes or index.
+			expected = nativeMergeKernelReference(fixture)
+		}
+		for row := range batch.Len {
+			key := nativeMergeKernelKey{
+				high: batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[row],
+				low:  batch.Columns[1].(*vectorized.TypedColumn[int64]).Data()[row],
+			}
+			want, exists := expected[key]
+			require.True(t, exists, "group %+v is unexpected or repeated", key)
+			count := batch.Columns[3].(*vectorized.TypedColumn[int64]).Data()[row]
+			require.Equal(t, want.count, count, "COUNT for %+v", key)
+			require.Equal(t, count == 0, batch.Columns[2].IsNull(row), "SUM nullness for %+v", key)
+			require.Equal(t, count == 0, batch.Columns[4].IsNull(row), "AVG nullness for %+v", key)
+			if count != 0 {
+				require.Equal(t, want.sum, batch.Columns[2].(*vectorized.TypedColumn[float64]).Data()[row], "SUM for %+v", key)
+				require.Equal(t, want.sum/float64(want.count), batch.Columns[4].(*vectorized.TypedColumn[float64]).Data()[row], "AVG for %+v", key)
+			}
+			delete(expected, key)
+			rows++
+		}
+	}
+	if completed {
+		require.Equal(t, len(fixture.sums), rows, "every expected group must be emitted")
+		require.Empty(t, expected, "no expected group may be omitted")
+	}
+	stats, ok := NativeAggregateStatistics(op)
+	require.True(t, ok)
+	require.NoError(t, op.Close())
+	for _, source := range sources {
+		require.Equal(t, 1, source.closes)
+	}
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used(), "Close must release every reservation")
+	return stats, completed
+}
diff --git a/pkg/query/ql/aggregate_merge_test.go b/pkg/query/ql/aggregate_merge_test.go
new file mode 100644
index 000000000..3e62cb50e
--- /dev/null
+++ b/pkg/query/ql/aggregate_merge_test.go
@@ -0,0 +1,116 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func nativeMergePlan(groups []vectorized.ColumnDef, source vectorized.ColumnDef, functions ...AggregateFunction) *NativeAggregatePlan {
+	plan := &AggregatePlan{GroupBy: groups}
+	for index, group := range groups {
+		output := group
+		output.Entity = false
+		plan.Outputs = append(plan.Outputs, AggregateOutput{Column: output, Index: index, IsGroup: true})
+	}
+	for index, function := range functions {
+		call := AggregateCall{Source: source, Function: function}
+		plan.Calls = append(plan.Calls, call)
+		plan.Outputs = append(plan.Outputs, AggregateOutput{Column: aggregateField(string(rune('a'+index)), aggregateOutputType(call)), Index: index})
+	}
+	return &NativeAggregatePlan{Aggregate: plan, MaxCount: 100}
+}
+
+func TestNativeAggregateMergeMatchesFullKeysAcrossLeafOrders(t *testing.T) {
+	groups := []vectorized.ColumnDef{
+		{Name: "zone", TagFamily: "default", Role: vectorized.RoleTag, Type: vectorized.ColumnTypeString},
+		aggregateField("rack", vectorized.ColumnTypeInt64),
+	}
+	value := aggregateField("v", vectorized.ColumnTypeFloat64)
+	for _, reverse := range []bool{false, true} {
+		plan := nativeMergePlan(groups, value, AggregateSum, AggregateCount, AggregateAvg, AggregateSum)
+		plan.Leaves = []NativeAggregateLeaf{{Source: value, Function: AggregateSum}, {Source: value, Function: AggregateCount}}
+		if reverse {
+			plan.Leaves[0], plan.Leaves[1] = plan.Leaves[1], plan.Leaves[0]
+		}
+		ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+		var opened []*rawSource
+		open := func(_ context.Context, leaf NativeAggregateLeaf) (vectorized.PullOperator, error) {
+			for _, previous := range opened {
+				require.Equal(t, 1, previous.closes, "a leaf must close before the next opens")
+			}
+			schema := vectorized.NewBatchSchema(append(append([]vectorized.ColumnDef(nil), groups...), value))
+			batch := vectorized.NewRecordBatch(schema, 3)
+			zones := []string{"a", "a", "b"}
+			racks := []int64{1, 2, 1}
+			values := []float64{12, 9, 0}
+			if leaf.Function == AggregateCount {
+				zones, racks, values = []string{"b", "a", "a"}, []int64{1, 2, 1}, []float64{0, 3, 2}
+			}
+			batch.Columns[0].(*vectorized.TypedColumn[string]).AppendSlice(zones)
+			batch.Columns[1].(*vectorized.TypedColumn[int64]).AppendSlice(racks)
+			batch.Columns[2].(*vectorized.TypedColumn[float64]).AppendSlice(values)
+			batch.Len = 3
+			source := &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}
+			opened = append(opened, source)
+			return source, nil
+		}
+		op, err := MergeNativeAggregate(ctx, plan, open)
+		require.NoError(t, err)
+		require.NoError(t, op.Init(ctx))
+		batch, err := op.NextBatch(ctx)
+		require.NoError(t, err)
+		require.Equal(t, 3, batch.Len)
+		seen := map[string]bool{}
+		for row := range batch.Len {
+			zone := batch.Columns[0].(*vectorized.TypedColumn[string]).Data()[row]
+			rack := batch.Columns[1].(*vectorized.TypedColumn[int64]).Data()[row]
+			key := zone + string(rune('0'+rack))
+			require.False(t, seen[key])
+			seen[key] = true
+			switch key {
+			case "a1":
+				require.Equal(t, 12., batch.Columns[2].(*vectorized.TypedColumn[float64]).Data()[row])
+				require.Equal(t, int64(2), batch.Columns[3].(*vectorized.TypedColumn[int64]).Data()[row])
+				require.Equal(t, 6., batch.Columns[4].(*vectorized.TypedColumn[float64]).Data()[row])
+			case "a2":
+				require.Equal(t, 9., batch.Columns[2].(*vectorized.TypedColumn[float64]).Data()[row])
+				require.Equal(t, int64(3), batch.Columns[3].(*vectorized.TypedColumn[int64]).Data()[row])
+				require.Equal(t, 3., batch.Columns[4].(*vectorized.TypedColumn[float64]).Data()[row])
+			case "b1":
+				require.Zero(t, batch.Columns[3].(*vectorized.TypedColumn[int64]).Data()[row])
+				require.True(t, batch.Columns[2].IsNull(row))
+				require.True(t, batch.Columns[4].IsNull(row))
+			default:
+				t.Fatalf("unexpected group %q", key)
+			}
+			require.Equal(t, batch.Columns[2].IsNull(row), batch.Columns[5].IsNull(row))
+			require.Equal(t, batch.Columns[2].(*vectorized.TypedColumn[float64]).Data()[row], batch.Columns[5].(*vectorized.TypedColumn[float64]).Data()[row])
+		}
+		require.NoError(t, op.Close())
+		require.NoError(t, op.Close())
+		require.Len(t, opened, 2)
+		require.Equal(t, 1, opened[1].closes)
+		require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+	}
+}
diff --git a/pkg/query/ql/aggregate_metrics.go b/pkg/query/ql/aggregate_metrics.go
new file mode 100644
index 000000000..9beba7c75
--- /dev/null
+++ b/pkg/query/ql/aggregate_metrics.go
@@ -0,0 +1,37 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+// AggregateExecutionCounters records cumulative local aggregate work and retained
+// capacity at successful scan completion. Capacity deltas divided by Executions
+// describe each execution; memory counters cover all lifecycle paths.
+// Production builds return zero counters.
+type AggregateExecutionCounters struct {
+	Executions    uint64
+	InputRows     uint64
+	Groups        uint64
+	GroupCapacity uint64
+	KeyBytes      uint64
+	KeyCapacity   uint64
+	StateBytes    uint64
+	StateCapacity uint64
+	StateLanes    uint64
+	ReservedBytes uint64
+	ReleasedBytes uint64
+	LiveBytes     int64
+}
diff --git a/pkg/query/ql/aggregate_metrics_disabled.go b/pkg/query/ql/aggregate_metrics_disabled.go
new file mode 100644
index 000000000..147bed1c0
--- /dev/null
+++ b/pkg/query/ql/aggregate_metrics_disabled.go
@@ -0,0 +1,26 @@
+//go:build !querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+// AggregateExecutionStats returns zero counters in production builds.
+func AggregateExecutionStats() AggregateExecutionCounters { return AggregateExecutionCounters{} }
+func recordAggregateExecution(AggregateStats)             {}
+func recordAggregateReserve(int64)                        {}
+func recordAggregateRelease(int64)                        {}
diff --git a/pkg/query/ql/aggregate_metrics_enabled.go b/pkg/query/ql/aggregate_metrics_enabled.go
new file mode 100644
index 000000000..1b8bf7282
--- /dev/null
+++ b/pkg/query/ql/aggregate_metrics_enabled.go
@@ -0,0 +1,70 @@
+//go:build querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import "sync/atomic"
+
+var aggregateCounters struct {
+	executions    atomic.Uint64
+	inputRows     atomic.Uint64
+	groups        atomic.Uint64
+	groupCapacity atomic.Uint64
+	keyBytes      atomic.Uint64
+	keyCapacity   atomic.Uint64
+	stateBytes    atomic.Uint64
+	stateCapacity atomic.Uint64
+	stateLanes    atomic.Uint64
+	reserved      atomic.Uint64
+	released      atomic.Uint64
+	live          atomic.Int64
+}
+
+// AggregateExecutionStats returns process-wide local aggregation counters.
+func AggregateExecutionStats() AggregateExecutionCounters {
+	c := &aggregateCounters
+	return AggregateExecutionCounters{
+		Executions: c.executions.Load(), InputRows: c.inputRows.Load(), Groups: c.groups.Load(), GroupCapacity: c.groupCapacity.Load(),
+		KeyBytes: c.keyBytes.Load(), KeyCapacity: c.keyCapacity.Load(), StateBytes: c.stateBytes.Load(), StateCapacity: c.stateCapacity.Load(),
+		StateLanes: c.stateLanes.Load(), ReservedBytes: c.reserved.Load(), ReleasedBytes: c.released.Load(), LiveBytes: c.live.Load(),
+	}
+}
+
+func recordAggregateExecution(s AggregateStats) {
+	c := &aggregateCounters
+	c.executions.Add(1)
+	c.inputRows.Add(s.InputRows)
+	c.groups.Add(s.Groups)
+	c.groupCapacity.Add(s.GroupCapacity)
+	c.keyBytes.Add(s.KeyBytes)
+	c.keyCapacity.Add(s.KeyCapacity)
+	c.stateBytes.Add(s.StateBytes)
+	c.stateCapacity.Add(s.StateCapacity)
+	c.stateLanes.Add(s.StateLanes)
+}
+
+func recordAggregateReserve(bytes int64) {
+	aggregateCounters.reserved.Add(uint64(bytes))
+	aggregateCounters.live.Add(bytes)
+}
+
+func recordAggregateRelease(bytes int64) {
+	aggregateCounters.released.Add(uint64(bytes))
+	aggregateCounters.live.Add(-bytes)
+}
diff --git a/pkg/query/ql/aggregate_metrics_test.go b/pkg/query/ql/aggregate_metrics_test.go
new file mode 100644
index 000000000..681eef687
--- /dev/null
+++ b/pkg/query/ql/aggregate_metrics_test.go
@@ -0,0 +1,57 @@
+//go:build querymetrics
+
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func TestAggregateMetricsCaptureCompletedScanAndRelease(t *testing.T) {
+	before := AggregateExecutionStats()
+	source, defs := aggregateFixture(false)
+	plan := aggregateCallsPlan(defs)
+	plan.GroupBy = defs[:1]
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	op, err := WrapAggregate(ctx, source, plan)
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	_, err = op.NextBatch(ctx)
+	require.NoError(t, err)
+	stats, ok := AggregateStatistics(op)
+	require.True(t, ok)
+	live := AggregateExecutionStats()
+	require.EqualValues(t, 1, live.Executions-before.Executions)
+	require.EqualValues(t, 6, live.InputRows-before.InputRows)
+	require.EqualValues(t, 4, live.Groups-before.Groups)
+	require.Equal(t, stats.StateBytes, live.StateBytes-before.StateBytes)
+	require.Equal(t, stats.StateCapacity, live.StateCapacity-before.StateCapacity)
+	require.Equal(t, stats.KeyBytes, live.KeyBytes-before.KeyBytes)
+	require.Equal(t, stats.KeyCapacity, live.KeyCapacity-before.KeyCapacity)
+	require.Greater(t, live.LiveBytes, before.LiveBytes)
+	require.NoError(t, op.Close())
+	after := AggregateExecutionStats()
+	require.Equal(t, before.LiveBytes, after.LiveBytes)
+	require.Equal(t, after.ReservedBytes-before.ReservedBytes, after.ReleasedBytes-before.ReleasedBytes)
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
diff --git a/pkg/query/ql/aggregate_output.go b/pkg/query/ql/aggregate_output.go
new file mode 100644
index 000000000..89776b9a0
--- /dev/null
+++ b/pkg/query/ql/aggregate_output.go
@@ -0,0 +1,82 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func (o *aggregateOperator) emit(ctx context.Context) (*vectorized.RecordBatch, error) {
+	if o.output == nil {
+		capacity := min(vectorized.DefaultBatchSize, len(o.groups.keys))
+		// Every column is at most a 24-byte slice cell plus its null bitmap.
+		bytes := int64(capacity)*int64(len(o.outputs))*25 + int64(len(o.outputs))*128
+		if err := o.reserve(bytes); err != nil {
+			return nil, err
+		}
+		o.outputBytes = bytes
+		o.output = vectorized.NewRecordBatch(o.schema, capacity)
+	} else {
+		o.output.Reset()
+	}
+	o.release(o.outputValueBytes)
+	o.outputValueBytes = 0
+	end := min(o.position+vectorized.DefaultBatchSize, len(o.groups.keys))
+	// Byte grouping outputs need their own []byte cells. String grouping outputs
+	// lend immutable key substrings and therefore require no payload allocation.
+	var valueBytes int64
+	for group := o.position; group < end; group++ {
+		decodeGroupValues(o.groups.keys[group], o.groupValues)
+		for _, output := range o.outputs {
+			if output.IsGroup && output.Column.Type == vectorized.ColumnTypeBytes {
+				bytes, allocationErr := groupAllocationBytes(int64(len(o.groupValues[output.Index].text)))
+				if allocationErr != nil {
+					return nil, allocationErr
+				}
+				valueBytes += bytes
+			}
+		}
+	}
+	if err := o.reserve(valueBytes); err != nil {
+		return nil, err
+	}
+	o.outputValueBytes = valueBytes
+	for group := o.position; group < end; group++ {
+		if (group-o.position)%1024 == 0 {
+			if err := ctx.Err(); err != nil {
+				return nil, err
+			}
+		}
+		decodeGroupValues(o.groups.keys[group], o.groupValues)
+		for index, output := range o.outputs {
+			var value aggregateValue
+			if output.IsGroup {
+				value = o.groupValues[output.Index]
+			} else {
+				call := o.calls[output.Index]
+				value = o.states[call.state].result(group, call.function)
+			}
+			appendAggregateValue(o.output.Columns[index], value)
+		}
+	}
+	o.output.Len = end - o.position
+	o.position = end
+	return o.output, nil
+}
diff --git a/pkg/query/ql/aggregate_query.go b/pkg/query/ql/aggregate_query.go
new file mode 100644
index 000000000..488dd705d
--- /dev/null
+++ b/pkg/query/ql/aggregate_query.go
@@ -0,0 +1,53 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func wrapAggregateQuery(ctx context.Context, source vectorized.PullOperator, plan *RawPlan) (vectorized.PullOperator, error) {
+	ctx = vectorized.WithQueryMemoryTracker(ctx, 256<<20)
+	input := *plan
+	input.Aggregate = nil
+	input.Limit = nil
+	input.Offset = 0
+	filtered, err := WrapRaw(ctx, source, &input)
+	if err != nil {
+		return nil, err
+	}
+	grouped, err := WrapAggregate(ctx, filtered, plan.Aggregate)
+	if err != nil {
+		return nil, err
+	}
+	tracker := vectorized.QueryMemoryTracker(ctx, 0)
+	bytes := int64(len(plan.Aggregate.Outputs)) * 256
+	if err = tracker.Reserve(bytes); err != nil {
+		_ = grouped.Close()
+		return nil, err
+	}
+	defer tracker.Release(bytes)
+	outputs := make([]Slot, len(plan.Aggregate.Outputs))
+	for index, output := range plan.Aggregate.Outputs {
+		outputs[index] = Slot{ID: SlotID(index), Source: output.Column, Output: output.Column, Nullable: true}
+	}
+	// Pagination applies to finalized groups, never to the raw aggregate input.
+	return WrapRaw(ctx, grouped, &RawPlan{Slots: outputs, Limit: plan.Limit, Offset: plan.Offset})
+}
diff --git a/pkg/query/ql/aggregate_query_test.go b/pkg/query/ql/aggregate_query_test.go
new file mode 100644
index 000000000..5170a6ac0
--- /dev/null
+++ b/pkg/query/ql/aggregate_query_test.go
@@ -0,0 +1,103 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func TestMeasureAggregatePipelineFiltersBeforeAggregationAndPaginatesGroups(t *testing.T) {
+	source, _ := rawFixture()
+	defs := source.schema.Columns
+	outputs := []AggregateOutput{
+		{Index: 0, Column: vectorized.ColumnDef{Name: "c", Role: vectorized.RoleField, Type: vectorized.ColumnTypeInt64}},
+		{Index: 1, Column: vectorized.ColumnDef{Name: "s", Role: vectorized.RoleField, Type: vectorized.ColumnTypeInt64}},
+		{Index: 2, Column: vectorized.ColumnDef{Name: "a", Role: vectorized.RoleField, Type: vectorized.ColumnTypeFloat64}},
+	}
+	aggregate := &AggregatePlan{Calls: []AggregateCall{
+		{Source: defs[4], Function: AggregateCount},
+		{Source: defs[4], Function: AggregateSum},
+		{Source: defs[4], Function: AggregateAvg},
+	}, Outputs: outputs}
+	limit := uint32(1)
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	plan := &RawPlan{
+		Slots: []Slot{{Source: defs[4], Output: defs[4]}}, Aggregate: aggregate, Limit: &limit,
+		Residual: true, Ranges: []MillisRange{{Begin: 0, End: 1000}, {Begin: 2000, End: 3001}},
+	}
+	op, err := WrapRaw(ctx, source, plan)
+	require.NoError(t, err)
+	iterator, err := RawIterator(ctx, op)
+	require.NoError(t, err)
+	require.True(t, iterator.Next())
+	row := iterator.Current()[0].DataPoint
+	require.EqualValues(t, 2, row.Fields[0].Value.GetInt().Value)
+	require.EqualValues(t, 3, row.Fields[1].Value.GetInt().Value)
+	require.Equal(t, 1.5, row.Fields[2].Value.GetFloat().Value)
+	require.Nil(t, row.Timestamp)
+	require.Zero(t, row.Sid)
+	require.Zero(t, row.Version)
+	require.False(t, iterator.Next())
+	require.NoError(t, iterator.Close())
+	require.Equal(t, 2, source.pulls)
+	require.Equal(t, 1, source.closes)
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
+
+func TestMeasureAggregatePipelineHandlesEmptyInputAndFinalPagination(t *testing.T) {
+	for _, grouped := range []bool{false, true} {
+		for _, offset := range []uint32{0, 1} {
+			source, _ := rawFixture()
+			defs := source.schema.Columns
+			aggregate := &AggregatePlan{
+				Calls: []AggregateCall{{Source: defs[4], Function: AggregateCount}, {Source: defs[4], Function: AggregateSum}},
+				Outputs: []AggregateOutput{
+					{Index: 0, Column: vectorized.ColumnDef{Name: "c", Role: vectorized.RoleField, Type: vectorized.ColumnTypeInt64}},
+					{Index: 1, Column: vectorized.ColumnDef{Name: "s", Role: vectorized.RoleField, Type: vectorized.ColumnTypeInt64}},
+				},
+			}
+			if grouped {
+				aggregate.GroupBy = []vectorized.ColumnDef{defs[3]}
+			}
+			ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+			op, err := WrapRaw(ctx, source, &RawPlan{
+				Slots:     []Slot{{Source: defs[3], Output: defs[3]}, {Source: defs[4], Output: defs[4]}},
+				Aggregate: aggregate, Empty: true, Offset: offset,
+			})
+			require.NoError(t, err)
+			iterator, err := RawIterator(ctx, op)
+			require.NoError(t, err)
+			if !grouped && offset == 0 {
+				require.True(t, iterator.Next())
+				row := iterator.Current()[0].DataPoint
+				require.EqualValues(t, 0, row.Fields[0].Value.GetInt().Value)
+				require.NotNil(t, row.Fields[1].Value.GetNull())
+			}
+			require.False(t, iterator.Next())
+			require.NoError(t, iterator.Close())
+			require.Zero(t, source.inits)
+			require.Zero(t, source.pulls)
+			require.Equal(t, 1, source.closes)
+			require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+		}
+	}
+}
diff --git a/pkg/query/ql/aggregate_state.go b/pkg/query/ql/aggregate_state.go
new file mode 100644
index 000000000..546987a7d
--- /dev/null
+++ b/pkg/query/ql/aggregate_state.go
@@ -0,0 +1,183 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"fmt"
+	"math"
+
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+type aggregateState struct {
+	count    []int64
+	sumInt   []int64
+	sumFloat []float64
+	minInt   []int64
+	maxInt   []int64
+	minFloat []float64
+	maxFloat []float64
+	source   vectorized.ColumnDef
+	needs    uint8
+}
+
+const (
+	aggregateNeedsIntSum uint8 = 1 << iota
+	aggregateNeedsFloatSum
+	aggregateNeedsMin
+	aggregateNeedsMax
+)
+
+func (s *aggregateState) require(function AggregateFunction) {
+	switch function {
+	case AggregateCount:
+	case AggregateSum:
+		if s.source.Type == vectorized.ColumnTypeInt64 {
+			s.needs |= aggregateNeedsIntSum
+		} else {
+			s.needs |= aggregateNeedsFloatSum
+		}
+	case AggregateAvg:
+		s.needs |= aggregateNeedsFloatSum
+	case AggregateMin:
+		s.needs |= aggregateNeedsMin
+	case AggregateMax:
+		s.needs |= aggregateNeedsMax
+	}
+}
+
+func (s *aggregateState) lanes() int {
+	lanes := 1 // Exact non-null count also distinguishes an all-null group.
+	for mask := uint8(1); mask <= aggregateNeedsMax; mask <<= 1 {
+		if s.needs&mask != 0 {
+			lanes++
+		}
+	}
+	return lanes
+}
+
+func growAggregateLane[T int64 | float64](old []T, capacity int) []T {
+	lane := make([]T, capacity)
+	copy(lane, old)
+	return lane
+}
+
+func (s *aggregateState) grow(capacity int) {
+	s.count = growAggregateLane(s.count, capacity)
+	if s.needs&aggregateNeedsIntSum != 0 {
+		s.sumInt = growAggregateLane(s.sumInt, capacity)
+	}
+	if s.needs&aggregateNeedsFloatSum != 0 {
+		s.sumFloat = growAggregateLane(s.sumFloat, capacity)
+	}
+	if s.source.Type == vectorized.ColumnTypeInt64 {
+		if s.needs&aggregateNeedsMin != 0 {
+			s.minInt = growAggregateLane(s.minInt, capacity)
+		}
+		if s.needs&aggregateNeedsMax != 0 {
+			s.maxInt = growAggregateLane(s.maxInt, capacity)
+		}
+	} else {
+		if s.needs&aggregateNeedsMin != 0 {
+			s.minFloat = growAggregateLane(s.minFloat, capacity)
+		}
+		if s.needs&aggregateNeedsMax != 0 {
+			s.maxFloat = growAggregateLane(s.maxFloat, capacity)
+		}
+	}
+}
+
+func (s *aggregateState) update(group int, value aggregateValue) error {
+	if value.null {
+		return nil
+	}
+	if s.count[group] == math.MaxInt64 {
+		return fmt.Errorf("aggregate COUNT(%s) exceeds int64", s.source.Name)
+	}
+	first := s.count[group] == 0
+	s.count[group]++
+	if s.needs&aggregateNeedsIntSum != 0 {
+		previous := s.sumInt[group]
+		if (value.i64 > 0 && previous > math.MaxInt64-value.i64) || (value.i64 < 0 && previous < math.MinInt64-value.i64) {
+			return fmt.Errorf("aggregate SUM(%s) exceeds int64", s.source.Name)
+		}
+		s.sumInt[group] += value.i64
+	}
+	if s.needs&aggregateNeedsFloatSum != 0 {
+		number := value.f64
+		if value.typ == vectorized.ColumnTypeInt64 {
+			number = float64(value.i64)
+		}
+		s.sumFloat[group] += number
+	}
+	if s.source.Type == vectorized.ColumnTypeInt64 {
+		if s.needs&aggregateNeedsMin != 0 && (first || value.i64 < s.minInt[group]) {
+			s.minInt[group] = value.i64
+		}
+		if s.needs&aggregateNeedsMax != 0 && (first || value.i64 > s.maxInt[group]) {
+			s.maxInt[group] = value.i64
+		}
+	} else {
+		if s.needs&aggregateNeedsMin != 0 && (first || aggregateFloatLess(value.f64, s.minFloat[group])) {
+			s.minFloat[group] = value.f64
+		}
+		if s.needs&aggregateNeedsMax != 0 && (first || aggregateFloatLess(s.maxFloat[group], value.f64)) {
+			s.maxFloat[group] = value.f64
+		}
+	}
+	return nil
+}
+
+func aggregateFloatLess(left, right float64) bool {
+	if math.IsNaN(left) {
+		return false
+	}
+	return math.IsNaN(right) || left < right
+}
+
+func (s *aggregateState) result(group int, function AggregateFunction) aggregateValue {
+	value := aggregateValue{typ: s.source.Type, null: s.count[group] == 0}
+	switch function {
+	case AggregateCount:
+		value.typ, value.i64, value.null = vectorized.ColumnTypeInt64, s.count[group], false
+	case AggregateSum:
+		if s.source.Type == vectorized.ColumnTypeInt64 {
+			value.i64 = s.sumInt[group]
+		} else {
+			value.f64 = s.sumFloat[group]
+		}
+	case AggregateMin:
+		if s.source.Type == vectorized.ColumnTypeInt64 {
+			value.i64 = s.minInt[group]
+		} else {
+			value.f64 = s.minFloat[group]
+		}
+	case AggregateMax:
+		if s.source.Type == vectorized.ColumnTypeInt64 {
+			value.i64 = s.maxInt[group]
+		} else {
+			value.f64 = s.maxFloat[group]
+		}
+	case AggregateAvg:
+		value.typ = vectorized.ColumnTypeFloat64
+		if !value.null {
+			value.f64 = s.sumFloat[group] / float64(s.count[group])
+		}
+	}
+	return value
+}
diff --git a/pkg/query/ql/aggregate_test.go b/pkg/query/ql/aggregate_test.go
new file mode 100644
index 000000000..7b6bd8c68
--- /dev/null
+++ b/pkg/query/ql/aggregate_test.go
@@ -0,0 +1,479 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"context"
+	"fmt"
+	"math"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/convert"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+func aggregateField(name string, typ vectorized.ColumnType) vectorized.ColumnDef {
+	return vectorized.ColumnDef{Name: name, Role: vectorized.RoleField, Type: typ}
+}
+
+func aggregateFixture(passthrough bool) (*rawSource, []vectorized.ColumnDef) {
+	defs := []vectorized.ColumnDef{
+		{
+			Name:      "zone",
+			TagFamily: "default",
+			Role:      vectorized.RoleTag,
+			Type:      vectorized.ColumnTypeString,
+		},
+		aggregateField("v", vectorized.ColumnTypeInt64),
+		aggregateField("f", vectorized.ColumnTypeFloat64),
+	}
+	actual := append([]vectorized.ColumnDef(nil), defs...)
+	if passthrough {
+		actual[0].Type = vectorized.ColumnTypeTagValue
+		actual[1].Type = vectorized.ColumnTypeFieldValue
+		actual[2].Type = vectorized.ColumnTypeFieldValue
+	}
+	schema := vectorized.NewBatchSchema(actual)
+	batch := vectorized.NewRecordBatch(schema, 6)
+	zones := []string{"x", "x", "y", "y", "", ""}
+	ints := []int64{0, 2, 4, 0, 6, 0}
+	floats := []float64{.25, .75, 1.5, 0, 2.5, 4}
+	for i := range 6 {
+		if passthrough {
+			z := &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: zones[i]}}}
+			if i == 4 {
+				z = nil
+			}
+			batch.Columns[0].(*vectorized.TypedColumn[*modelv1.TagValue]).Append(z)
+			var v *modelv1.FieldValue
+			if i != 3 && i != 5 {
+				v = &modelv1.FieldValue{Value: &modelv1.FieldValue_Int{Int: &modelv1.Int{Value: ints[i]}}}
+			}
+			batch.Columns[1].(*vectorized.TypedColumn[*modelv1.FieldValue]).Append(v)
+			var f *modelv1.FieldValue
+			if i != 3 {
+				f = &modelv1.FieldValue{Value: &modelv1.FieldValue_Float{Float: &modelv1.Float{Value: floats[i]}}}
+			}
+			batch.Columns[2].(*vectorized.TypedColumn[*modelv1.FieldValue]).Append(f)
+		} else {
+			if i == 4 {
+				batch.Columns[0].AppendNull()
+			} else {
+				batch.Columns[0].(*vectorized.TypedColumn[string]).Append(zones[i])
+			}
+			if i == 3 || i == 5 {
+				batch.Columns[1].AppendNull()
+			} else {
+				batch.Columns[1].(*vectorized.TypedColumn[int64]).Append(ints[i])
+			}
+			if i == 3 {
+				batch.Columns[2].AppendNull()
+			} else {
+				batch.Columns[2].(*vectorized.TypedColumn[float64]).Append(floats[i])
+			}
+		}
+	}
+	batch.Len = 6
+	return &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}, defs
+}
+
+func aggregateCallsPlan(defs []vectorized.ColumnDef) *AggregatePlan {
+	calls := []AggregateCall{
+		{Source: defs[1], Function: AggregateCount},
+		{Source: defs[1], Function: AggregateSum},
+		{Source: defs[1], Function: AggregateMin},
+		{Source: defs[1], Function: AggregateMax},
+		{Source: defs[1], Function: AggregateAvg},
+		{Source: defs[2], Function: AggregateAvg},
+		{Source: defs[2], Function: AggregateSum},
+		{Source: defs[2], Function: AggregateCount},
+	}
+	outputs := make([]AggregateOutput, len(calls))
+	for i, call := range calls {
+		typ := call.Source.Type
+		if call.Function == AggregateCount {
+			typ = vectorized.ColumnTypeInt64
+		}
+		if call.Function == AggregateAvg {
+			typ = vectorized.ColumnTypeFloat64
+		}
+		outputs[i] = AggregateOutput{Column: aggregateField("result_"+string(rune('a'+i)), typ), Index: i}
+	}
+	return &AggregatePlan{Calls: calls, Outputs: outputs}
+}
+
+func TestAggregateScalarSQLNullsAndSharedState(t *testing.T) {
+	for _, passthrough := range []bool{false, true} {
+		t.Run(map[bool]string{false: "typed", true: "native-passthrough"}[passthrough], func(t *testing.T) {
+			source, defs := aggregateFixture(passthrough)
+			ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+			op, err := WrapAggregate(ctx, source, aggregateCallsPlan(defs))
+			require.NoError(t, err)
+			require.NoError(t, op.Init(ctx))
+			batch, err := op.NextBatch(ctx)
+			require.NoError(t, err)
+			require.Equal(t, 1, batch.Len)
+			require.Equal(t, -1, batch.Schema.TimestampIndex())
+			require.Equal(t, int64(4), batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+			require.Equal(t, int64(12), batch.Columns[1].(*vectorized.TypedColumn[int64]).Data()[0])
+			require.Equal(t, int64(0), batch.Columns[2].(*vectorized.TypedColumn[int64]).Data()[0])
+			require.Equal(t, int64(6), batch.Columns[3].(*vectorized.TypedColumn[int64]).Data()[0])
+			require.Equal(t, 3., batch.Columns[4].(*vectorized.TypedColumn[float64]).Data()[0])
+			require.InDelta(t, 1.8, batch.Columns[5].(*vectorized.TypedColumn[float64]).Data()[0], 1e-12)
+			require.Equal(t, 9., batch.Columns[6].(*vectorized.TypedColumn[float64]).Data()[0])
+			require.Equal(t, int64(5), batch.Columns[7].(*vectorized.TypedColumn[int64]).Data()[0])
+			stats, ok := AggregateStatistics(op)
+			require.True(t, ok)
+			require.EqualValues(t, 6, stats.InputRows)
+			require.EqualValues(t, 1, stats.Groups)
+			require.EqualValues(t, 7, stats.StateLanes)
+			require.NoError(t, op.Close())
+			require.NoError(t, op.Close())
+			require.Equal(t, 1, source.closes)
+			require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+		})
+	}
+}
+
+func TestAggregateGroupingKeepsNullAndEmptyApart(t *testing.T) {
+	source, defs := aggregateFixture(false)
+	plan := aggregateCallsPlan(defs)
+	plan.GroupBy = []vectorized.ColumnDef{defs[0]}
+	plan.Outputs = append([]AggregateOutput{{Column: defs[0], Index: 0, IsGroup: true}}, plan.Outputs...)
+	op, err := WrapAggregate(t.Context(), source, plan)
+	require.NoError(t, err)
+	require.NoError(t, op.Init(t.Context()))
+	batch, err := op.NextBatch(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, 4, batch.Len)
+	zones := batch.Columns[0].(*vectorized.TypedColumn[string])
+	require.Equal(t, []string{"x", "y", "", ""}, zones.Data())
+	require.True(t, zones.IsNull(2))
+	require.False(t, zones.IsNull(3))
+	require.True(t, batch.Columns[2].IsNull(3))
+	require.Equal(t, []int64{2, 1, 1, 0}, batch.Columns[1].(*vectorized.TypedColumn[int64]).Data())
+	require.NoError(t, op.Close())
+}
+
+func TestAggregateScalarAndGroupedEmpty(t *testing.T) {
+	for _, grouped := range []bool{false, true} {
+		source, defs := aggregateFixture(false)
+		source.batches = nil
+		plan := aggregateCallsPlan(defs)
+		if grouped {
+			plan.GroupBy = []vectorized.ColumnDef{defs[0]}
+		}
+		op, err := WrapAggregate(t.Context(), source, plan)
+		require.NoError(t, err)
+		require.NoError(t, op.Init(t.Context()))
+		batch, err := op.NextBatch(t.Context())
+		require.NoError(t, err)
+		if grouped {
+			require.Nil(t, batch)
+		} else {
+			require.Equal(t, 1, batch.Len)
+			require.False(t, batch.Columns[0].IsNull(0))
+			require.Equal(t, int64(0), batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+			for _, i := range []int{1, 2, 3, 4, 5, 6} {
+				require.True(t, batch.Columns[i].IsNull(0))
+			}
+		}
+		require.NoError(t, op.Close())
+		require.Equal(t, 1, source.closes)
+	}
+}
+
+func TestAggregateGroupFloatCanonicalizationAndTypedBoundaries(t *testing.T) {
+	defs := []vectorized.ColumnDef{aggregateField("key", vectorized.ColumnTypeFloat64), aggregateField("v", vectorized.ColumnTypeInt64)}
+	schema := vectorized.NewBatchSchema(defs)
+	batch := vectorized.NewRecordBatch(schema, 5)
+	batch.Columns[0].(*vectorized.TypedColumn[float64]).AppendSlice([]float64{0, math.Copysign(0, -1), math.NaN(), math.Float64frombits(0x7ff8000000000011), 1})
+	batch.Columns[1].(*vectorized.TypedColumn[int64]).AppendSlice([]int64{1, 1, 1, 1, 1})
+	batch.Len = 5
+	source := &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}
+	plan := aggregateCountPlan(defs[:1], defs[1])
+	op, err := WrapAggregate(t.Context(), source, plan)
+	require.NoError(t, err)
+	require.NoError(t, op.Init(t.Context()))
+	out, err := op.NextBatch(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, 3, out.Len)
+	require.Equal(t, []int64{2, 2, 1}, out.Columns[1].(*vectorized.TypedColumn[int64]).Data())
+	require.False(t, math.Signbit(out.Columns[0].(*vectorized.TypedColumn[float64]).Data()[0]))
+	require.True(t, math.IsNaN(out.Columns[0].(*vectorized.TypedColumn[float64]).Data()[1]))
+	require.NoError(t, op.Close())
+}
+
+func TestAggregateCancellationAndBudgetFailureRelease(t *testing.T) {
+	source, defs := aggregateFixture(false)
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 1<<20)
+	op, err := WrapAggregate(ctx, source, aggregateCallsPlan(defs))
+	require.NoError(t, err)
+	canceled, cancel := context.WithCancel(ctx)
+	cancel()
+	_, err = op.NextBatch(canceled)
+	require.ErrorIs(t, err, context.Canceled)
+	require.NoError(t, op.Close())
+	require.Equal(t, 1, source.closes)
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+	source, defs = aggregateFixture(false)
+	ctx = vectorized.WithQueryMemoryTracker(t.Context(), 1)
+	_, err = WrapAggregate(ctx, source, aggregateCallsPlan(defs))
+	require.ErrorIs(t, err, vectorized.ErrMemoryBudgetExceeded)
+	require.Equal(t, 1, source.closes)
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
+
+func TestAggregateSpanningSelectionBoundaryMatchesIndependentScalar(t *testing.T) {
+	const rows, groupCount = 131073, 513
+	defs := []vectorized.ColumnDef{aggregateField("key", vectorized.ColumnTypeInt64), aggregateField("f", vectorized.ColumnTypeFloat64)}
+	schema := vectorized.NewBatchSchema(defs)
+	source := &rawSource{schema: schema}
+	type scalar struct {
+		count         int64
+		sum, min, max float64
+	}
+	expected := make(map[int64]scalar)
+	for begin := 0; begin < rows; begin += 65536 {
+		end := min(begin+65536, rows)
+		batch := vectorized.NewRecordBatch(schema, end-begin)
+		for row := begin; row < end; row++ {
+			key := int64(row % groupCount)
+			batch.Columns[0].(*vectorized.TypedColumn[int64]).Append(key)
+			if row%7 == 0 {
+				batch.Columns[1].AppendNull()
+				if _, ok := expected[key]; !ok {
+					expected[key] = scalar{}
+				}
+				continue
+			}
+			value := float64(row%23-11) / 4
+			batch.Columns[1].(*vectorized.TypedColumn[float64]).Append(value)
+			state := expected[key]
+			if state.count == 0 || value < state.min {
+				state.min = value
+			}
+			if state.count == 0 || value > state.max {
+				state.max = value
+			}
+			state.count++
+			state.sum += value
+			expected[key] = state
+		}
+		batch.Len = end - begin
+		source.batches = append(source.batches, batch)
+	}
+	calls := []AggregateCall{
+		{Source: defs[1], Function: AggregateCount},
+		{Source: defs[1], Function: AggregateSum},
+		{Source: defs[1], Function: AggregateMin},
+		{Source: defs[1], Function: AggregateMax},
+		{Source: defs[1], Function: AggregateAvg},
+	}
+	outputs := []AggregateOutput{{Column: defs[0], IsGroup: true}}
+	for i, call := range calls {
+		outputs = append(outputs, AggregateOutput{Column: aggregateField("result_"+string(rune('a'+i)), aggregateOutputType(call)), Index: i})
+	}
+	ctx := vectorized.WithQueryMemoryTracker(t.Context(), 4<<20)
+	op, err := WrapAggregate(ctx, source, &AggregatePlan{GroupBy: defs[:1], Calls: calls, Outputs: outputs})
+	require.NoError(t, err)
+	require.NoError(t, op.Init(ctx))
+	seen := 0
+	for {
+		batch, pullErr := op.NextBatch(ctx)
+		require.NoError(t, pullErr)
+		if batch == nil {
+			break
+		}
+		for row, key := range batch.Columns[0].(*vectorized.TypedColumn[int64]).Data() {
+			want := expected[key]
+			require.Equal(t, want.count, batch.Columns[1].(*vectorized.TypedColumn[int64]).Data()[row])
+			for index, wantValue := range []float64{want.sum, want.min, want.max, want.sum / float64(want.count)} {
+				require.Equal(t, wantValue, batch.Columns[index+2].(*vectorized.TypedColumn[float64]).Data()[row])
+			}
+			seen++
+		}
+	}
+	require.Equal(t, groupCount, seen)
+	stats, ok := AggregateStatistics(op)
+	require.True(t, ok)
+	require.EqualValues(t, rows, stats.InputRows)
+	require.EqualValues(t, groupCount, stats.Groups)
+	require.EqualValues(t, 1024*4*8, stats.StateCapacity)
+	require.EqualValues(t, 4, stats.StateLanes)
+	require.EqualValues(t, groupCount*4*8, stats.StateBytes)
+	require.NoError(t, op.Close())
+	require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+}
+
+func TestAggregateCountPrecisionAtStateBoundary(t *testing.T) {
+	source, defs := aggregateFixture(false)
+	op, err := WrapAggregate(t.Context(), source, aggregateCountPlan(nil, defs[1]))
+	require.NoError(t, err)
+	require.NoError(t, op.Init(t.Context()))
+	_, err = op.NextBatch(t.Context())
+	require.NoError(t, err)
+	// Exercise finalized state directly instead of ingesting quadrillions of rows.
+	aggregate := op.(*aggregateOperator)
+	aggregate.states[0].count[0] = (1 << 53) + 1
+	aggregate.position = 0
+	batch, err := op.NextBatch(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, int64((1<<53)+1), batch.Columns[0].(*vectorized.TypedColumn[int64]).Data()[0])
+	require.NoError(t, op.Close())
+}
+
+type aggregateErrorSource struct {
+	*rawSource
+	nextErr, closeErr error
+}
+
+func (s *aggregateErrorSource) NextBatch(ctx context.Context) (*vectorized.RecordBatch, error) {
+	if s.nextErr != nil {
+		return nil, s.nextErr
+	}
+	return s.rawSource.NextBatch(ctx)
+}
+func (s *aggregateErrorSource) Close() error { _ = s.rawSource.Close(); return s.closeErr }
+
+func TestAggregateFailurePathsReturnReservations(t *testing.T) {
+	for _, stage := range []string{"init", "pull", "close", "growth"} {
+		t.Run(stage, func(t *testing.T) {
+			source, defs := aggregateFixture(false)
+			failure := fmt.Errorf("source %s failed", stage)
+			wrapped := &aggregateErrorSource{rawSource: source}
+			switch stage {
+			case "init":
+				source.initErr = failure
+			case "pull":
+				wrapped.nextErr = failure
+			case "close":
+				wrapped.closeErr = failure
+			}
+			budget := int64(1 << 20)
+			plan := aggregateCallsPlan(defs)
+			if stage == "growth" {
+				plan.GroupBy = defs[:1]
+				metadata, err := rawDescriptorBound(len(plan.GroupBy)+len(plan.Calls)+len(plan.Outputs), 0, 0)
+				require.NoError(t, err)
+				budget = metadata + 400
+			}
+			ctx := vectorized.WithQueryMemoryTracker(t.Context(), budget)
+			op, err := WrapAggregate(ctx, wrapped, plan)
+			require.NoError(t, err)
+			err = op.Init(ctx)
+			if err == nil {
+				_, err = op.NextBatch(ctx)
+			}
+			if stage == "growth" {
+				require.ErrorIs(t, err, vectorized.ErrMemoryBudgetExceeded)
+			} else {
+				require.ErrorIs(t, err, failure)
+			}
+			require.NoError(t, op.Close())
+			require.Equal(t, 1, source.closes)
+			require.Zero(t, vectorized.QueryMemoryTracker(ctx, 0).Used())
+		})
+	}
+}
+
+func TestAggregateSelectionAndTypedKeyBoundaries(t *testing.T) {
+	defs := []vectorized.ColumnDef{
+		{
+			Name:      "a",
+			TagFamily: "f",
+			Role:      vectorized.RoleTag,
+			Type:      vectorized.ColumnTypeString,
+		},
+		{
+			Name:      "b",
+			TagFamily: "f",
+			Role:      vectorized.RoleTag,
+			Type:      vectorized.ColumnTypeString,
+		},
+		aggregateField("v", vectorized.ColumnTypeInt64),
+	}
+	schema := vectorized.NewBatchSchema(defs)
+	batch := vectorized.NewRecordBatch(schema, 3)
+	batch.Columns[0].(*vectorized.TypedColumn[string]).AppendSlice([]string{"a", "ab", "a"})
+	batch.Columns[1].(*vectorized.TypedColumn[string]).AppendSlice([]string{"bc", "c", "bc"})
+	batch.Columns[2] = vectorized.NewConstInt64(1, 3)
+	batch.Len = 3
+	batch.Selection = []uint16{2, 1}
+	source := &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}}
+	op, err := WrapAggregate(t.Context(), source, aggregateCountPlan(defs[:2], defs[2]))
+	require.NoError(t, err)
+	require.NoError(t, op.Init(t.Context()))
+	out, err := op.NextBatch(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, 2, out.Len)
+	require.Equal(t, []string{"a", "ab"}, out.Columns[0].(*vectorized.TypedColumn[string]).Data())
+	require.Equal(t, []int64{1, 1}, out.Columns[2].(*vectorized.TypedColumn[int64]).Data())
+	require.NoError(t, op.Close())
+}
+
+func TestAggregateRetainsKeysBeforeNativeSourcePoison(t *testing.T) {
+	for _, typ := range []vectorized.ColumnType{vectorized.ColumnTypeString, vectorized.ColumnTypeBytes} {
+		t.Run(typ.String(), func(t *testing.T) {
+			payload := []byte("first")
+			defs := []vectorized.ColumnDef{
+				{Name: "key", TagFamily: "f", Role: vectorized.RoleTag, Type: typ},
+				aggregateField("v", vectorized.ColumnTypeInt64),
+			}
+			schema := vectorized.NewBatchSchema(defs)
+			batch := vectorized.NewRecordBatch(schema, 1)
+			if typ == vectorized.ColumnTypeBytes {
+				batch.Columns[0].(*vectorized.TypedColumn[[]byte]).Append(payload)
+			} else {
+				// Native decoders lend strings over mutable buffers. The copied key must
+				// survive the source overwriting the buffer at its next pull and Close.
+				batch.Columns[0].(*vectorized.TypedColumn[string]).Append(convert.BytesToString(payload))
+			}
+			batch.Columns[1].(*vectorized.TypedColumn[int64]).Append(1)
+			batch.Len = 1
+			source := &rawSource{schema: schema, batches: []*vectorized.RecordBatch{batch}, poison: payload}
+			op, err := WrapAggregate(t.Context(), source, aggregateCountPlan(defs[:1], defs[1]))
+			require.NoError(t, err)
+			require.NoError(t, op.Init(t.Context()))
+			out, err := op.NextBatch(t.Context())
+			require.NoError(t, err)
+			require.Equal(t, "xxxxx", string(payload))
+			if typ == vectorized.ColumnTypeBytes {
+				require.Equal(t, []byte("first"), out.Columns[0].(*vectorized.TypedColumn[[]byte]).Data()[0])
+			} else {
+				require.Equal(t, "first", out.Columns[0].(*vectorized.TypedColumn[string]).Data()[0])
+			}
+			aggregate := op.(*aggregateOperator)
+			require.Nil(t, aggregate.groupReaders[0].column)
+			require.Nil(t, aggregate.stateReaders[0].column)
+			require.NoError(t, op.Close())
+		})
+	}
+}
+
+func aggregateCountPlan(groups []vectorized.ColumnDef, source vectorized.ColumnDef) *AggregatePlan {
+	outputs := make([]AggregateOutput, 0, len(groups)+1)
+	for index, def := range groups {
+		outputs = append(outputs, AggregateOutput{Column: def, Index: index, IsGroup: true})
+	}
+	outputs = append(outputs, AggregateOutput{Column: aggregateField("n", vectorized.ColumnTypeInt64)})
+	return &AggregatePlan{GroupBy: groups, Calls: []AggregateCall{{Source: source, Function: AggregateCount}}, Outputs: outputs}
+}
diff --git a/pkg/query/ql/aggregate_value.go b/pkg/query/ql/aggregate_value.go
new file mode 100644
index 000000000..f82b4d8f6
--- /dev/null
+++ b/pkg/query/ql/aggregate_value.go
@@ -0,0 +1,221 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package ql
+
+import (
+	"fmt"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+type aggregateValue struct {
+	text string
+	raw  []byte
+	i64  int64
+	f64  float64
+	typ  vectorized.ColumnType
+	null bool
+}
+
+type aggregateReader struct {
+	column vectorized.Column
+	def    vectorized.ColumnDef
+	index  int
+}
+
+func aggregateScalarType(typ vectorized.ColumnType) bool {
+	return typ == vectorized.ColumnTypeInt64 || typ == vectorized.ColumnTypeFloat64 ||
+		typ == vectorized.ColumnTypeString || typ == vectorized.ColumnTypeBytes
+}
+
+func newAggregateReader(schema *vectorized.BatchSchema, def vectorized.ColumnDef) (aggregateReader, error) {
+	index := sourceIndex(schema, def)
+	if index < 0 {
+		return aggregateReader{}, fmt.Errorf("aggregate source column %q in family %q is absent", def.Name, def.TagFamily)
+	}
+	actual := schema.Columns[index].Type
+	if actual != def.Type && actual != vectorized.ColumnTypeTagValue && actual != vectorized.ColumnTypeFieldValue {
+		return aggregateReader{}, fmt.Errorf("aggregate source column %q has type %s; expected %s", def.Name, actual, def.Type)
+	}
+	return aggregateReader{def: def, index: index}, nil
+}
+
+func (r *aggregateReader) bind(batch *vectorized.RecordBatch) error {
+	if r.index >= len(batch.Columns) || batch.Columns[r.index] == nil || batch.Columns[r.index].Len() < batch.Len {
+		return fmt.Errorf("aggregate source column %q does not cover the input batch", r.def.Name)
+	}
+	r.column = batch.Columns[r.index]
+	return nil
+}
+
+func (r *aggregateReader) value(row int) (aggregateValue, error) {
+	value := aggregateValue{typ: r.def.Type, null: r.column.IsNull(row)}
+	if value.null {
+		return value, nil
+	}
+	if constant, ok := vectorized.AsConst(r.column); ok {
+		if constant.Type() != value.typ {
+			return value, r.typeError()
+		}
+		value.i64, value.f64, value.text, value.raw = constant.Int64(), constant.Float64(), constant.Str(), constant.Bytes()
+		return value, nil
+	}
+	switch column := r.column.(type) {
+	case *vectorized.TypedColumn[int64]:
+		if value.typ != vectorized.ColumnTypeInt64 {
+			return value, r.typeError()
+		}
+		value.i64 = column.Data()[row]
+	case *vectorized.TypedColumn[float64]:
+		if value.typ != vectorized.ColumnTypeFloat64 {
+			return value, r.typeError()
+		}
+		value.f64 = column.Data()[row]
+	case *vectorized.TypedColumn[string]:
+		if value.typ != vectorized.ColumnTypeString {
+			return value, r.typeError()
+		}
+		value.text = column.Data()[row]
+	case *vectorized.TypedColumn[[]byte]:
+		if value.typ != vectorized.ColumnTypeBytes {
+			return value, r.typeError()
+		}
+		value.raw = column.Data()[row]
+	case *vectorized.TypedColumn[*modelv1.TagValue]:
+		return r.tagValue(column.Data()[row])
+	case *vectorized.TypedColumn[*modelv1.FieldValue]:
+		return r.fieldValue(column.Data()[row])
+	default:
+		return value, r.typeError()
+	}
+	return value, nil
+}
+
+func (r *aggregateReader) typeError() error {
+	return fmt.Errorf("aggregate source column %q has a value incompatible with %s", r.def.Name, r.def.Type)
+}
+
+func (r *aggregateReader) tagValue(cell *modelv1.TagValue) (aggregateValue, error) {
+	value := aggregateValue{typ: r.def.Type}
+	if cell == nil || cell.Value == nil {
+		value.null = true
+		return value, nil
+	}
+	switch item := cell.Value.(type) {
+	case *modelv1.TagValue_Null:
+		value.null = true
+	case *modelv1.TagValue_Int:
+		if item.Int == nil {
+			value.null = true
+			return value, nil
+		}
+		if value.typ != vectorized.ColumnTypeInt64 {
+			return value, r.typeError()
+		}
+		value.i64 = item.Int.Value
+	case *modelv1.TagValue_Str:
+		if item.Str == nil {
+			value.null = true
+			return value, nil
+		}
+		if value.typ != vectorized.ColumnTypeString {
+			return value, r.typeError()
+		}
+		value.text = item.Str.Value
+	case *modelv1.TagValue_BinaryData:
+		if value.typ != vectorized.ColumnTypeBytes {
+			return value, r.typeError()
+		}
+		value.raw = item.BinaryData
+	case *modelv1.TagValue_Timestamp:
+		if item.Timestamp == nil {
+			value.null = true
+			return value, nil
+		}
+		if value.typ != vectorized.ColumnTypeInt64 || item.Timestamp.CheckValid() != nil {
+			return value, r.typeError()
+		}
+		value.i64 = item.Timestamp.AsTime().UnixNano()
+	case *modelv1.TagValue_IntArray, *modelv1.TagValue_StrArray:
+		return value, r.typeError()
+	}
+	return value, nil
+}
+
+func (r *aggregateReader) fieldValue(cell *modelv1.FieldValue) (aggregateValue, error) {
+	value := aggregateValue{typ: r.def.Type}
+	if cell == nil || cell.Value == nil {
+		value.null = true
+		return value, nil
+	}
+	switch item := cell.Value.(type) {
+	case *modelv1.FieldValue_Null:
+		value.null = true
+	case *modelv1.FieldValue_Int:
+		if item.Int == nil {
+			value.null = true
+			return value, nil
+		}
+		if value.typ != vectorized.ColumnTypeInt64 {
+			return value, r.typeError()
+		}
+		value.i64 = item.Int.Value
+	case *modelv1.FieldValue_Float:
+		if item.Float == nil {
+			value.null = true
+			return value, nil
+		}
+		if value.typ != vectorized.ColumnTypeFloat64 {
+			return value, r.typeError()
+		}
+		value.f64 = item.Float.Value
+	case *modelv1.FieldValue_Str:
+		if item.Str == nil {
+			value.null = true
+			return value, nil
+		}
+		if value.typ != vectorized.ColumnTypeString {
+			return value, r.typeError()
+		}
+		value.text = item.Str.Value
+	case *modelv1.FieldValue_BinaryData:
+		if value.typ != vectorized.ColumnTypeBytes {
+			return value, r.typeError()
+		}
+		value.raw = item.BinaryData
+	}
+	return value, nil
+}
+
+func appendAggregateValue(column vectorized.Column, value aggregateValue) {
+	if value.null {
+		column.AppendNull()
+		return
+	}
+	switch output := column.(type) {
+	case *vectorized.TypedColumn[int64]:
+		output.Append(value.i64)
+	case *vectorized.TypedColumn[float64]:
+		output.Append(value.f64)
+	case *vectorized.TypedColumn[string]:
+		output.Append(value.text)
+	case *vectorized.TypedColumn[[]byte]:
+		output.Append([]byte(value.text))
+	}
+}
diff --git a/pkg/query/ql/raw.go b/pkg/query/ql/raw.go
index c701246c7..45216cb6d 100644
--- a/pkg/query/ql/raw.go
+++ b/pkg/query/ql/raw.go
@@ -40,14 +40,16 @@ type Slot struct {
 // MillisRange is a half-open timestamp interval in Unix milliseconds.
 type MillisRange struct{ Begin, End int64 }
 
-// RawPlan is the immutable bound recipe for raw measure batch execution.
+// RawPlan is the immutable bound recipe for a complete native measure scan and QL operators.
 type RawPlan struct {
-	Limit    *uint32
-	Slots    []Slot
-	Ranges   []MillisRange
-	Offset   uint32
-	Residual bool
-	Empty    bool
+	NativeAggregate *NativeAggregatePlan
+	Aggregate       *AggregatePlan
+	Limit           *uint32
+	Slots           []Slot
+	Ranges          []MillisRange
+	Offset          uint32
+	Residual        bool
+	Empty           bool
 }
 
 type rawOperator struct {
@@ -73,6 +75,9 @@ type rawOperator struct {
 // WrapRaw takes ownership of source, including on failure, and resolves projection indices once.
 // Its output lends immutable source columns until the next pull or Close.
 func WrapRaw(ctx context.Context, source vectorized.PullOperator, plan *RawPlan) (_ vectorized.PullOperator, err error) {
+	if plan != nil && plan.Aggregate != nil {
+		return wrapAggregateQuery(ctx, source, plan)
+	}
 	defer func() {
 		if err != nil && source != nil {
 			_ = source.Close()
diff --git a/test/cases/measure/cmd/generate/ql_only.go b/test/cases/measure/cmd/generate/ql_only.go
index 819abab51..d60e1440f 100644
--- a/test/cases/measure/cmd/generate/ql_only.go
+++ b/test/cases/measure/cmd/generate/ql_only.go
@@ -53,7 +53,9 @@ func runGenerateQL(arguments []string) error {
 			pending++
 		}
 	}
-	fmt.Printf("Generated %d QL-only cases; feasible pairs %d/%d; %d explicit pending features; output=%s\n",
-		len(suite.Cases), suite.Pairs.Covered, suite.Pairs.Required, pending, directory)
+	fmt.Printf("Generated %d QL-only cases, %d reuse scenarios, %d aggregate scenarios, %d aggregate rejections and %d native aggregate scenarios; "+
+		"feasible pairs %d/%d; %d explicit pending features; output=%s\n",
+		len(suite.Cases), len(suite.ReuseScenarios), len(suite.AggregateScenarios), len(suite.AggregateRejections), len(suite.NativeAggregateScenarios),
+		suite.Pairs.Covered, suite.Pairs.Required, pending, directory)
 	return nil
 }
diff --git a/test/cases/measure/ql_generated.go b/test/cases/measure/ql_generated.go
index 5a06b53ce..3a62c1af2 100644
--- a/test/cases/measure/ql_generated.go
+++ b/test/cases/measure/ql_generated.go
@@ -183,6 +183,8 @@ func RegisterGeneratedQLTable(description string) bool {
 			})
 		}
 		registerGeneratedQLReuseScenarios(suite, &base)
+		registerGeneratedQLAggregateScenarios(suite, &base)
+		registerGeneratedQLNativeAggregateScenarios(suite, &base)
 	})
 }
 
diff --git a/test/cases/measure/ql_generated_aggregate.go b/test/cases/measure/ql_generated_aggregate.go
new file mode 100644
index 000000000..8b6a04d4e
--- /dev/null
+++ b/test/cases/measure/ql_generated_aggregate.go
@@ -0,0 +1,141 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package measure_test
+
+import (
+	"context"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"google.golang.org/grpc/codes"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	liaisongrpc "github.com/apache/skywalking-banyandb/banyand/liaison/grpc"
+	ql "github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+	vmeasure "github.com/apache/skywalking-banyandb/pkg/query/vectorized/measure"
+	"github.com/apache/skywalking-banyandb/pkg/test/flags"
+	measuredata "github.com/apache/skywalking-banyandb/test/cases/measure/data"
+	"github.com/apache/skywalking-banyandb/test/cases/measure/qlgen"
+)
+
+func registerGeneratedQLAggregateScenarios(suite qlgen.Suite, base *time.Time) {
+	g.Describe("generated complete SQL aggregation", g.Ordered, g.Label("QLB-T07"), func() {
+		g.BeforeAll(func() {
+			gm.Expect(vectorized.QueryMetricsEnabled).To(gm.BeTrue(), "QLB-T07 execution/state verification requires -tags=querymetrics")
+			prepareGeneratedAggregateFixtures(*base, suite.AggregateReadiness)
+		})
+		for _, scenario := range suite.AggregateScenarios {
+			g.It(scenario.Name, func() {
+				liaisongrpc.ClearBydbQLPlanCachesForTest()
+				before := liaisongrpc.BydbQLPlanCacheStats()
+				for _, c := range scenario.Cases {
+					verifyGeneratedAggregateCall(*base, c)
+				}
+				warm := liaisongrpc.BydbQLPlanCacheStats()
+				gm.Expect(warm.Compiles-before.Compiles).To(gm.Equal(uint64(1)), "cold/warm changed bindings share one immutable template")
+				gm.Expect(warm.Hits - before.Hits).To(gm.BeNumerically(">=", 2))
+				restore := liaisongrpc.SetBydbQLPlanCacheHooksForTest(liaisongrpc.PlanCacheTestHooks{ForceBypass: true})
+				defer restore()
+				for _, c := range scenario.Cases {
+					verifyGeneratedAggregateCall(*base, c)
+				}
+				uncached := liaisongrpc.BydbQLPlanCacheStats()
+				gm.Expect(uncached.Hits).To(gm.Equal(warm.Hits))
+				gm.Expect(uncached.Bypass - warm.Bypass).To(gm.Equal(uint64(len(scenario.Cases))))
+			})
+		}
+		for _, c := range suite.AggregateRejections {
+			g.It(c.Name, func() { verifyGeneratedAggregateCall(*base, c) })
+		}
+	})
+}
+
+func prepareGeneratedAggregateFixtures(base time.Time, fixtures []qlgen.AggregateFixture) {
+	ctx, cancel := context.WithTimeout(context.Background(), flags.EventuallyTimeout)
+	defer cancel()
+	registry := databasev1.NewMeasureRegistryServiceClient(SharedContext.Connection)
+	for _, fixture := range fixtures {
+		schema := qlgen.T07FixtureSchema(fixture.Name)
+		_, createErr := registry.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{Measure: schema})
+		gm.Expect(createErr).NotTo(gm.HaveOccurred())
+		registered, getErr := registry.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{Metadata: schema.GetMetadata()})
+		gm.Expect(getErr).NotTo(gm.HaveOccurred())
+		writes := qlgen.T07FixtureWrites(fixture.Name, base)
+		for ordinal, request := range writes {
+			request.Metadata = nil
+			if ordinal == 0 {
+				request.Metadata = registered.GetMeasure().GetMetadata()
+			}
+		}
+		gm.Eventually(func(inner gm.Gomega) {
+			writeCtx, writeCancel := context.WithTimeout(context.Background(), 20*time.Second)
+			defer writeCancel()
+			inner.Expect(writeGeneratedQLFixture(writeCtx, SharedContext.Connection, writes)).To(gm.Succeed())
+		}, flags.EventuallyTimeout, 100*time.Millisecond).Should(gm.Succeed())
+		for _, c := range fixture.Cases {
+			gm.Eventually(func() error {
+				return executeGeneratedReuseCall(ctx, base, c)
+			}, flags.EventuallyTimeout, 100*time.Millisecond).Should(gm.Succeed())
+		}
+	}
+}
+
+func verifyGeneratedAggregateCall(base time.Time, c qlgen.Case) {
+	before := vmeasure.BatchExecutionStats()
+	memoryBefore := vectorized.QueryMemoryStats()
+	aggregateBefore := ql.AggregateExecutionStats()
+	defer func() {
+		after := vmeasure.BatchExecutionStats()
+		gm.Expect(after.IntermediateDataPoints).To(gm.Equal(before.IntermediateDataPoints))
+		gm.Expect(after.LiveResults).To(gm.Equal(before.LiveResults))
+		gm.Expect(vectorized.QueryMemoryStats().LiveReservedBytes).To(gm.Equal(memoryBefore.LiveReservedBytes))
+		gm.Expect(ql.AggregateExecutionStats().LiveBytes).To(gm.Equal(aggregateBefore.LiveBytes))
+		if c.NoScan {
+			gm.Expect(after.NativeLeafCalls).To(gm.Equal(before.NativeLeafCalls))
+		}
+	}()
+	query, expected := renderGeneratedQLCase(base, c)
+	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+	defer cancel()
+	if c.WantCode != codes.OK {
+		gm.Expect(measuredata.VerifyQLError(ctx, SharedContext.Connection, query, c.WantCode)).To(gm.Succeed())
+		return
+	}
+	verify := func() (*measurev1.QueryResponse, error) {
+		if c.AggregateGroups > 100 {
+			return verifyGeneratedAggregateGroups(ctx, query, expected)
+		}
+		return measuredata.VerifyQL(ctx, SharedContext.Connection, query, expected, false)
+	}
+	response, queryErr := verify()
+	gm.Expect(queryErr).NotTo(gm.HaveOccurred(), c.Name)
+	for _, point := range response.GetDataPoints() {
+		gm.Expect(point.GetTimestamp()).To(gm.BeNil(), "local aggregate rows have no source timestamp")
+		gm.Expect(point.GetSid()).To(gm.BeZero(), "local aggregate rows have no source series identity")
+		gm.Expect(point.GetVersion()).To(gm.BeZero(), "local aggregate rows have no source version")
+	}
+	if !c.NoScan {
+		aggregateAfter := ql.AggregateExecutionStats()
+		gm.Expect(aggregateAfter.Executions-aggregateBefore.Executions).To(gm.Equal(uint64(1)), "one local aggregate per QL RPC")
+		gm.Expect(aggregateAfter.InputRows-aggregateBefore.InputRows).To(gm.Equal(c.AggregateInputs), "complete raw input after native deduplication")
+		gm.Expect(aggregateAfter.Groups-aggregateBefore.Groups).To(gm.Equal(c.AggregateGroups), "typed group cardinality")
+	}
+}
diff --git a/test/cases/measure/ql_generated_aggregate_compare.go b/test/cases/measure/ql_generated_aggregate_compare.go
new file mode 100644
index 000000000..ae3c62df8
--- /dev/null
+++ b/test/cases/measure/ql_generated_aggregate_compare.go
@@ -0,0 +1,90 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package measure_test
+
+import (
+	"context"
+	"fmt"
+	"slices"
+	"strings"
+
+	"google.golang.org/protobuf/encoding/protojson"
+
+	bydbqlv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/bydbql/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	"github.com/apache/skywalking-banyandb/pkg/test/helpers"
+	measuredata "github.com/apache/skywalking-banyandb/test/cases/measure/data"
+)
+
+func verifyGeneratedAggregateGroups(ctx context.Context, query string, expected []byte) (*measurev1.QueryResponse, error) {
+	text, params, extractErr := helpers.ExtractQL(query)
+	if extractErr != nil {
+		return nil, extractErr
+	}
+	want := &measurev1.QueryResponse{}
+	if decodeErr := protojson.Unmarshal(expected, want); decodeErr != nil {
+		return nil, fmt.Errorf("decode generated aggregate response: %w", decodeErr)
+	}
+	response, queryErr := bydbqlv1.NewBydbQLServiceClient(SharedContext.Connection).Query(ctx, &bydbqlv1.QueryRequest{Query: text, Params: params})
+	if queryErr != nil {
+		return nil, fmt.Errorf("query generated aggregate: %w", queryErr)
+	}
+	actual := response.GetMeasureResult()
+	if actual == nil {
+		return nil, fmt.Errorf("generated aggregate returned no measure result")
+	}
+	// The complete-input fixture has a unique non-null string zone key. Prove
+	// that invariant on both sides, then compare in key order to avoid quadratic
+	// multiset matching. This does not impose a SQL output-order requirement.
+	for _, points := range [][]*measurev1.DataPoint{actual.GetDataPoints(), want.GetDataPoints()} {
+		keys := make(map[string]bool, len(points))
+		pointKeys := make(map[*measurev1.DataPoint]string, len(points))
+		for _, point := range points {
+			key, keyErr := generatedAggregateGroupKey(point)
+			if keyErr != nil {
+				return nil, keyErr
+			}
+			if keys[key] {
+				return nil, fmt.Errorf("generated complete-input aggregate repeats key %q", key)
+			}
+			keys[key] = true
+			pointKeys[point] = key
+		}
+		slices.SortFunc(points, func(left, right *measurev1.DataPoint) int {
+			return strings.Compare(pointKeys[left], pointKeys[right])
+		})
+	}
+	if compareErr := measuredata.CompareQLResult(actual, want, true); compareErr != nil {
+		return nil, compareErr
+	}
+	return actual, nil
+}
+
+func generatedAggregateGroupKey(point *measurev1.DataPoint) (string, error) {
+	for _, family := range point.GetTagFamilies() {
+		if family.GetName() != "default" {
+			continue
+		}
+		for _, tag := range family.GetTags() {
+			if tag.GetKey() == "key" && tag.GetValue().GetStr() != nil {
+				return tag.GetValue().GetStr().GetValue(), nil
+			}
+		}
+	}
+	return "", fmt.Errorf("generated complete-input aggregate lacks its non-null string key")
+}
diff --git a/test/cases/measure/ql_generated_native_aggregate.go b/test/cases/measure/ql_generated_native_aggregate.go
new file mode 100644
index 000000000..84b291963
--- /dev/null
+++ b/test/cases/measure/ql_generated_native_aggregate.go
@@ -0,0 +1,173 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package measure_test
+
+import (
+	"context"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	liaisongrpc "github.com/apache/skywalking-banyandb/banyand/liaison/grpc"
+	ql "github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+	vmeasure "github.com/apache/skywalking-banyandb/pkg/query/vectorized/measure"
+	"github.com/apache/skywalking-banyandb/pkg/test/flags"
+	"github.com/apache/skywalking-banyandb/test/cases/measure/qlgen"
+)
+
+func registerGeneratedQLNativeAggregateScenarios(suite qlgen.Suite, base *time.Time) {
+	g.Describe("generated native aggregate pushdown", g.Ordered, g.Label("QLB-T08"), func() {
+		g.BeforeAll(func() {
+			gm.Expect(vectorized.QueryMetricsEnabled).To(gm.BeTrue(), "QLB-T08 routing and ownership verification requires -tags=querymetrics")
+			prepareGeneratedNativeAggregateFixtures(*base, suite.NativeAggregateReadiness)
+		})
+		for _, scenario := range suite.NativeAggregateScenarios {
+			g.It(scenario.Name, func() {
+				liaisongrpc.ClearBydbQLPlanCachesForTest()
+				if scenario.Kind != "query" && scenario.Kind != "fallback" {
+					verifyGeneratedNativeAggregateLifecycle(*base, scenario)
+					return
+				}
+				before := liaisongrpc.BydbQLPlanCacheStats()
+				for _, c := range scenario.Cases {
+					verifyGeneratedNativeAggregateCall(*base, c, scenario.Branches, false)
+				}
+				warm := liaisongrpc.BydbQLPlanCacheStats()
+				gm.Expect(warm.Compiles-before.Compiles).To(gm.Equal(uint64(1)), "changed bindings share one immutable template")
+				gm.Expect(warm.Hits - before.Hits).To(gm.BeNumerically(">=", 2))
+				func() {
+					restore := liaisongrpc.SetBydbQLPlanCacheHooksForTest(liaisongrpc.PlanCacheTestHooks{ForceBypass: true})
+					defer restore()
+					for _, c := range scenario.Cases {
+						verifyGeneratedNativeAggregateCall(*base, c, scenario.Branches, false)
+					}
+				}()
+				uncached := liaisongrpc.BydbQLPlanCacheStats()
+				gm.Expect(uncached.Hits).To(gm.Equal(warm.Hits))
+				gm.Expect(uncached.Bypass - warm.Bypass).To(gm.Equal(uint64(len(scenario.Cases))))
+				if scenario.Branches == 0 {
+					return
+				}
+				restore := liaisongrpc.SetNativeAggregateHooksForTest(liaisongrpc.NativeAggregateTestHooks{Disabled: true})
+				defer restore()
+				for _, c := range scenario.Cases {
+					verifyGeneratedNativeAggregateCall(*base, c, scenario.Branches, true)
+				}
+			})
+		}
+		for _, c := range suite.NativeAggregateRejections {
+			g.It(c.Name, func() {
+				verifyGeneratedNativeAggregateOverflow(*base, c, suite.NativeAggregateScenarios[0])
+			})
+		}
+	})
+}
+
+func prepareGeneratedNativeAggregateFixtures(base time.Time, fixtures []qlgen.AggregateFixture) {
+	ctx, cancel := context.WithTimeout(context.Background(), flags.EventuallyTimeout)
+	defer cancel()
+	registry := databasev1.NewMeasureRegistryServiceClient(SharedContext.Connection)
+	indexRegistry := databasev1.NewIndexRuleRegistryServiceClient(SharedContext.Connection)
+	bindingRegistry := databasev1.NewIndexRuleBindingRegistryServiceClient(SharedContext.Connection)
+	for _, fixture := range fixtures {
+		_, indexErr := indexRegistry.Create(ctx, &databasev1.IndexRuleRegistryServiceCreateRequest{IndexRule: qlgen.T08FixtureIndexRule(fixture.Name)})
+		gm.Expect(indexErr).NotTo(gm.HaveOccurred())
+		_, bindingErr := bindingRegistry.Create(ctx, &databasev1.IndexRuleBindingRegistryServiceCreateRequest{
+			IndexRuleBinding: qlgen.T08FixtureIndexBinding(fixture.Name),
+		})
+		gm.Expect(bindingErr).NotTo(gm.HaveOccurred())
+		schema := qlgen.T08FixtureSchema(fixture.Name)
+		_, createErr := registry.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{Measure: schema})
+		gm.Expect(createErr).NotTo(gm.HaveOccurred())
+		registered, getErr := registry.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{Metadata: schema.GetMetadata()})
+		gm.Expect(getErr).NotTo(gm.HaveOccurred())
+		writes := qlgen.T08FixtureWrites(fixture.Name, base)
+		for ordinal, request := range writes {
+			request.Metadata = nil
+			if ordinal == 0 {
+				request.Metadata = registered.GetMeasure().GetMetadata()
+			}
+		}
+		gm.Eventually(func(inner gm.Gomega) {
+			writeCtx, writeCancel := context.WithTimeout(context.Background(), 20*time.Second)
+			defer writeCancel()
+			inner.Expect(writeGeneratedQLFixture(writeCtx, SharedContext.Connection, writes)).To(gm.Succeed())
+			inner.Expect(executeGeneratedReuseCall(writeCtx, base, fixture.Cases[len(fixture.Cases)-1])).To(gm.Succeed())
+		}, flags.EventuallyTimeout, 100*time.Millisecond).Should(gm.Succeed())
+		for _, c := range fixture.Cases {
+			gm.Eventually(func() error {
+				return executeGeneratedReuseCall(ctx, base, c)
+			}, flags.EventuallyTimeout, 100*time.Millisecond).Should(gm.Succeed())
+		}
+	}
+}
+
+func verifyGeneratedNativeAggregateCall(base time.Time, c qlgen.Case, branches uint64, forceLocal bool) {
+	before := vmeasure.BatchExecutionStats()
+	memoryBefore := vectorized.QueryMemoryStats()
+	localBefore := ql.AggregateExecutionStats()
+	nativeBefore := ql.NativeAggregateExecutionStats()
+	defer func() {
+		after := vmeasure.BatchExecutionStats()
+		gm.Expect(after.IntermediateDataPoints).To(gm.Equal(before.IntermediateDataPoints))
+		gm.Expect(after.LiveResults).To(gm.Equal(before.LiveResults))
+		gm.Expect(vectorized.QueryMemoryStats().LiveReservedBytes).To(gm.Equal(memoryBefore.LiveReservedBytes))
+		gm.Expect(ql.AggregateExecutionStats().LiveBytes).To(gm.Equal(localBefore.LiveBytes))
+		gm.Expect(ql.NativeAggregateExecutionStats().LiveBytes).To(gm.Equal(nativeBefore.LiveBytes))
+		if c.NoScan {
+			gm.Expect(after.NativeLeafCalls).To(gm.Equal(before.NativeLeafCalls))
+		}
+	}()
+	query, expected := renderGeneratedQLCase(base, c)
+	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+	defer cancel()
+	response, queryErr := verifyGeneratedNativeAggregateResult(ctx, query, expected, c.AggregateGroups)
+	gm.Expect(queryErr).NotTo(gm.HaveOccurred(), c.Name)
+	for _, point := range response.GetDataPoints() {
+		gm.Expect(point.GetTimestamp()).To(gm.BeNil(), "SQL aggregate rows have no source timestamp")
+		gm.Expect(point.GetSid()).To(gm.BeZero(), "SQL aggregate rows have no source series identity")
+		gm.Expect(point.GetVersion()).To(gm.BeZero(), "SQL aggregate rows have no source version")
+	}
+	localAfter := ql.AggregateExecutionStats()
+	nativeAfter := ql.NativeAggregateExecutionStats()
+	if c.NoScan {
+		gm.Expect(localAfter.Executions).To(gm.Equal(localBefore.Executions))
+		gm.Expect(nativeAfter.Executions).To(gm.Equal(nativeBefore.Executions))
+		gm.Expect(vmeasure.BatchExecutionStats().QLBatchExecutions).To(gm.Equal(before.QLBatchExecutions))
+		return
+	}
+	if branches == 0 || forceLocal {
+		gm.Expect(nativeAfter.Executions).To(gm.Equal(nativeBefore.Executions), "unsafe/disabled native aggregation uses the complete T07 path")
+		gm.Expect(localAfter.Executions - localBefore.Executions).To(gm.Equal(uint64(1)))
+		gm.Expect(localAfter.InputRows-localBefore.InputRows).To(gm.Equal(c.AggregateInputs), "local fallback consumes the complete deduplicated input")
+		gm.Expect(localAfter.Groups - localBefore.Groups).To(gm.Equal(c.AggregateGroups))
+		gm.Expect(vmeasure.BatchExecutionStats().QLBatchExecutions - before.QLBatchExecutions).To(gm.Equal(uint64(1)))
+		return
+	}
+	gm.Expect(localAfter.Executions).To(gm.Equal(localBefore.Executions), "eligible QL aggregates use the keyed native merge")
+	gm.Expect(nativeAfter.Executions - nativeBefore.Executions).To(gm.Equal(uint64(1)))
+	gm.Expect(nativeAfter.Branches-nativeBefore.Branches).To(gm.Equal(branches), "matching SUM/COUNT/AVG calls reuse native leaves")
+	gm.Expect(vmeasure.BatchExecutionStats().QLBatchExecutions - before.QLBatchExecutions).To(gm.Equal(branches))
+	gm.Expect(nativeAfter.Groups-nativeBefore.Groups).To(gm.Equal(c.AggregateGroups), "native merge retains the complete typed key union")
+	inputRows := nativeAfter.InputRows - nativeBefore.InputRows
+	visits := nativeAfter.MergeVisits - nativeBefore.MergeVisits
+	gm.Expect(visits).To(gm.BeNumerically("<=", 3*(inputRows+c.AggregateGroups*branches)), "keyed merge work grows linearly in branch rows and group states")
+}
diff --git a/test/cases/measure/ql_generated_native_aggregate_compare.go b/test/cases/measure/ql_generated_native_aggregate_compare.go
new file mode 100644
index 000000000..7f01c3946
--- /dev/null
+++ b/test/cases/measure/ql_generated_native_aggregate_compare.go
@@ -0,0 +1,100 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package measure_test
+
+import (
+	"context"
+	"fmt"
+	"slices"
+	"strings"
+
+	"google.golang.org/protobuf/encoding/protojson"
+
+	bydbqlv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/bydbql/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	"github.com/apache/skywalking-banyandb/pkg/test/helpers"
+	measuredata "github.com/apache/skywalking-banyandb/test/cases/measure/data"
+)
+
+func verifyGeneratedNativeAggregateResult(ctx context.Context, query string, expected []byte, groups uint64) (*measurev1.QueryResponse, error) {
+	if groups <= 100 {
+		return measuredata.VerifyQL(ctx, SharedContext.Connection, query, expected, false)
+	}
+	text, params, extractErr := helpers.ExtractQL(query)
+	if extractErr != nil {
+		return nil, extractErr
+	}
+	want := &measurev1.QueryResponse{}
+	if decodeErr := protojson.Unmarshal(expected, want); decodeErr != nil {
+		return nil, fmt.Errorf("decode generated native aggregate response: %w", decodeErr)
+	}
+	response, queryErr := bydbqlv1.NewBydbQLServiceClient(SharedContext.Connection).Query(ctx, &bydbqlv1.QueryRequest{Query: text, Params: params})
+	if queryErr != nil {
+		return nil, fmt.Errorf("query generated native aggregate: %w", queryErr)
+	}
+	actual := response.GetMeasureResult()
+	if actual == nil {
+		return nil, fmt.Errorf("generated native aggregate returned no measure result")
+	}
+	// The complete fixture projects the full non-null entity tuple. Validate its
+	// uniqueness and sort both responses so result validation stays linearithmic.
+	// This is independent of production key encoding and imposes no SQL ordering.
+	for _, points := range [][]*measurev1.DataPoint{actual.GetDataPoints(), want.GetDataPoints()} {
+		seen := make(map[string]bool, len(points))
+		keys := make(map[*measurev1.DataPoint]string, len(points))
+		for _, point := range points {
+			key, keyErr := generatedNativeAggregateTuple(point)
+			if keyErr != nil {
+				return nil, keyErr
+			}
+			if seen[key] {
+				return nil, fmt.Errorf("generated native aggregate repeats entity tuple %q", key)
+			}
+			seen[key], keys[point] = true, key
+		}
+		slices.SortFunc(points, func(left, right *measurev1.DataPoint) int {
+			return strings.Compare(keys[left], keys[right])
+		})
+	}
+	if compareErr := measuredata.CompareQLResult(actual, want, true); compareErr != nil {
+		return nil, compareErr
+	}
+	return actual, nil
+}
+
+func generatedNativeAggregateTuple(point *measurev1.DataPoint) (string, error) {
+	var host, region *string
+	for _, family := range point.GetTagFamilies() {
+		for _, tag := range family.GetTags() {
+			if tag.GetValue().GetStr() == nil {
+				continue
+			}
+			value := tag.GetValue().GetStr().GetValue()
+			switch tag.GetKey() {
+			case "host":
+				host = &value
+			case "region":
+				region = &value
+			}
+		}
+	}
+	if host == nil || region == nil {
+		return "", fmt.Errorf("generated native aggregate lacks its non-null host/region tuple")
+	}
+	return fmt.Sprintf("%q/%q", *host, *region), nil
+}
diff --git a/test/cases/measure/ql_generated_native_aggregate_lifecycle.go b/test/cases/measure/ql_generated_native_aggregate_lifecycle.go
new file mode 100644
index 000000000..a51d036e6
--- /dev/null
+++ b/test/cases/measure/ql_generated_native_aggregate_lifecycle.go
@@ -0,0 +1,149 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package measure_test
+
+import (
+	"context"
+	"sync/atomic"
+	"time"
+
+	gm "github.com/onsi/gomega"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	bydbqlv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/bydbql/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	liaisongrpc "github.com/apache/skywalking-banyandb/banyand/liaison/grpc"
+	ql "github.com/apache/skywalking-banyandb/pkg/query/ql"
+	"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+	vmeasure "github.com/apache/skywalking-banyandb/pkg/query/vectorized/measure"
+	"github.com/apache/skywalking-banyandb/pkg/test/helpers"
+	"github.com/apache/skywalking-banyandb/test/cases/measure/qlgen"
+)
+
+func verifyGeneratedNativeAggregateLifecycle(base time.Time, scenario qlgen.NativeAggregateScenario) {
+	c := scenario.Cases[0]
+	if scenario.Kind == "authorization" {
+		verifyGeneratedNativeAggregateAuthorization(base, c, scenario.Branches)
+		return
+	}
+	for _, branch := range []int{0, 1} {
+		if scenario.Kind == "group-cap" && branch > 0 {
+			break
+		}
+		func() {
+			var calls atomic.Uint64
+			hooks := liaisongrpc.NativeAggregateTestHooks{}
+			wantCode := codes.ResourceExhausted
+			if scenario.Kind == "group-cap" {
+				hooks.MaxGroups = 1
+			} else {
+				wantCode = codes.Unavailable
+				if scenario.Kind == "cancellation" {
+					wantCode = codes.Canceled
+				}
+				if scenario.Kind == "child-revision" {
+					wantCode = codes.FailedPrecondition
+				}
+				hooks.BeforeBranch = func(_ context.Context, index int, request *measurev1.QueryRequest) error {
+					calls.Add(1)
+					if index != branch {
+						return nil
+					}
+					if wantCode == codes.Canceled {
+						return context.Canceled
+					}
+					if wantCode == codes.FailedPrecondition {
+						request.GroupModRevisions = map[string]int64{qlgen.FixtureGroup: -1}
+						return nil
+					}
+					return status.Error(wantCode, "generated T08 branch failure")
+				}
+			}
+			restore := liaisongrpc.SetNativeAggregateHooksForTest(hooks)
+			defer restore()
+			verifyGeneratedNativeAggregateFailure(base, c, wantCode)
+			if scenario.Kind != "group-cap" {
+				gm.Expect(calls.Load()).To(gm.Equal(uint64(branch+1)), "later native branches must not execute after failure")
+			}
+		}()
+		verifyGeneratedNativeAggregateCall(base, c, scenario.Branches, false)
+	}
+}
+
+func verifyGeneratedNativeAggregateAuthorization(base time.Time, c qlgen.Case, branches uint64) {
+	verifyGeneratedNativeAggregateCall(base, c, branches, false)
+	var calls atomic.Uint64
+	func() {
+		restoreNative := liaisongrpc.SetNativeAggregateHooksForTest(liaisongrpc.NativeAggregateTestHooks{
+			BeforeBranch: func(context.Context, int, *measurev1.QueryRequest) error {
+				calls.Add(1)
+				return nil
+			},
+		})
+		defer restoreNative()
+		restoreAuthorization := liaisongrpc.SetBydbQLPlanCacheHooksForTest(liaisongrpc.PlanCacheTestHooks{
+			Context: generatedReuseAuthorization("revoked_scope"),
+		})
+		defer restoreAuthorization()
+		before := vmeasure.BatchExecutionStats()
+		verifyGeneratedNativeAggregateFailure(base, c, codes.PermissionDenied)
+		gm.Expect(vmeasure.BatchExecutionStats().NativeLeafCalls).To(gm.Equal(before.NativeLeafCalls))
+	}()
+	gm.Expect(calls.Load()).To(gm.BeZero(), "authorization must reject a cached template before opening any native branch")
+	verifyGeneratedNativeAggregateCall(base, c, branches, false)
+}
+
+func verifyGeneratedNativeAggregateFailure(base time.Time, c qlgen.Case, wantCode codes.Code) {
+	before := vmeasure.BatchExecutionStats()
+	memoryBefore := vectorized.QueryMemoryStats()
+	localBefore := ql.AggregateExecutionStats()
+	nativeBefore := ql.NativeAggregateExecutionStats()
+	defer func() {
+		after := vmeasure.BatchExecutionStats()
+		gm.Expect(after.IntermediateDataPoints).To(gm.Equal(before.IntermediateDataPoints))
+		gm.Expect(after.LiveResults).To(gm.Equal(before.LiveResults))
+		gm.Expect(vectorized.QueryMemoryStats().LiveReservedBytes).To(gm.Equal(memoryBefore.LiveReservedBytes))
+		gm.Expect(ql.AggregateExecutionStats().LiveBytes).To(gm.Equal(localBefore.LiveBytes))
+		gm.Expect(ql.NativeAggregateExecutionStats().LiveBytes).To(gm.Equal(nativeBefore.LiveBytes))
+	}()
+	query, _ := renderGeneratedQLCase(base, c)
+	text, params, extractErr := helpers.ExtractQL(query)
+	gm.Expect(extractErr).NotTo(gm.HaveOccurred())
+	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+	defer cancel()
+	response, queryErr := bydbqlv1.NewBydbQLServiceClient(SharedContext.Connection).Query(ctx, &bydbqlv1.QueryRequest{Query: text, Params: params})
+	gm.Expect(status.Code(queryErr)).To(gm.Equal(wantCode), c.Name)
+	gm.Expect(response).To(gm.BeNil(), "failed aggregate queries must not return partial rows")
+}
+
+func verifyGeneratedNativeAggregateOverflow(base time.Time, c qlgen.Case, recovery qlgen.NativeAggregateScenario) {
+	var calls atomic.Uint64
+	func() {
+		restore := liaisongrpc.SetNativeAggregateHooksForTest(liaisongrpc.NativeAggregateTestHooks{
+			BeforeBranch: func(context.Context, int, *measurev1.QueryRequest) error {
+				calls.Add(1)
+				return nil
+			},
+		})
+		defer restore()
+		verifyGeneratedNativeAggregateFailure(base, c, c.WantCode)
+	}()
+	gm.Expect(calls.Load()).To(gm.BeZero(), "wide-range integer SUM must retain T07's checked arithmetic")
+	verifyGeneratedNativeAggregateCall(base, recovery.Cases[0], recovery.Branches, false)
+}
diff --git a/test/cases/measure/qlgen/README.md b/test/cases/measure/qlgen/README.md
index fbbaa0ea7..ef7629e43 100644
--- a/test/cases/measure/qlgen/README.md
+++ b/test/cases/measure/qlgen/README.md
@@ -76,7 +76,7 @@ generation branch cannot silently remove its coverage obligation.
 | State | Required evidence |
 | --- | --- |
 | `supported` | Generated QL cases with independently calculated success expectations. |
-| `rejection` | Generated invalid QL cases with an expected error status. |
+| `rejection` | Generated QL cases with an expected error status. |
 | `pending` | An explicit reason naming the missing implementation or fixture. |
 
 These are generation obligations. Actual engine support requires passing the
@@ -189,6 +189,95 @@ go test -tags=querymetrics -count=1 -timeout=30m ./test/integration/distributed/
 Run the two topologies sequentially. These are correctness and ownership cases;
 cache-on/cache-off timing uses the separate, bounded T05 benchmark.
 
+## T07 complete SQL aggregation
+
+The artifact declares `aggregate_scenarios`, independent `aggregate_readiness`
+queries, and `aggregate_rejections`. Its 41 scenarios each bind the exact same
+statement three times: the full input, null-only or smaller input, and empty
+input. The runner executes these calls cold and warm, then repeats all three
+with the compiled cache bypassed. Seven additional statements reject unsupported
+aggregation shapes before scanning. The existing 293 legacy and T03 cases and
+18 T05 processes keep their original query texts and expected values.
+
+`ql_t07` contains exactly fixture F's six rows. `ql_t07_groups` separately adds
+513 non-entity zone groups with one, two or three rows each across two host
+entities, for 1,026 logical rows.
+Both measures receive stale versions followed by the final rows with matching
+series/timestamps, in the existing replicated group. QL-only raw readiness checks
+every final value before aggregate cases run. No native query supplies expected
+results or readiness.
+
+`t07_model.go`, `t07_generate.go` and `t07_oracle.go` cover scalar and grouped
+COUNT/SUM/MIN/MAX/AVG/MEAN over integer and floating fields, multiple and repeated
+calls, tag counts, qualified names, hidden grouping keys, nullable string/integer/
+float keys, residual time OR, final LIMIT/OFFSET, and native source spelling that
+activates SQL aggregation. The scalar oracle evaluates typed rows directly and
+encodes grouping tuples with JSON, independently of the engine's group codec.
+NULL and empty strings remain separate. Counts retain integer type; AVG/MEAN
+divide the complete sum by the non-null count. Null-only and empty scalar COUNT
+is zero; other scalar aggregates are null, and grouped empty input has no rows.
+
+Every successful aggregate RPC checks exact consumed rows after deduplication,
+actual group cardinality, one local aggregate execution, absence of source
+identity/timestamps on synthesized rows, and restored group/native memory
+ownership. These structural assertions require the instrumented build:
+
+```sh
+go test -tags=querymetrics -count=1 -timeout=30m ./test/integration/standalone/query \
+  -ginkgo.label-filter=QLB-T07 -ginkgo.fail-on-empty
+go test -tags=querymetrics -count=1 -timeout=30m ./test/integration/distributed/query \
+  -ginkgo.label-filter=QLB-T07 -ginkgo.fail-on-empty
+```
+
+Run the topologies sequentially. T07 does not alter the existing legacy MEAN or
+COUNT(DISTINCT) fixtures. Native aggregate pushdown and keyed merging are T08;
+T07's new SQL semantics execute over one complete native raw scan.
+
+## T08 native aggregate pushdown and keyed merge
+
+The artifact adds `native_aggregate_scenarios`, `native_aggregate_readiness`,
+and `native_aggregate_rejections`. The 30 scenarios retain three identical query
+texts with independently evaluated changed bindings. Eligible native cohorts
+run cold, warm, with the compiled cache bypassed, and with native aggregation
+disabled so the identical SQL also exercises T07. Unsafe cohorts must use T07
+automatically. A checked integer-SUM overflow retains its existing RPC error and
+must open no native aggregate branch. Every expected result is generated from
+fixture rows; neither readiness nor verification issues a native Query RPC.
+
+`ql_t08` uses the complete entity tuple `(host, region)`, unequal per-entity
+populations, nullable fields and keys, an all-null entity, sub-one floating means,
+and integer extrema above 2^53. `ql_t08_groups` contains 129 distinct entity tuples
+and 258 logical rows. Stale and final versions share each logical row's identity.
+Bounded raw QL visibility windows verify every final value before aggregation.
+The indexed non-entity `key` is constant within each full entity, as required by
+the series index. Different entities retain NULL, empty and ordinary string keys.
+The setup installs the key index and binding, then retries writes until a separate
+indexed QL visibility witness succeeds; raw windows still verify every row.
+The oracle uses typed integer extrema and exact rational sums and averages,
+independently of native aggregate state and the production grouping codec.
+
+The runner proves SUM/COUNT/AVG leaf reuse, complete tuple-key merging beyond the
+default 100 rows, null-only and empty semantics, cold/warm binding isolation, and
+fallback for incomplete or unproven entity keys, nullable non-entity grouping,
+integer arithmetic, floating extrema, tag counts, and residual time predicates.
+It also injects a group cap, first and later branch errors and cancellation, and
+authorization denial after a cached plan succeeds, and child revision-gate failure.
+Failed RPCs return no partial
+rows, release all tracked state and native results, and allow a subsequent query
+to succeed. Bucket grouping remains in its later grammar ticket; T08 does not
+claim a new bucket syntax through these RPC cases.
+
+```sh
+go test -tags=querymetrics -count=1 -timeout=30m ./test/integration/standalone/query \
+  -ginkgo.label-filter=QLB-T08 -ginkgo.fail-on-empty
+go test -tags=querymetrics -count=1 -timeout=30m ./test/integration/distributed/query \
+  -ginkgo.label-filter=QLB-T08 -ginkgo.fail-on-empty
+```
+
+Run these sequentially. The existing 293 cases, 18 T05 scenarios, 41 T07
+scenarios and seven T07 rejections keep their query texts, expected values and
+pair-coverage report unchanged.
+
 ## Engine regressions exposed by this suite
 
 The initial standalone and distributed runs each execute all 151 cases, with
@@ -215,7 +304,6 @@ The authoritative inventory is `coverageContract` in `coverage_contract.go`.
 
 | Capability | Missing implementation or fixture |
 | --- | --- |
-| Catalog B3 | Multiple SQL aggregates and SQL-correct finalization require T07/T08. |
 | Catalog B4 | Post-aggregate `HAVING` and new membership syntax require T14. |
 | Catalog B5 | Multiple sort keys and null placement require T17. |
 | Catalog B6 | `SELECT DISTINCT` requires T15. |
diff --git a/test/cases/measure/qlgen/coverage_contract.go b/test/cases/measure/qlgen/coverage_contract.go
index 849a6c384..e88e2a4de 100644
--- a/test/cases/measure/qlgen/coverage_contract.go
+++ b/test/cases/measure/qlgen/coverage_contract.go
@@ -23,6 +23,44 @@ import "fmt"
 // generator branch must leave a missing witness, not erase its coverage claim.
 // A pending entry records a gap; it never counts as successful QL coverage.
 var coverageContract = []Feature{
+	{ID: "t08.fixture", Status: "supported"},
+	{ID: "t08.native_float", Status: "supported"},
+	{ID: "t08.null_empty", Status: "supported"},
+	{ID: "t08.tuple_keys", Status: "supported"},
+	{ID: "t08.rebind", Status: "supported"},
+	{ID: "t08.shared_leaves", Status: "supported"},
+	{ID: "t08.exact_integer", Status: "supported"},
+	{ID: "t08.scalar_entity", Status: "supported"},
+	{ID: "t08.complete_groups", Status: "supported"},
+	{ID: "t08.dedup", Status: "supported"},
+	{ID: "t08.fallback", Status: "supported"},
+	{ID: "t08.weighted_average", Status: "supported"},
+	{ID: "t08.no_scan", Status: "supported"},
+	{ID: "t08.lifecycle", Status: "supported"},
+	{ID: "t08.integer_overflow", Status: "rejection"},
+	{ID: "t08.child_revision", Status: "supported"},
+	{ID: "t07.fixture", Status: "supported"},
+	{ID: "t07.aggregate.COUNT", Status: "supported"},
+	{ID: "t07.aggregate.SUM", Status: "supported"},
+	{ID: "t07.aggregate.MIN", Status: "supported"},
+	{ID: "t07.aggregate.MAX", Status: "supported"},
+	{ID: "t07.aggregate.AVG", Status: "supported"},
+	{ID: "t07.aggregate.MEAN", Status: "supported"},
+	{ID: "t07.null_empty", Status: "supported"},
+	{ID: "t07.rebind", Status: "supported"},
+	{ID: "t07.multiple", Status: "supported"},
+	{ID: "t07.shared_state", Status: "supported"},
+	{ID: "t07.typed_keys", Status: "supported"},
+	{ID: "t07.hidden_slots", Status: "supported"},
+	{ID: "t07.count_tags", Status: "supported"},
+	{ID: "t07.local_activation", Status: "supported"},
+	{ID: "t07.default_names", Status: "supported"},
+	{ID: "t07.qualified_columns", Status: "supported"},
+	{ID: "t07.residual_time", Status: "supported"},
+	{ID: "t07.complete_input", Status: "supported"},
+	{ID: "t07.dedup", Status: "supported"},
+	{ID: "t07.limit", Status: "supported"},
+	{ID: "t07.invalid_shape", Status: "rejection"},
 	{ID: "t03.order.timestamp_alias", Status: "supported"},
 	{ID: "t03.invalid.order", Status: "rejection"},
 	{ID: "t03.time.domain", Status: "supported"},
@@ -100,7 +138,7 @@ var coverageContract = []Feature{
 	{ID: "invalid.syntax", Status: "rejection"},
 	{ID: "catalog.B1", Status: "supported"},
 	{ID: "catalog.B2", Status: "supported"},
-	{ID: "catalog.B3", Status: "pending", Reason: "Multiple SQL aggregates and SQL-correct finalization require T07/T08"},
+	{ID: "catalog.B3", Status: "supported"},
 	{ID: "catalog.B4", Status: "pending", Reason: "Post-aggregate HAVING and new membership syntax require T14"},
 	{ID: "catalog.B5", Status: "pending", Reason: "Multiple sort keys and NULL placement require T17"},
 	{ID: "catalog.B6", Status: "pending", Reason: "SELECT DISTINCT requires T15"},
diff --git a/test/cases/measure/qlgen/fixture.go b/test/cases/measure/qlgen/fixture.go
index 9d5bdafa0..adf548b4b 100644
--- a/test/cases/measure/qlgen/fixture.go
+++ b/test/cases/measure/qlgen/fixture.go
@@ -143,14 +143,30 @@ func FixtureWrites(base time.Time) []*measurev1.WriteRequest {
 // FixtureDigest identifies the exact schema and values used by generated expectations.
 func FixtureDigest() string {
 	data, err := json.Marshal(struct {
-		Schema    *databasev1.Measure
-		Binding   *databasev1.IndexRuleBinding
-		Rules     []*databasev1.IndexRule
-		Rows      []fixtureRow
-		T03Schema *databasev1.Measure
-		T05Schema *databasev1.Measure
-		T03Rows   []fixtureRow
+		Schema          *databasev1.Measure
+		Binding         *databasev1.IndexRuleBinding
+		Rules           []*databasev1.IndexRule
+		Rows            []fixtureRow
+		T03Schema       *databasev1.Measure
+		T05Schema       *databasev1.Measure
+		T03Rows         []fixtureRow
+		T07Schema       *databasev1.Measure
+		T07Rows         []fixtureRow
+		T07GroupsSchema *databasev1.Measure
+		T07GroupsRows   []fixtureRow
+		T08Schema       *databasev1.Measure
+		T08Rows         []t08Row
+		T08GroupsSchema *databasev1.Measure
+		T08GroupsRows   []t08Row
+		T08Indexes      []*databasev1.IndexRule
+		T08Bindings     []*databasev1.IndexRuleBinding
 	}{
+		T08Indexes:  []*databasev1.IndexRule{T08FixtureIndexRule(T08FixtureName), T08FixtureIndexRule(T08GroupsFixtureName)},
+		T08Bindings: []*databasev1.IndexRuleBinding{T08FixtureIndexBinding(T08FixtureName), T08FixtureIndexBinding(T08GroupsFixtureName)},
+		T08Schema:   T08FixtureSchema(T08FixtureName), T08Rows: t08Rows(T08FixtureName),
+		T08GroupsSchema: T08FixtureSchema(T08GroupsFixtureName), T08GroupsRows: t08Rows(T08GroupsFixtureName),
+		T07Schema: T07FixtureSchema(T07FixtureName), T07Rows: t07Rows(T07FixtureName),
+		T07GroupsSchema: T07FixtureSchema(T07GroupsFixtureName), T07GroupsRows: t07Rows(T07GroupsFixtureName),
 		T05Schema: T05FixtureSchema(), Rows: fixtureRows(), T03Schema: T03FixtureSchema(), T03Rows: t03Rows(),
 		Schema: FixtureSchema(), Binding: FixtureIndexBinding(), Rules: FixtureIndexRules(),
 	})
diff --git a/test/cases/measure/qlgen/generate.go b/test/cases/measure/qlgen/generate.go
index a27d72c6f..d8944ca5d 100644
--- a/test/cases/measure/qlgen/generate.go
+++ b/test/cases/measure/qlgen/generate.go
@@ -47,6 +47,16 @@ func Generate() (Suite, error) {
 	pairs := generateInteractions(b)
 	generateRejections(b)
 	generateT03(b)
+	aggregateReadiness, aggregateScenarios, aggregateErr := generateT07(b)
+	if aggregateErr != nil {
+		return Suite{}, aggregateErr
+	}
+	aggregateRejections := generateT07Rejections(b)
+	nativeReadiness, nativeScenarios, nativeErr := generateT08(b)
+	if nativeErr != nil {
+		return Suite{}, nativeErr
+	}
+	nativeRejections := generateT08Rejections(b)
 	if b.err != nil {
 		return Suite{}, b.err
 	}
@@ -68,7 +78,11 @@ func Generate() (Suite, error) {
 		return Suite{}, reuseReadinessErr
 	}
 	suite := Suite{
-		ReuseScenarios: reuseScenarios, ReuseReadiness: reuseReadiness, Version: 1, Seed: 1412,
+		NativeAggregateReadiness: nativeReadiness, NativeAggregateScenarios: nativeScenarios,
+		NativeAggregateRejections: nativeRejections,
+		AggregateReadiness:        aggregateReadiness, AggregateScenarios: aggregateScenarios,
+		AggregateRejections: aggregateRejections,
+		ReuseScenarios:      reuseScenarios, ReuseReadiness: reuseReadiness, Version: 1, Seed: 1412,
 		FixtureDigest: FixtureDigest(), Features: features, Cases: b.cases, Pairs: pairs,
 	}
 	return suite, suite.Validate()
diff --git a/test/cases/measure/qlgen/generated/suite.json b/test/cases/measure/qlgen/generated/suite.json
index dd15b59e0..153cc9aae 100644
--- a/test/cases/measure/qlgen/generated/suite.json
+++ b/test/cases/measure/qlgen/generated/suite.json
@@ -1,5 +1,112594 @@
 {
-  "fixture_digest": "4936a6d975d4c8f0ebab1cd6473113b469e09a5e046dc6eb46d4d21fdecbbd4a",
+  "native_aggregate_readiness": [
+    {
+      "name": "ql_t08",
+      "cases": [
+        {
+          "ticket": "T08",
+          "name": "t08_ready_ql_t08_0",
+          "query": "SELECT rid, host, region, key, v::FIELD, f::FIELD FROM replicated_group.ql_t08 WHERE time \u003e= '{{time:0}}' AND time \u003c '{{time:9001}}' ORDER BY TIME ASC",
+          "want": {
+            "dataPoints": [
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "9007199254740993"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.25
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "1"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "a"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "null": 0
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:0}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "9007199254740995"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.75
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "2"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "a"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "null": 0
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:1000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "null": 0
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "null": 0
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "3"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "a"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "null": 0
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:2000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "2"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.125
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "4"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "a"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "west"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": ""
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:3000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "4"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 1.5
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "5"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "b"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "x"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:4000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "null": 0
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "null": 0
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "6"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "b"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "x"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:5000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "null": 0
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "null": 0
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "7"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "c"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "west"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "null": 0
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:6000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "null": 0
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "null": 0
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "8"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "c"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "west"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "null": 0
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:7000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "9223372036854775807"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 2
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "9"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "overflow"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "x"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:8000}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "1"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "null": 0
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "10"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "overflow"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "x"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:9000}}"
+              }
+            ]
+          },
+          "features": [
+            "t08.fixture"
+          ],
+          "ordered": true
+        },
+        {
+          "ticket": "T08",
+          "name": "t08_ready_indexed_ql_t08",
+          "query": "SELECT rid, host, region, key, v::FIELD, f::FIELD FROM replicated_group.ql_t08 WHERE time \u003e= '{{time:-1}}' AND time \u003c '{{time:100000}}' AND key = '' ORDER BY TIME ASC",
+          "want": {
+            "dataPoints": [
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "2"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.125
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "4"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "a"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "west"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": ""
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:3000}}"
+              }
+            ]
+          },
+          "features": [
+            "t08.fixture"
+          ],
+          "ordered": true
+        }
+      ]
+    },
+    {
+      "name": "ql_t08_groups",
+      "cases": [
+        {
+          "ticket": "T08",
+          "name": "t08_ready_ql_t08_groups_0",
+          "query": "SELECT rid, host, region, key, v::FIELD, f::FIELD FROM replicated_group.ql_t08_groups WHERE time \u003e= '{{time:0}}' AND time \u003c '{{time:1501}}' ORDER BY TIME ASC",
+          "want": {
+            "dataPoints": [
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "null": 0
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "null": 0
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "1"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "host-000"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "key-000"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:0}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "3"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.25
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "2"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "host-000"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "west"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "key-001"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:100}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "4"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.375
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "3"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "host-000"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "west"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "key-001"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:200}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "6"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.5
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "4"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "host-001"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "key-002"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:300}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "7"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.625
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "5"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "host-001"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
+                            "value": "east"
+                          }
+                        }
+                      },
+                      {
+                        "key": "key",
+                        "value": {
+                          "str": {
+                            "value": "key-002"
+                          }
+                        }
+                      }
+                    ]
+                  }
+                ],
+                "timestamp": "{{time:400}}"
+              },
+              {
+                "fields": [
+                  {
+                    "name": "v",
+                    "value": {
+                      "int": {
+                        "value": "8"
+                      }
+                    }
+                  },
+                  {
+                    "name": "f",
+                    "value": {
+                      "float": {
+                        "value": 0.75
+                      }
+                    }
+                  }
+                ],
+                "tagFamilies": [
+                  {
+                    "name": "default",
+                    "tags": [
+                      {
+                        "key": "rid",
+                        "value": {
+                          "int": {
+                            "value": "6"
+                          }
+                        }
+                      },
+                      {
+                        "key": "host",
+                        "value": {
+                          "str": {
+                            "value": "host-001"
+                          }
+                        }
+                      },
+                      {
+                        "key": "region",
+                        "value": {
+                          "str": {
... 116755 lines suppressed ...