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/09/15 09:09:52 UTC
(skywalking-banyandb) branch release-0.11.1 created (now 0f4aa5e1e)
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a change to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
at 0f4aa5e1e fix(logger): restore pflag import dropped during cherry-pick
This branch includes the following new commits:
new d5959ac1d Fix standalone TopN write ordering (#1304)
new 393b28d8a Fix stale query clauses after Canopy resource changes (#1312)
new c9335edb1 Bound the vectorized stream Top-N merge by limit+offset (#1322)
new 0c433fa67 fix(canopy): enforce readonly role on /monitoring/* proxy (#1330)
new ee098a7ce Fix FODC proxy `/metrics` returning partial data or timing out when concurrent scrapes overlap. (#1328)
new 17c0e32b5 perf(stream): bound the filtered index-order vectorized Top-N merge (#1331)
new 438b215f5 Pre setting logging level before flags parse (#1337)
new c98ba8d53 fix: honor log level for native metrics (#1338)
new e075769c6 fix(trace): enforce time ranges for non-timestamp sorted queries (#1333)
new 014b08c39 Update schema registry client init retry strategy (#1344)
new e4c6be3de fix(query): bound Property/Stream/Trace query allocations (#1334)
new 067d5e817 fix(bydbql): reject deeply nested WHERE parentheses to prevent stack overflow (#1343)
new a1e3c8524 fix(observability): stop liaison gauge from stealing memory_state schema (#1346)
new 8aad00ce2 chore(release): add CHANGES.md notes for v0.11.1
new 0f4aa5e1e fix(logger): restore pflag import dropped during cherry-pick
The 15 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-banyandb) 11/15: fix(query): bound Property/Stream/Trace query allocations (#1334)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit e4c6be3def1c97dd201dcd863d4fc45771ac6989
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Mon Sep 14 14:08:18 2026 +0800
fix(query): bound Property/Stream/Trace query allocations (#1334)
(cherry picked from commit d28ff6277845e2175b7d98e7f78b5d4d81da6f01)
---
banyand/internal/sidx/query.go | 22 ++
banyand/internal/sidx/query_memory_guard_test.go | 48 +++
banyand/liaison/grpc/property.go | 41 ++-
banyand/liaison/grpc/property_capacity.go | 43 +++
banyand/liaison/grpc/property_capacity_test.go | 72 ++++
.../grpc/property_charge_regression_test.go | 56 +++
banyand/liaison/grpc/query_admission.go | 96 +++++
.../liaison/grpc/query_admission_review_test.go | 182 ++++++++++
banyand/liaison/grpc/query_admission_transport.go | 44 +++
banyand/liaison/grpc/server.go | 4 +
banyand/liaison/grpc/stream.go | 23 ++
banyand/liaison/grpc/trace.go | 19 +
banyand/property/db/db.go | 6 +-
banyand/property/db/query_capacity.go | 30 ++
banyand/property/db/query_capacity_test.go | 122 +++++++
banyand/property/db/shard.go | 10 +-
banyand/property/listener.go | 29 +-
banyand/protector/protector.go | 52 ++-
banyand/protector/query_admission_report_test.go | 64 ++++
banyand/protector/query_budget.go | 386 +++++++++++++++++++++
banyand/protector/query_budget_review_test.go | 159 +++++++++
banyand/protector/query_budget_test.go | 107 ++++++
banyand/protector/query_reservation_test.go | 145 ++++++++
banyand/protector/query_scan_budget_test.go | 193 +++++++++++
banyand/query/processor.go | 42 ++-
banyand/query/query.go | 8 +
banyand/queue/sub/sub.go | 5 +-
banyand/stream/query_by_idx.go | 10 +-
banyand/stream/query_capacity.go | 26 ++
banyand/stream/query_capacity_test.go | 77 ++++
banyand/stream/query_vectorized.go | 7 +
banyand/trace/query_capacity_test.go | 45 +++
banyand/trace/query_vectorized.go | 5 +
banyand/trace/streaming_pipeline.go | 48 ++-
banyand/trace/streaming_pipeline_test.go | 26 ++
.../internal/integration/failure_scenarios_test.go | 84 ++---
pkg/cmdsetup/data.go | 2 +-
pkg/cmdsetup/liaison.go | 1 +
pkg/cmdsetup/liaison_protector_test.go | 54 +++
pkg/cmdsetup/standalone.go | 2 +-
pkg/index/inverted/inverted_series.go | 114 +++---
pkg/index/inverted/query_scan_budget_test.go | 86 +++++
pkg/query/budget.go | 88 +++++
pkg/query/budget_result_test.go | 58 ++++
pkg/query/limit.go | 30 ++
pkg/query/limit_test.go | 68 ++++
pkg/query/logical/stream/stream_analyzer.go | 10 +-
.../logical/stream/stream_plan_distributed.go | 6 +-
pkg/query/logical/trace/trace_analyzer.go | 10 +-
pkg/query/logical/trace/trace_plan_distributed.go | 6 +-
pkg/query/transport_scope.go | 82 +++++
pkg/query/transport_scope_test.go | 41 +++
pkg/query/vectorized/retained_bytes.go | 116 +++++++
pkg/query/vectorized/retained_bytes_test.go | 26 ++
pkg/query/vectorized/stream/sorted_merge.go | 7 +-
pkg/query/vectorized/stream/sorted_merge_test.go | 31 ++
pkg/test/helpers/constant.go | 2 +
test/cases/property/data/input/all_max_int.ql | 19 +
test/cases/property/data/input/all_max_int.yaml | 24 ++
test/cases/property/data/input/all_max_uint.ql | 19 +
test/cases/property/data/input/all_max_uint.yaml | 24 ++
test/cases/property/property.go | 2 +
test/cases/stream/data/data.go | 12 +-
test/cases/stream/data/input/all_above_window.ql | 20 ++
test/cases/stream/data/input/all_above_window.yaml | 26 ++
test/cases/stream/stream.go | 4 +
test/e2e-v2/cases/cluster/docker-compose.yml | 2 +
.../istio/banyandb/banyandb-deployment-vec.yaml | 7 +
.../istio/banyandb/banyandb-deployment.yaml | 7 +
69 files changed, 3194 insertions(+), 148 deletions(-)
diff --git a/banyand/internal/sidx/query.go b/banyand/internal/sidx/query.go
index 69867edb9..a26e4f4b0 100644
--- a/banyand/internal/sidx/query.go
+++ b/banyand/internal/sidx/query.go
@@ -607,6 +607,7 @@ func (s *sidx) buildCursorsForBatch(
jobCh := make(chan blockScanResult, workerCount)
resultCh := make(chan *blockCursor, len(batch.bss))
+ errCh := make(chan error, 1)
var workerWg sync.WaitGroup
workerWg.Add(workerCount)
@@ -627,6 +628,13 @@ func (s *sidx) buildCursorsForBatch(
return
}
+ if chargeErr := query.Charge(ctx, bsResult.bm.uncompressedSize); chargeErr != nil {
+ select {
+ case errCh <- chargeErr:
+ default:
+ }
+ continue
+ }
effectiveReq, effectiveTagsToLoad := requestForPart(req, tagsToLoad, bsResult.p)
bc := generateBlockCursor()
bc.init(bsResult.p, &bsResult.bm, effectiveReq)
@@ -669,6 +677,14 @@ func (s *sidx) buildCursorsForBatch(
workerWg.Wait()
close(resultCh)
+ select {
+ case chargeErr := <-errCh:
+ for cursor := range resultCh {
+ releaseBlockCursor(cursor)
+ }
+ return nil, chargeErr
+ default:
+ }
cursors := make([]*blockCursor, 0, len(batch.bss))
for bc := range resultCh {
@@ -707,6 +723,12 @@ func (s *sidx) buildCursorsForBatchSync(
}
return nil, ctxErr
}
+ if chargeErr := query.Charge(ctx, bsResult.bm.uncompressedSize); chargeErr != nil {
+ for i := range cursors {
+ releaseBlockCursor(cursors[i])
+ }
+ return nil, chargeErr
+ }
effectiveReq, effectiveTagsToLoad := requestForPart(req, tagsToLoad, bsResult.p)
bc := generateBlockCursor()
bc.init(bsResult.p, &bsResult.bm, effectiveReq)
diff --git a/banyand/internal/sidx/query_memory_guard_test.go b/banyand/internal/sidx/query_memory_guard_test.go
new file mode 100644
index 000000000..aa95fd10c
--- /dev/null
+++ b/banyand/internal/sidx/query_memory_guard_test.go
@@ -0,0 +1,48 @@
+// Licensed to the 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. The 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 sidx
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+type tinySIDXLease struct{ remaining uint64 }
+
+func (l *tinySIDXLease) Charge(bytes uint64) error {
+ if bytes > l.remaining {
+ return errors.New("sidx test budget exhausted")
+ }
+ l.remaining -= bytes
+ return nil
+}
+func (*tinySIDXLease) Limit() uint64 { return 1024 }
+func (l *tinySIDXLease) Used() uint64 { return 1024 - l.remaining }
+func (*tinySIDXLease) Owner() any { return "sidx-test" }
+
+func TestBuildCursorsForBatchSyncChargesBeforeBlockDecode(t *testing.T) {
+ s := &sidx{}
+ ctx := query.WithBudgetLease(context.Background(), &tinySIDXLease{remaining: 1024})
+ batch := &blockScanResultBatch{bss: []blockScanResult{{bm: blockMetadata{uncompressedSize: 1 << 20}}}}
+ cursors, err := s.buildCursorsForBatchSync(ctx, batch, nil, QueryRequest{}, true, &batchMetrics{})
+ if err == nil {
+ t.Fatal("expected oversized block charge failure")
+ }
+ if cursors != nil {
+ t.Fatalf("expected no cursors, got %d", len(cursors))
+ }
+}
diff --git a/banyand/liaison/grpc/property.go b/banyand/liaison/grpc/property.go
index 0393d759f..c2f503f24 100644
--- a/banyand/liaison/grpc/property.go
+++ b/banyand/liaison/grpc/property.go
@@ -41,6 +41,7 @@ import (
"github.com/apache/skywalking-banyandb/banyand/metadata"
"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
propertydb "github.com/apache/skywalking-banyandb/banyand/property/db"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
"github.com/apache/skywalking-banyandb/banyand/queue"
"github.com/apache/skywalking-banyandb/pkg/accesslog"
"github.com/apache/skywalking-banyandb/pkg/bus"
@@ -64,6 +65,7 @@ type propertyServer struct {
nodeRegistry NodeRegistry
metrics *metrics
repairQueue *repairQueue
+ queryBudget *protector.QueryBudget
repairQueueCount int
}
@@ -424,6 +426,26 @@ func (ps *propertyServer) Delete(ctx context.Context, req *propertyv1.DeleteRequ
}
func (ps *propertyServer) Query(ctx context.Context, req *propertyv1.QueryRequest) (resp *propertyv1.QueryResponse, err error) {
+ if req == nil {
+ return nil, status.Error(codes.InvalidArgument, "query request is nil")
+ }
+ ctx, release, admissionErr := admitPropertyQuery(ctx, ps.queryBudget, req)
+ if admissionErr != nil {
+ return nil, admissionErr
+ }
+ defer release()
+ defer func() {
+ if err == nil {
+ err = query.ChargeResponse(ctx, resp)
+ if err != nil {
+ resp = nil
+ }
+ }
+ err = queryStatus(err)
+ }()
+ if len(req.Groups) == 0 {
+ return nil, schema.BadRequest("groups", "groups should not be empty")
+ }
for _, g := range req.Groups {
if acquireErr := ps.groupRepo.acquireRequest(g); acquireErr != nil {
return nil, status.Errorf(codes.FailedPrecondition, "group %s is pending deletion", g)
@@ -450,9 +472,6 @@ func (ps *propertyServer) Query(ctx context.Context, req *propertyv1.QueryReques
}
}
}()
- if len(req.Groups) == 0 {
- return nil, schema.BadRequest("groups", "groups should not be empty")
- }
if req.Limit == 0 {
req.Limit = 100
}
@@ -465,6 +484,11 @@ func (ps *propertyServer) Query(ctx context.Context, req *propertyv1.QueryReques
return &propertyv1.QueryResponse{Properties: nil, Trace: trace}, nil
}
+ // Reserve merge containers, revision replacements, entity keys and projection
+ // slices before deduplication. Count all replicas, not just the requested limit.
+ if mergeChargeErr := chargePropertyMerge(ctx, nodeProperties); mergeChargeErr != nil {
+ return nil, mergeChargeErr
+ }
var properties []*propertyWithCount
// Choose processing path based on whether ordering is requested
@@ -534,7 +558,7 @@ func (ps *propertyServer) sortedQueryWithDedup(
// seenIDs tracks entity -> propertyWithCount for deduplication
seenIDs := make(map[string]*propertyWithCount)
// resultBuffer maintains sorted list of unique properties
- resultBuffer := make([]*propertyWithCount, 0, req.Limit)
+ resultBuffer := make([]*propertyWithCount, 0, propertyQueryCapacity(req.Limit))
// Process items from k-way merge
for mergeIter.Next() {
@@ -787,6 +811,12 @@ func (ps *propertyServer) queryProperties(
switch v := d.(type) {
case *propertyv1.InternalQueryResponse:
for i, s := range v.Sources {
+ // Source bytes are already JSON; reserve payload plus a small
+ // structural overhead rather than an 8x inflate that exhausts
+ // the liaison fallback pool during OAP UI-template list-all.
+ if chargeErr := query.ChargeResult(ctx, uint64(len(s))+256); chargeErr != nil {
+ return nil, groups, trace, chargeErr
+ }
var p propertyv1.Property
var deleteTime int64
unmarshalErr := protojson.Unmarshal(s, &p)
@@ -802,6 +832,9 @@ func (ps *propertyServer) queryProperties(
sortedValue = v.SortedValues[i]
}
+ if chargeErr := query.Charge(ctx, 128); chargeErr != nil {
+ return nil, groups, trace, chargeErr
+ }
property := &propertyWithMetadata{
Property: &p,
sortedValue: sortedValue,
diff --git a/banyand/liaison/grpc/property_capacity.go b/banyand/liaison/grpc/property_capacity.go
new file mode 100644
index 000000000..40a6615f0
--- /dev/null
+++ b/banyand/liaison/grpc/property_capacity.go
@@ -0,0 +1,43 @@
+// Licensed to the 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. The 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"
+
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+const propertyQueryCapacityHint = 1024
+
+func propertyQueryCapacity(limit uint32) int {
+ if limit > propertyQueryCapacityHint {
+ return propertyQueryCapacityHint
+ }
+ return int(limit)
+}
+
+func chargePropertyMerge(ctx context.Context, nodeProperties map[string][]*propertyWithMetadata) error {
+ for _, properties := range nodeProperties {
+ for _, property := range properties {
+ metadata := property.GetMetadata()
+ entityBytes := uint64(len(metadata.GetGroup())) + uint64(len(metadata.GetName())) + uint64(len(property.GetId())) + 2
+ containerBytes := uint64(len(property.GetTags()))*16 + uint64(len(property.node)) + 512
+ if chargeErr := query.Charge(ctx, entityBytes+containerBytes); chargeErr != nil {
+ return chargeErr
+ }
+ }
+ }
+ return nil
+}
diff --git a/banyand/liaison/grpc/property_capacity_test.go b/banyand/liaison/grpc/property_capacity_test.go
new file mode 100644
index 000000000..e24cea523
--- /dev/null
+++ b/banyand/liaison/grpc/property_capacity_test.go
@@ -0,0 +1,72 @@
+// Licensed to the 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. The 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 (
+ "math"
+ "testing"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+)
+
+// Ordered property merge buffers previously used req.Limit as make capacity.
+// Keep the hint capped for oversized limits.
+func TestPropertyQueryCapacityCapsOversizedLimit(t *testing.T) {
+ if got := propertyQueryCapacity(math.MaxUint32); got != propertyQueryCapacityHint {
+ t.Fatalf("propertyQueryCapacity(MaxUint32)=%d, want %d", got, propertyQueryCapacityHint)
+ }
+ if got := propertyQueryCapacity(32); got != 32 {
+ t.Fatalf("propertyQueryCapacity(32)=%d, want 32", got)
+ }
+ const oversized uint32 = 4096
+ buf := make([]*propertyWithCount, 0, propertyQueryCapacity(oversized))
+ if cap(buf) != propertyQueryCapacityHint {
+ t.Fatalf("liaison buffer capacity %d, want %d", cap(buf), propertyQueryCapacityHint)
+ }
+}
+
+func TestSortedQueryWithDedupCapacityBounded(t *testing.T) {
+ const oversizedLimit uint32 = 4096
+ ps := &propertyServer{}
+ req := &propertyv1.QueryRequest{
+ Limit: oversizedLimit,
+ OrderBy: &propertyv1.QueryOrder{
+ TagName: "tag1",
+ Sort: modelv1.Sort_SORT_ASC,
+ },
+ }
+ nodeProperties := map[string][]*propertyWithMetadata{
+ "node-a": {{
+ Property: &propertyv1.Property{
+ Metadata: &commonv1.Metadata{
+ Group: "g",
+ Name: "n",
+ ModRevision: 1,
+ },
+ Id: "cap-id",
+ },
+ node: "node-a",
+ sortedValue: []byte("tag1"),
+ }},
+ }
+ out := ps.sortedQueryWithDedup(nodeProperties, req)
+ if len(out) == 0 {
+ t.Fatal("expected sortedQueryWithDedup to retain the input property")
+ }
+ if cap(out) != propertyQueryCapacityHint {
+ t.Fatalf("sortedQueryWithDedup capacity %d, want %d", cap(out), propertyQueryCapacityHint)
+ }
+}
diff --git a/banyand/liaison/grpc/property_charge_regression_test.go b/banyand/liaison/grpc/property_charge_regression_test.go
new file mode 100644
index 000000000..150da17ac
--- /dev/null
+++ b/banyand/liaison/grpc/property_charge_regression_test.go
@@ -0,0 +1,56 @@
+// 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"
+ "testing"
+
+ propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+// Liaison roles do not enable the memory protector, so property list-all uses the
+// 64 MiB fallback pool (8 MiB max query). OAP UI-template bootstrap must fit.
+func TestPropertySourceChargeFitsFallbackScanBudget(t *testing.T) {
+ budget := protector.NewQueryBudget(nil)
+ ctx, release, err := budget.AdmitScanContext(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ // ~1 MiB of UI-template JSON (matches Cluster E2E property shard footprint).
+ const properties, payload = 80, 12 << 10
+ for i := 0; i < properties; i++ {
+ source := make([]byte, payload)
+ if chargeErr := query.ChargeResult(ctx, uint64(len(source))+256); chargeErr != nil {
+ t.Fatalf("property %d exhausted fallback scan budget: %v", i, chargeErr)
+ }
+ if chargeErr := query.Charge(ctx, 128); chargeErr != nil {
+ t.Fatal(chargeErr)
+ }
+ }
+ resp := &propertyv1.QueryResponse{Properties: make([]*propertyv1.Property, properties)}
+ for i := range resp.Properties {
+ resp.Properties[i] = &propertyv1.Property{Id: string(make([]byte, payload/4))}
+ }
+ if chargeErr := query.ChargeResponse(ctx, resp); chargeErr != nil {
+ t.Fatalf("response encoding exhausted fallback scan budget: %v", chargeErr)
+ }
+}
diff --git a/banyand/liaison/grpc/query_admission.go b/banyand/liaison/grpc/query_admission.go
new file mode 100644
index 000000000..2758c3689
--- /dev/null
+++ b/banyand/liaison/grpc/query_admission.go
@@ -0,0 +1,96 @@
+// Licensed to the 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. The 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"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+
+ propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+const maxQueryWindow uint32 = 100000
+
+func admitQuery(ctx context.Context, budget *protector.QueryBudget, limit, offset, defaultLimit uint32) (context.Context, func(), error) {
+ if budget == nil {
+ budget = protector.QueryBudgetFor(nil)
+ }
+ if limit == 0 {
+ limit = defaultLimit
+ }
+ // MaxUint32 is the historical list-all / max-limit sentinel used by OAP and
+ // integration fixtures. Admit as a scan; capacity hints and ChargeResult bound memory.
+ if query.IsUnboundedLimit(limit, offset) {
+ admittedCtx, release, admissionErr := budget.AdmitScanContext(ctx)
+ if admissionErr != nil {
+ return ctx, nil, queryStatus(admissionErr)
+ }
+ return admittedCtx, release, nil
+ }
+ window, valid := query.AddWindow(limit, offset)
+ if !valid || window > uint64(maxQueryWindow) {
+ return ctx, nil, status.Error(codes.InvalidArgument, "query limit and offset exceed the maximum window")
+ }
+ // Admit reserves the complete dynamic assignment after accounting for current
+ // sampled memory and outstanding requests.
+ admittedCtx, release, reserveErr := budget.AdmitContext(ctx, limit, offset, defaultLimit)
+ if reserveErr != nil {
+ if errors.Is(reserveErr, context.Canceled) || errors.Is(reserveErr, context.DeadlineExceeded) {
+ return ctx, nil, status.FromContextError(reserveErr).Err()
+ }
+ if errors.Is(reserveErr, protector.ErrQueryTooLarge) {
+ return ctx, nil, status.Error(codes.InvalidArgument, reserveErr.Error())
+ }
+ return ctx, nil, status.Error(codes.ResourceExhausted, reserveErr.Error())
+ }
+ return admittedCtx, release, nil
+}
+
+// queryStatus preserves typed local resource failures at the public boundary.
+func queryStatus(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return status.FromContextError(err).Err()
+ }
+ if errors.Is(err, protector.ErrQueryTooLarge) {
+ return status.Error(codes.InvalidArgument, err.Error())
+ }
+ if errors.Is(err, protector.ErrQueryResourceExhausted) || errors.Is(err, protector.ErrQueryWindowPressure) {
+ return status.Error(codes.ResourceExhausted, err.Error())
+ }
+ return err
+}
+
+// Unordered Property queries use the limit only as a stopping condition, not a
+// preallocation size. Preserve list-all callers while bounding actual results.
+func admitPropertyQuery(ctx context.Context, budget *protector.QueryBudget, req *propertyv1.QueryRequest) (context.Context, func(), error) {
+ if req.OrderBy != nil && req.OrderBy.TagName != "" {
+ return admitQuery(ctx, budget, req.Limit, 0, 100)
+ }
+ if budget == nil {
+ budget = protector.QueryBudgetFor(nil)
+ }
+ admittedCtx, release, admissionErr := budget.AdmitScanContext(ctx)
+ if admissionErr != nil {
+ return ctx, nil, queryStatus(admissionErr)
+ }
+ return admittedCtx, release, nil
+}
diff --git a/banyand/liaison/grpc/query_admission_review_test.go b/banyand/liaison/grpc/query_admission_review_test.go
new file mode 100644
index 000000000..1c3ac2df8
--- /dev/null
+++ b/banyand/liaison/grpc/query_admission_review_test.go
@@ -0,0 +1,182 @@
+// 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. The 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"
+ "math"
+ "strings"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/stats"
+ "google.golang.org/grpc/status"
+
+ propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+ streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+func TestQueryAdmissionBeforeSchemaAccess(t *testing.T) {
+ // Empty services intentionally have no schema, pipeline, or metric dependencies.
+ // Rejection must happen before accessing any of those dependencies.
+ //
+ // Cases cover nil requests and overflow windows. MaxUint32 list-all / max-limit
+ // sentinels are admitted as scans and are covered separately.
+ testCases := []struct {
+ call func(context.Context) error
+ name string
+ }{
+ {name: "property nil", call: func(ctx context.Context) error {
+ _, queryErr := new(propertyServer).Query(ctx, nil)
+ return queryErr
+ }},
+ {name: "stream nil", call: func(ctx context.Context) error {
+ _, queryErr := new(streamService).Query(ctx, nil)
+ return queryErr
+ }},
+ {name: "trace nil", call: func(ctx context.Context) error {
+ _, queryErr := new(traceService).Query(ctx, nil)
+ return queryErr
+ }},
+ {name: "stream overflow", call: func(ctx context.Context) error {
+ _, queryErr := new(streamService).Query(ctx, &streamv1.QueryRequest{Limit: 1, Offset: math.MaxUint32})
+ return queryErr
+ }},
+ {name: "trace overflow", call: func(ctx context.Context) error {
+ _, queryErr := new(traceService).Query(ctx, &tracev1.QueryRequest{Limit: 1, Offset: math.MaxUint32})
+ return queryErr
+ }},
+ {name: "stream above absolute window", call: func(ctx context.Context) error {
+ _, queryErr := new(streamService).Query(ctx, &streamv1.QueryRequest{Limit: 100001})
+ return queryErr
+ }},
+ {name: "trace above absolute window", call: func(ctx context.Context) error {
+ _, queryErr := new(traceService).Query(ctx, &tracev1.QueryRequest{Limit: 100001})
+ return queryErr
+ }},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ queryErr := testCase.call(context.Background())
+ if status.Code(queryErr) != codes.InvalidArgument {
+ t.Fatalf("expected InvalidArgument, got %v", queryErr)
+ }
+ })
+ }
+}
+
+func TestQueryAdmissionAllowsUnboundedLimitSentinel(t *testing.T) {
+ for _, limit := range []uint32{math.MaxUint32, math.MaxInt32} {
+ budget := protector.NewQueryBudget(nil)
+ ctx, release, err := admitQuery(context.Background(), budget, limit, 0, 20)
+ if err != nil {
+ t.Fatalf("list-all sentinel %d must be admitted as a scan: %v", limit, err)
+ }
+ release()
+ if _, ok := query.BudgetLeaseFromContext(ctx); !ok {
+ t.Fatal("expected admitted scan lease")
+ }
+ if budget.Reserved() != 0 {
+ t.Fatalf("release leaked %d reserved bytes", budget.Reserved())
+ }
+ }
+}
+
+func TestQueryAdmissionContextStatus(t *testing.T) {
+ canceledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+ expiredCtx, expireCancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
+ defer expireCancel()
+ testCases := []struct {
+ ctx context.Context
+ name string
+ code codes.Code
+ }{
+ {ctx: canceledCtx, name: "canceled", code: codes.Canceled},
+ {ctx: expiredCtx, name: "expired", code: codes.DeadlineExceeded},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ budget := protector.NewQueryBudget(nil)
+ service := &propertyServer{queryBudget: budget}
+ _, queryErr := service.Query(testCase.ctx, &propertyv1.QueryRequest{Limit: 1})
+ if status.Code(queryErr) != testCase.code {
+ t.Fatalf("expected %s, got %v", testCase.code, queryErr)
+ }
+ if budget.Reserved() != 0 {
+ t.Fatalf("canceled admission leaked %d reserved bytes", budget.Reserved())
+ }
+ })
+ }
+}
+
+func TestQueryAdmissionTransportScopeClosesAtRPCEnd(t *testing.T) {
+ budget := protector.NewQueryBudget(nil)
+ handler := queryAdmissionStatsHandler{}
+ ctx := handler.TagRPC(context.Background(), &stats.RPCTagInfo{FullMethodName: "/query"})
+ _, release, admissionErr := admitQuery(ctx, budget, 1, 0, 20)
+ if admissionErr != nil {
+ t.Fatal(admissionErr)
+ }
+ release()
+ if budget.Reserved() == 0 {
+ t.Fatal("query reservation released before RPC serialization completed")
+ }
+ handler.HandleRPC(ctx, &stats.End{})
+ if budget.Reserved() != 0 {
+ t.Fatalf("query reservation remained after RPC end: %d", budget.Reserved())
+ }
+}
+
+func TestPropertyMergeChargesPayloadBeforeDeduplication(t *testing.T) {
+ budget := protector.NewQueryBudget(nil)
+ ctx, release, admissionErr := budget.AdmitContext(context.Background(), 1, 0, 100)
+ if admissionErr != nil {
+ t.Fatal(admissionErr)
+ }
+ defer release()
+ lease, _ := query.BudgetLeaseFromContext(ctx)
+ if chargeErr := lease.Charge(lease.Limit() - lease.Used() - 1024); chargeErr != nil {
+ t.Fatal(chargeErr)
+ }
+ properties := map[string][]*propertyWithMetadata{
+ "node": {{Property: &propertyv1.Property{Id: strings.Repeat("x", 2048)}}},
+ }
+ mergeErr := chargePropertyMerge(ctx, properties)
+ if status.Code(queryStatus(mergeErr)) != codes.ResourceExhausted {
+ t.Fatalf("expected rejection before allocating entity and dedup maps, got %v", mergeErr)
+ }
+}
+
+func TestQueryResponseEncodingBudget(t *testing.T) {
+ budget := protector.NewQueryBudget(nil)
+ ctx, release, admissionErr := budget.AdmitContext(context.Background(), 1, 0, 100)
+ if admissionErr != nil {
+ t.Fatal(admissionErr)
+ }
+ defer release()
+ lease, _ := query.BudgetLeaseFromContext(ctx)
+ if chargeErr := lease.Charge(lease.Limit() - lease.Used() - 1024); chargeErr != nil {
+ t.Fatal(chargeErr)
+ }
+ response := &propertyv1.QueryResponse{Properties: []*propertyv1.Property{{Id: strings.Repeat("x", 1024)}}}
+ encodingErr := query.ChargeResponse(ctx, response)
+ if status.Code(queryStatus(encodingErr)) != codes.ResourceExhausted {
+ t.Fatalf("expected encoding budget rejection, got %v", encodingErr)
+ }
+}
diff --git a/banyand/liaison/grpc/query_admission_transport.go b/banyand/liaison/grpc/query_admission_transport.go
new file mode 100644
index 000000000..9306f77f7
--- /dev/null
+++ b/banyand/liaison/grpc/query_admission_transport.go
@@ -0,0 +1,44 @@
+// Licensed to the 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. The 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"
+
+ "google.golang.org/grpc/stats"
+
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+type queryAdmissionStatsHandler struct{}
+
+func (queryAdmissionStatsHandler) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
+ scopedCtx, _ := query.NewTransportScope(ctx)
+ return scopedCtx
+}
+
+func (queryAdmissionStatsHandler) HandleRPC(ctx context.Context, rpcStats stats.RPCStats) {
+ if _, ok := rpcStats.(*stats.End); !ok {
+ return
+ }
+ if scope, ok := query.TransportScopeFromContext(ctx); ok {
+ scope.Close()
+ }
+}
+
+func (queryAdmissionStatsHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
+ return ctx
+}
+
+func (queryAdmissionStatsHandler) HandleConn(context.Context, stats.ConnStats) {}
diff --git a/banyand/liaison/grpc/server.go b/banyand/liaison/grpc/server.go
index 7968ae272..1b0d437a0 100644
--- a/banyand/liaison/grpc/server.go
+++ b/banyand/liaison/grpc/server.go
@@ -167,6 +167,7 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
discoveryService: newDiscoveryService(schema.KindStream, schemaRegistry, nr.StreamLiaisonNodeRegistry, gr),
pipeline: tir1Client,
broadcaster: broadcaster,
+ queryBudget: protector.QueryBudgetFor(protectorService),
}
measureSVC := &measureService{
discoveryService: newDiscoveryServiceWithEntityRepo(schema.KindMeasure, schemaRegistry, nr.MeasureLiaisonNodeRegistry, gr, er),
@@ -177,12 +178,14 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
discoveryService: newDiscoveryService(schema.KindTrace, schemaRegistry, nr.TraceLiaisonNodeRegistry, gr),
pipeline: tir1Client,
broadcaster: broadcaster,
+ queryBudget: protector.QueryBudgetFor(protectorService),
}
propertyService := &propertyServer{
schemaRegistry: schemaRegistry,
pipeline: tir2Client,
nodeRegistry: nr.PropertyNodeRegistry,
discoveryService: newDiscoveryService(schema.KindProperty, schemaRegistry, nr.PropertyNodeRegistry, gr),
+ queryBudget: protector.QueryBudgetFor(protectorService),
}
bydbQLSVC := &bydbQLService{
repo: schemaRegistry,
@@ -586,6 +589,7 @@ func (s *server) Serve() run.StopNotify {
}
opts = append(opts, grpclib.MaxRecvMsgSize(int(s.maxRecvMsgSize)),
+ grpclib.StatsHandler(queryAdmissionStatsHandler{}),
grpclib.ChainUnaryInterceptor(unaryChain...),
grpclib.ChainStreamInterceptor(streamChain...),
)
diff --git a/banyand/liaison/grpc/stream.go b/banyand/liaison/grpc/stream.go
index 71542bd6c..a45f9533a 100644
--- a/banyand/liaison/grpc/stream.go
+++ b/banyand/liaison/grpc/stream.go
@@ -33,6 +33,7 @@ import (
modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
"github.com/apache/skywalking-banyandb/banyand/queue"
"github.com/apache/skywalking-banyandb/pkg/accesslog"
"github.com/apache/skywalking-banyandb/pkg/bus"
@@ -64,6 +65,7 @@ type streamService struct {
*discoveryService
l *logger.Logger
metrics *metrics
+ queryBudget *protector.QueryBudget
writeTimeout time.Duration
maxWaitDuration time.Duration
}
@@ -354,6 +356,27 @@ func (s *streamService) Write(stream streamv1.StreamService_WriteServer) error {
var emptyStreamQueryResponse = &streamv1.QueryResponse{Elements: make([]*streamv1.Element, 0)}
func (s *streamService) Query(ctx context.Context, req *streamv1.QueryRequest) (resp *streamv1.QueryResponse, err error) {
+ if req == nil {
+ return nil, status.Error(codes.InvalidArgument, "query request is nil")
+ }
+ ctx, release, admissionErr := admitQuery(ctx, s.queryBudget, req.GetLimit(), req.GetOffset(), 20)
+ if admissionErr != nil {
+ return nil, admissionErr
+ }
+ defer release()
+ defer func() {
+ if err == nil {
+ err = query.ChargeResponse(ctx, resp)
+ if err != nil {
+ resp = nil
+ }
+ }
+ err = queryStatus(err)
+ }()
+ return s.query(ctx, req)
+}
+
+func (s *streamService) query(ctx context.Context, req *streamv1.QueryRequest) (resp *streamv1.QueryResponse, err error) {
for _, g := range req.Groups {
if acquireErr := s.groupRepo.acquireRequest(g); acquireErr != nil {
return nil, status.Errorf(codes.FailedPrecondition, "group %s is pending deletion", g)
diff --git a/banyand/liaison/grpc/trace.go b/banyand/liaison/grpc/trace.go
index a1aaeb31a..ed0313504 100644
--- a/banyand/liaison/grpc/trace.go
+++ b/banyand/liaison/grpc/trace.go
@@ -33,6 +33,7 @@ import (
modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
"github.com/apache/skywalking-banyandb/banyand/queue"
"github.com/apache/skywalking-banyandb/pkg/accesslog"
"github.com/apache/skywalking-banyandb/pkg/bus"
@@ -76,6 +77,7 @@ type traceService struct {
*discoveryService
l *logger.Logger
metrics *metrics
+ queryBudget *protector.QueryBudget
writeTimeout time.Duration
maxWaitDuration time.Duration
}
@@ -473,6 +475,23 @@ func (s *traceService) applyClamp(req *tracev1.QueryRequest) bool {
}
func (s *traceService) Query(ctx context.Context, req *tracev1.QueryRequest) (resp *tracev1.QueryResponse, err error) {
+ if req == nil {
+ return nil, status.Error(codes.InvalidArgument, "query request is nil")
+ }
+ ctx, release, admissionErr := admitQuery(ctx, s.queryBudget, req.GetLimit(), req.GetOffset(), 20)
+ if admissionErr != nil {
+ return nil, admissionErr
+ }
+ defer release()
+ defer func() {
+ if err == nil {
+ err = query.ChargeResponse(ctx, resp)
+ if err != nil {
+ resp = nil
+ }
+ }
+ err = queryStatus(err)
+ }()
for _, g := range req.Groups {
if acquireErr := s.groupRepo.acquireRequest(g); acquireErr != nil {
return nil, status.Errorf(codes.FailedPrecondition, "group %s is pending deletion", g)
diff --git a/banyand/property/db/db.go b/banyand/property/db/db.go
index 8c535b4d3..78c678b02 100644
--- a/banyand/property/db/db.go
+++ b/banyand/property/db/db.go
@@ -47,6 +47,7 @@ import (
"github.com/apache/skywalking-banyandb/pkg/iter/sort"
"github.com/apache/skywalking-banyandb/pkg/logger"
"github.com/apache/skywalking-banyandb/pkg/meter"
+ "github.com/apache/skywalking-banyandb/pkg/query"
)
const (
@@ -293,8 +294,11 @@ func (db *database) Query(ctx context.Context, req *propertyv1.QueryRequest) ([]
defer mergeIter.Close()
// Collect merged results up to limit
- result := make([]QueriedProperty, 0, req.Limit)
+ result := make([]QueriedProperty, 0, queryCapacityUint(req.Limit))
for mergeIter.Next() {
+ if chargeErr := query.Charge(ctx, 128); chargeErr != nil {
+ return nil, chargeErr
+ }
result = append(result, mergeIter.Val())
}
diff --git a/banyand/property/db/query_capacity.go b/banyand/property/db/query_capacity.go
new file mode 100644
index 000000000..cc076cb6f
--- /dev/null
+++ b/banyand/property/db/query_capacity.go
@@ -0,0 +1,30 @@
+// Licensed to the 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. The 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 db
+
+const queryCapacityHint = 1024
+
+func queryCapacity(limit int) int {
+ if limit > queryCapacityHint {
+ return queryCapacityHint
+ }
+ return limit
+}
+
+func queryCapacityUint(limit uint32) int {
+ if limit > queryCapacityHint {
+ return queryCapacityHint
+ }
+ return int(limit)
+}
diff --git a/banyand/property/db/query_capacity_test.go b/banyand/property/db/query_capacity_test.go
new file mode 100644
index 000000000..eaa3f05ee
--- /dev/null
+++ b/banyand/property/db/query_capacity_test.go
@@ -0,0 +1,122 @@
+// Licensed to the 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. The 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 db
+
+import (
+ "context"
+ "math"
+ "testing"
+ "time"
+
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+ "github.com/apache/skywalking-banyandb/banyand/observability"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+ "github.com/apache/skywalking-banyandb/pkg/index/inverted"
+ "github.com/apache/skywalking-banyandb/pkg/test"
+)
+
+// Property queries with an oversized Limit previously sized shard/db result slices from
+// that value. The capacity hint must stay bounded even if such a limit reaches allocation.
+func TestQueryCapacityCapsOversizedLimit(t *testing.T) {
+ if got := queryCapacity(math.MaxInt32); got != queryCapacityHint {
+ t.Fatalf("queryCapacity(MaxInt32)=%d, want %d", got, queryCapacityHint)
+ }
+ if got := queryCapacityUint(math.MaxUint32); got != queryCapacityHint {
+ t.Fatalf("queryCapacityUint(MaxUint32)=%d, want %d", got, queryCapacityHint)
+ }
+ if got := queryCapacity(16); got != 16 {
+ t.Fatalf("queryCapacity(16)=%d, want 16", got)
+ }
+ if got := queryCapacityUint(16); got != 16 {
+ t.Fatalf("queryCapacityUint(16)=%d, want 16", got)
+ }
+}
+
+func TestQueryCapacityAllocationStaysBounded(t *testing.T) {
+ // Use a modest oversized limit so a guard regression fails the assertion without OOM.
+ const oversized = 4096
+ data := make([]*queryProperty, 0, queryCapacity(oversized))
+ if cap(data) != queryCapacityHint {
+ t.Fatalf("shard slice capacity %d, want %d", cap(data), queryCapacityHint)
+ }
+ result := make([]QueriedProperty, 0, queryCapacityUint(oversized))
+ if cap(result) != queryCapacityHint {
+ t.Fatalf("db slice capacity %d, want %d", cap(result), queryCapacityHint)
+ }
+}
+
+func TestOrderedPropertyQueryCapacityBounded(t *testing.T) {
+ const oversizedLimit uint32 = 4096
+ dataDir, dataCleanup, err := test.NewSpace()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(dataCleanup)
+
+ db, openErr := OpenDB(context.Background(), Config{
+ Location: dataDir,
+ MetricsScopeName: "property_query_capacity_test",
+ FlushInterval: time.Hour,
+ ExpireToDeleteDuration: time.Hour,
+ }, observability.BypassRegistry, fs.NewLocalFileSystem())
+ if openErr != nil {
+ t.Fatal(openErr)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ ctx := context.Background()
+ prop := generateProperty("cap-id", time.Now().UnixNano(), 1)
+ if updateErr := db.Update(ctx, 0, GetPropertyID(prop), prop); updateErr != nil {
+ t.Fatal(updateErr)
+ }
+
+ req := &propertyv1.QueryRequest{
+ Groups: []string{testPropertyGroup},
+ Limit: oversizedLimit,
+ OrderBy: &propertyv1.QueryOrder{
+ TagName: "tag1",
+ Sort: modelv1.Sort_SORT_ASC,
+ },
+ }
+ result, queryErr := db.Query(ctx, req)
+ if queryErr != nil {
+ t.Fatal(queryErr)
+ }
+ if len(result) == 0 {
+ t.Fatal("expected at least one ordered property result")
+ }
+ if cap(result) != queryCapacityHint {
+ t.Fatalf("database.Query ordered merge capacity %d, want %d", cap(result), queryCapacityHint)
+ }
+
+ sd, loadErr := db.(*database).loadShard(ctx, testPropertyGroup, 0)
+ if loadErr != nil {
+ t.Fatal(loadErr)
+ }
+ iq, buildErr := inverted.BuildPropertyQuery(req, groupField, entityID)
+ if buildErr != nil {
+ t.Fatal(buildErr)
+ }
+ shardResults, searchErr := sd.search(ctx, iq, req.OrderBy, int(req.Limit))
+ if searchErr != nil {
+ t.Fatal(searchErr)
+ }
+ if len(shardResults) == 0 {
+ t.Fatal("expected ordered shard.search to return the inserted property")
+ }
+ if cap(shardResults) != queryCapacityHint {
+ t.Fatalf("shard.search ordered capacity %d, want %d", cap(shardResults), queryCapacityHint)
+ }
+}
diff --git a/banyand/property/db/shard.go b/banyand/property/db/shard.go
index 8962c2b0f..aad68bb7f 100644
--- a/banyand/property/db/shard.go
+++ b/banyand/property/db/shard.go
@@ -299,9 +299,12 @@ func (s *shard) search(ctx context.Context, q index.Query, orderBy *propertyv1.Q
if len(ss) == 0 {
return nil, nil
}
- data = make([]*queryProperty, 0, len(ss))
+ data = make([]*queryProperty, 0)
for _, s := range ss {
bytes := s.Fields[sourceField]
+ if chargeErr := query.Charge(ctx, uint64(len(bytes))+128); chargeErr != nil {
+ return nil, chargeErr
+ }
var deleteTime int64
if s.Fields[deleteField] != nil {
deleteTime = convert.BytesToInt64(s.Fields[deleteField])
@@ -331,9 +334,12 @@ func (s *shard) search(ctx context.Context, q index.Query, orderBy *propertyv1.Q
defer func() {
err = multierr.Append(err, iter.Close())
}()
- data = make([]*queryProperty, 0, limit)
+ data = make([]*queryProperty, 0, queryCapacity(limit))
for iter.Next() {
val := iter.Val()
+ if chargeErr := query.Charge(ctx, uint64(len(val.Values[sourceField]))+uint64(len(val.SortedValue))+128); chargeErr != nil {
+ return nil, chargeErr
+ }
var deleteTime int64
if val.Values[deleteField] != nil {
diff --git a/banyand/property/listener.go b/banyand/property/listener.go
index 7a05dbe8d..7814df63a 100644
--- a/banyand/property/listener.go
+++ b/banyand/property/listener.go
@@ -33,6 +33,7 @@ import (
propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
"github.com/apache/skywalking-banyandb/banyand/internal/storage"
obsservice "github.com/apache/skywalking-banyandb/banyand/observability/services"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
"github.com/apache/skywalking-banyandb/pkg/bus"
"github.com/apache/skywalking-banyandb/pkg/logger"
"github.com/apache/skywalking-banyandb/pkg/query"
@@ -169,6 +170,26 @@ func (h *queryListener) Rev(ctx context.Context, message bus.Message) (resp bus.
resp = bus.NewMessage(bus.MessageID(now), common.NewError("limit is 0"))
return
}
+ budget := protector.QueryBudgetFor(h.s.pm)
+ var admittedCtx context.Context
+ var release func()
+ var admissionErr error
+ if d.OrderBy == nil || d.OrderBy.TagName == "" {
+ admittedCtx, release, admissionErr = budget.AdmitScanContext(ctx)
+ } else {
+ admittedCtx, release, admissionErr = budget.AdmitRequestContext(ctx, d.Limit, 0, 100)
+ }
+ if admissionErr != nil {
+ resp = bus.NewMessage(bus.MessageID(now), common.NewError("query admission failed: %v", admissionErr))
+ return
+ }
+ defer release()
+ ctx = admittedCtx
+ defer func() {
+ if chargeErr := query.ChargeResponse(ctx, resp.Data()); chargeErr != nil {
+ resp = bus.NewMessage(resp.ID(), common.NewError("query response memory exhausted: %v", chargeErr))
+ }
+ }()
var tracer *query.Tracer
var span *query.Span
if d.Trace {
@@ -183,16 +204,16 @@ func (h *queryListener) Rev(ctx context.Context, message bus.Message) (resp bus.
if err != nil {
if tracer != nil {
span.Error(err)
- resp = bus.NewMessage(bus.MessageID(now), &propertyv1.InternalQueryResponse{
- Trace: tracer.ToProto(),
- })
- return
}
resp = bus.NewMessage(bus.MessageID(now), common.NewError("fail to query property: %v", err))
return
}
qResp := &propertyv1.InternalQueryResponse{}
for _, p := range properties {
+ if chargeErr := query.Charge(ctx, 128); chargeErr != nil {
+ resp = bus.NewMessage(bus.MessageID(now), common.NewError("fail to materialize property response: %v", chargeErr))
+ return
+ }
qResp.Sources = append(qResp.Sources, p.Source())
qResp.Deletes = append(qResp.Deletes, p.DeleteTime())
qResp.SortedValues = append(qResp.SortedValues, p.SortedValue())
diff --git a/banyand/protector/protector.go b/banyand/protector/protector.go
index 6ad998406..98b9d509e 100644
--- a/banyand/protector/protector.go
+++ b/banyand/protector/protector.go
@@ -25,6 +25,7 @@ import (
"errors"
"fmt"
"runtime/metrics"
+ "sync"
"sync/atomic"
"time"
@@ -87,10 +88,18 @@ type memory struct {
l *logger.Logger
closed chan struct{}
blockedChan chan struct{}
+ queryBudget *QueryBudget
allowedPercent int
allowedBytes run.Bytes
limit atomic.Uint64
usage uint64
+ queryBudgetOnce sync.Once
+}
+
+// QueryBudget returns the process-local query reservation pool for this protector.
+func (m *memory) QueryBudget() *QueryBudget {
+ m.queryBudgetOnce.Do(func() { m.queryBudget = NewQueryBudget(m) })
+ return m.queryBudget
}
// NewMemory creates a new Memory protector.
@@ -282,6 +291,30 @@ func (m *memory) GracefulStop() {
close(m.closed)
}
+// RefreshUsage samples runtime memory immediately for query admission.
+func (m *memory) RefreshUsage() { m.refreshUsage() }
+
+func (m *memory) refreshUsage() {
+ samples := []metrics.Sample{
+ {Name: "/memory/classes/heap/objects:bytes"},
+ {Name: "/memory/classes/heap/stacks:bytes"},
+ {Name: "/memory/classes/metadata/mcache/inuse:bytes"},
+ {Name: "/memory/classes/metadata/mspan/inuse:bytes"},
+ {Name: "/memory/classes/metadata/other:bytes"},
+ {Name: "/memory/classes/os-stacks:bytes"},
+ {Name: "/memory/classes/other:bytes"},
+ }
+ metrics.Read(samples)
+ var usedBytes uint64
+ for _, sample := range samples {
+ usedBytes += sample.Value.Uint64()
+ }
+ atomic.StoreUint64(&m.usage, usedBytes)
+ if m.usageGauge != nil {
+ m.usageGauge.Set(float64(usedBytes))
+ }
+}
+
// Serve starts the protector.
func (m *memory) Serve() run.StopNotify {
if m.limit.Load() == 0 {
@@ -296,23 +329,8 @@ func (m *memory) Serve() run.StopNotify {
case <-m.closed:
return
case <-ticker.C:
- samples := []metrics.Sample{
- {Name: "/memory/classes/heap/objects:bytes"},
- {Name: "/memory/classes/heap/stacks:bytes"},
- {Name: "/memory/classes/metadata/mcache/inuse:bytes"},
- {Name: "/memory/classes/metadata/mspan/inuse:bytes"},
- {Name: "/memory/classes/metadata/other:bytes"},
- {Name: "/memory/classes/os-stacks:bytes"},
- {Name: "/memory/classes/other:bytes"},
- }
- metrics.Read(samples)
- var usedBytes uint64
- for _, sample := range samples {
- usedBytes += sample.Value.Uint64()
- }
-
- atomic.StoreUint64(&m.usage, usedBytes)
- m.usageGauge.Set(float64(usedBytes))
+ m.refreshUsage()
+ usedBytes := atomic.LoadUint64(&m.usage)
if usedBytes > m.limit.Load() {
m.overLimitCounter.Inc(1)
diff --git a/banyand/protector/query_admission_report_test.go b/banyand/protector/query_admission_report_test.go
new file mode 100644
index 000000000..c22801300
--- /dev/null
+++ b/banyand/protector/query_admission_report_test.go
@@ -0,0 +1,64 @@
+// Licensed to the 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. The 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 protector
+
+import (
+ "context"
+ "errors"
+ "math"
+ "testing"
+)
+
+// Oversized Property/Trace/Stream limit and limit+offset windows must be rejected
+// at admission before any make(..., limit).
+//
+// Use a 32 GiB mocked budget so the dynamic window ceiling admits 100000 and
+// only queryAbsoluteWindow rejects 100001 with ErrQueryTooLarge.
+func TestAdmitContextRejectsOversizedWindows(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 32 << 30, avail: 32 << 30})
+ cases := []struct {
+ name string
+ limit, offset uint32
+ }{
+ {name: "property_or_trace_max_limit", limit: math.MaxUint32},
+ {name: "trace_or_stream_limit_offset_overflow", limit: 1, offset: math.MaxUint32},
+ {name: "absolute_window_ceiling", limit: 100001},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, release, err := budget.AdmitContext(context.Background(), tc.limit, tc.offset, 20)
+ if release != nil {
+ release()
+ }
+ if !errors.Is(err, ErrQueryTooLarge) {
+ t.Fatalf("expected ErrQueryTooLarge, got %v", err)
+ }
+ if budget.Reserved() != 0 {
+ t.Fatalf("rejected admission leaked %d reserved bytes", budget.Reserved())
+ }
+ })
+ }
+}
+
+func TestAdmitContextAcceptsAbsoluteWindowBoundary(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 32 << 30, avail: 32 << 30})
+ _, release, err := budget.AdmitContext(context.Background(), 100000, 0, 20)
+ if err != nil {
+ t.Fatalf("limit=100000 must be admitted under ample budget: %v", err)
+ }
+ release()
+ if budget.Reserved() != 0 {
+ t.Fatalf("release leaked %d reserved bytes", budget.Reserved())
+ }
+}
diff --git a/banyand/protector/query_budget.go b/banyand/protector/query_budget.go
new file mode 100644
index 000000000..ec0ca6d3c
--- /dev/null
+++ b/banyand/protector/query_budget.go
@@ -0,0 +1,386 @@
+// Licensed to the 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. The 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 protector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "reflect"
+ "sync"
+ "sync/atomic"
+
+ querypkg "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+const (
+ queryPoolFraction = 4
+ queryMaxFraction = 8
+ queryHeadroomPercent = 10
+ queryFallbackPool uint64 = 64 << 20
+)
+
+var (
+ // ErrQueryTooLarge indicates that a request exceeds the configured hard limit.
+ ErrQueryTooLarge = errors.New("query memory request exceeds per-query limit")
+ // ErrQueryResourceExhausted indicates exhaustion of a query allowance or the shared pool.
+ ErrQueryResourceExhausted = errors.New("query memory pool exhausted")
+ // ErrQueryWindowPressure indicates a result window exceeding the current assignment.
+ ErrQueryWindowPressure = errors.New("query result window exceeds current memory budget")
+)
+
+// Admission uses conservative initial estimates, not measured per-record maxima.
+// Execution charges and the absolute window are additional bounds; these values
+// should be calibrated with representative Property, Stream and Trace workloads.
+const (
+ queryFixedOverhead uint64 = 1 << 20
+ queryEstimatedEntryBytes uint64 = 4 << 10
+ queryAbsoluteWindow uint64 = 100000
+)
+
+// QueryBudget provides an atomic, finite reservation pool for one service.
+// Reservations are estimates and must remain held until response materialization
+// is complete. It intentionally does not modify Memory.AcquireResource semantics.
+type QueryBudget struct {
+ protector Memory
+ reserved atomic.Uint64
+}
+
+// QueryReservation tracks charged allocations and shared ownership of an admitted query.
+type QueryReservation struct {
+ owner *QueryBudget
+ assigned uint64
+ results uint64
+ used atomic.Uint64
+ released atomic.Bool
+ mu sync.Mutex
+ refs int32
+}
+
+// Charge accounts bytes before allocation, rejecting charges beyond the assignment.
+func (r *QueryReservation) Charge(bytes uint64) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.chargeLocked(bytes)
+}
+
+// ChargeResult accounts one retained result and its bytes before allocation.
+// Exhausting either allowance rejects the result without changing either counter.
+func (r *QueryReservation) ChargeResult(bytes uint64) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.results >= queryAbsoluteWindow {
+ return ErrQueryResourceExhausted
+ }
+ if err := r.chargeLocked(bytes); err != nil {
+ return err
+ }
+ r.results++
+ return nil
+}
+
+func (r *QueryReservation) chargeLocked(bytes uint64) error {
+ if r.released.Load() {
+ return ErrQueryResourceExhausted
+ }
+ used := r.used.Load()
+ if bytes > r.assigned-used {
+ return ErrQueryResourceExhausted
+ }
+ r.used.Store(used + bytes)
+ return nil
+}
+
+// Limit returns the reservation's assigned byte budget.
+func (r *QueryReservation) Limit() uint64 { return r.assigned }
+
+// Used returns the cumulative bytes charged to the reservation.
+func (r *QueryReservation) Used() uint64 { return r.used.Load() }
+
+// Owner identifies the shared query pool owning this reservation.
+func (r *QueryReservation) Owner() any { return r.owner }
+
+func (r *QueryReservation) release() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.refs <= 0 {
+ return
+ }
+ r.refs--
+ if r.refs == 0 && r.released.CompareAndSwap(false, true) {
+ r.owner.reserved.Add(^(r.assigned - 1))
+ }
+}
+
+func (r *QueryReservation) retain() bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.released.Load() || r.refs <= 0 {
+ return false
+ }
+ r.refs++
+ return true
+}
+
+var fallbackBudget struct {
+ budget *QueryBudget
+ sync.Once
+}
+var customBudgets sync.Map
+
+type (
+ queryBudgetProvider interface{ QueryBudget() *QueryBudget }
+ usageRefresher interface{ RefreshUsage() }
+)
+
+// QueryBudgetFor returns the process-wide budget associated with a protector.
+// It avoids independent pools for the liaison's public services.
+func QueryBudgetFor(pm Memory) *QueryBudget {
+ if pm == nil {
+ pm = &Nop{}
+ }
+ if provider, ok := pm.(queryBudgetProvider); ok {
+ return provider.QueryBudget()
+ }
+ _, pointerNop := pm.(*Nop)
+ _, valueNop := pm.(Nop)
+ if !pointerNop && !valueNop && reflect.TypeOf(pm).Comparable() {
+ budget := NewQueryBudget(pm)
+ actual, _ := customBudgets.LoadOrStore(pm, budget)
+ return actual.(*QueryBudget)
+ }
+ fallbackBudget.Do(func() { fallbackBudget.budget = NewQueryBudget(nil) })
+ return fallbackBudget.budget
+}
+
+// NewQueryBudget derives a bounded query pool from the protector. A disabled or
+// unavailable protector uses a finite fallback pool rather than becoming unlimited.
+func NewQueryBudget(pm Memory) *QueryBudget {
+ return &QueryBudget{protector: pm}
+}
+
+// Limit returns the finite pool size available to all admitted queries.
+func (b *QueryBudget) Limit() uint64 { return b.poolLimit() }
+
+// Reserved returns the current total of outstanding query reservations.
+func (b *QueryBudget) Reserved() uint64 { return b.reserved.Load() }
+
+// MaxQuery returns the hard per-query reservation limit.
+func (b *QueryBudget) MaxQuery() uint64 { return b.poolLimit() / queryMaxFraction }
+
+// Reserve atomically reserves bytes, returning a release function. It rejects
+// oversized requests and pressure, and honors cancellation before admission.
+func (b *QueryBudget) Reserve(ctx context.Context, bytes uint64) (func(), error) {
+ if ctx == nil {
+ return nil, errors.New("query reservation requires a context")
+ }
+ select {
+ case <-ctx.Done():
+ return nil, fmt.Errorf("query reservation canceled: %w", ctx.Err())
+ default:
+ }
+ maxQuery := b.MaxQuery()
+ if maxQuery == 0 {
+ maxQuery = 1
+ }
+ if bytes > maxQuery {
+ return nil, fmt.Errorf("requested %d bytes, maximum %d: %w", bytes, maxQuery, ErrQueryTooLarge)
+ }
+ for {
+ pool := b.poolLimit()
+ current := b.reserved.Load()
+ if current > pool || bytes > pool-current {
+ return nil, ErrQueryResourceExhausted
+ }
+ available := b.available()
+ if available < current || bytes > available-current {
+ return nil, ErrQueryResourceExhausted
+ }
+ if b.reserved.CompareAndSwap(current, current+bytes) {
+ var released atomic.Bool
+ return func() {
+ if bytes > 0 && released.CompareAndSwap(false, true) {
+ b.reserved.Add(^(bytes - 1))
+ }
+ }, nil
+ }
+ }
+}
+
+// Admit reserves the complete dynamic assignment for a query after deriving a
+// result-window ceiling from current sampled memory and outstanding reservations.
+func (b *QueryBudget) Admit(ctx context.Context, limit, offset, defaultLimit uint32) (func(), error) {
+ _, release, err := b.AdmitContext(ctx, limit, offset, defaultLimit)
+ return release, err
+}
+
+// AdmitScanContext admits an incremental scan without reserving from its requested limit.
+// Callers must charge every retained result with query.ChargeResult before allocation.
+func (b *QueryBudget) AdmitScanContext(ctx context.Context) (context.Context, func(), error) {
+ return b.AdmitContext(ctx, 1, 0, 1)
+}
+
+// AdmitRequestContext admits a query window, treating MaxUint32 with zero offset as an unbounded scan.
+func (b *QueryBudget) AdmitRequestContext(ctx context.Context, limit, offset, defaultLimit uint32) (context.Context, func(), error) {
+ if limit == 0 {
+ limit = defaultLimit
+ }
+ if querypkg.IsUnboundedLimit(limit, offset) {
+ return b.AdmitScanContext(ctx)
+ }
+ return b.AdmitContext(ctx, limit, offset, defaultLimit)
+}
+
+// AdmitContext admits a query and attaches its chargeable reservation to ctx.
+func (b *QueryBudget) AdmitContext(ctx context.Context, limit, offset, defaultLimit uint32) (context.Context, func(), error) {
+ if ctx == nil {
+ return nil, nil, errors.New("query admission requires a context")
+ }
+ if limit == 0 {
+ limit = defaultLimit
+ }
+ window := uint64(limit) + uint64(offset)
+ if window > uint64(^uint32(0)) || window > queryAbsoluteWindow {
+ return nil, nil, ErrQueryTooLarge
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, nil, fmt.Errorf("query admission canceled: %w", err)
+ }
+ if existing, ok := querypkg.BudgetLeaseFromContext(ctx); ok && existing.Owner() == b {
+ reservation, reservationOk := existing.(*QueryReservation)
+ if !reservationOk || reservation.released.Load() {
+ return nil, nil, ErrQueryResourceExhausted
+ }
+ maxWindow := uint64(0)
+ if reservation.assigned > queryFixedOverhead {
+ maxWindow = (reservation.assigned - queryFixedOverhead) / queryEstimatedEntryBytes
+ }
+ if window > maxWindow {
+ return nil, nil, ErrQueryWindowPressure
+ }
+ if !reservation.retain() {
+ return nil, nil, ErrQueryResourceExhausted
+ }
+ return querypkg.WithBudgetLease(ctx, reservation), reservationReleaseForTransport(ctx, reservation), nil
+ }
+ pool := b.poolLimit()
+ maxQuery := pool / queryMaxFraction
+ if maxQuery == 0 {
+ maxQuery = 1
+ }
+ for {
+ select {
+ case <-ctx.Done():
+ return nil, nil, fmt.Errorf("query admission canceled: %w", ctx.Err())
+ default:
+ }
+ current := b.reserved.Load()
+ available := b.available()
+ if current >= pool || current >= available {
+ return nil, nil, ErrQueryResourceExhausted
+ }
+ assignment := minUint64(maxQuery, pool-current)
+ assignment = minUint64(assignment, available-current)
+ if assignment <= queryFixedOverhead {
+ return nil, nil, ErrQueryResourceExhausted
+ }
+ maxWindow := (assignment - queryFixedOverhead) / queryEstimatedEntryBytes
+ if window > maxWindow {
+ return nil, nil, ErrQueryWindowPressure
+ }
+ if b.reserved.CompareAndSwap(current, current+assignment) {
+ reservation := &QueryReservation{owner: b, assigned: assignment, refs: 1}
+ if chargeErr := reservation.Charge(queryFixedOverhead); chargeErr != nil {
+ b.reserved.Add(^(assignment - 1))
+ return nil, nil, chargeErr
+ }
+ return querypkg.WithBudgetLease(ctx, reservation), reservationReleaseForTransport(ctx, reservation), nil
+ }
+ }
+}
+
+func reservationRelease(reservation *QueryReservation) func() {
+ var once sync.Once
+ return func() { once.Do(reservation.release) }
+}
+
+func reservationReleaseForTransport(ctx context.Context, reservation *QueryReservation) func() {
+ localRelease := reservationRelease(reservation)
+ if _, scoped := querypkg.TransportScopeFromContext(ctx); !scoped {
+ return localRelease
+ }
+ if reservation.retain() {
+ querypkg.RegisterTransportRelease(ctx, reservationRelease(reservation))
+ }
+ return localRelease
+}
+
+func (b *QueryBudget) available() uint64 {
+ limit := uint64(0)
+ if b.protector != nil {
+ if refresher, ok := b.protector.(usageRefresher); ok {
+ refresher.RefreshUsage()
+ }
+ limit = b.protector.GetLimit()
+ }
+ pool := b.poolLimit()
+ if limit == 0 || b.protector == nil {
+ return pool
+ }
+ available := b.protector.AvailableBytes()
+ if available < 0 {
+ return 0
+ }
+ headroom := limit / queryHeadroomPercent
+ if uint64(available) <= headroom {
+ return 0
+ }
+ return minUint64(pool, uint64(available)-headroom)
+}
+
+func (b *QueryBudget) poolLimit() uint64 {
+ limit := uint64(0)
+ if b.protector != nil {
+ limit = b.protector.GetLimit()
+ }
+ if limit == 0 {
+ return queryFallbackPool
+ }
+ pool := limit / queryPoolFraction
+ if pool == 0 {
+ return 1
+ }
+ return pool
+}
+
+func minUint64(left, right uint64) uint64 {
+ if left < right {
+ return left
+ }
+ return right
+}
+
+// MaxWindow computes the policy ceiling without accounting for current contention.
+// AdmitContext must be used for admission against available memory.
+func (b *QueryBudget) MaxWindow(fixedOverhead, estimatedBytesPerEntry, absoluteCeiling uint64) uint64 {
+ maxQuery := b.MaxQuery()
+ if estimatedBytesPerEntry == 0 || maxQuery <= fixedOverhead {
+ return 0
+ }
+ window := (maxQuery - fixedOverhead) / estimatedBytesPerEntry
+ if window > absoluteCeiling {
+ return absoluteCeiling
+ }
+ return window
+}
diff --git a/banyand/protector/query_budget_review_test.go b/banyand/protector/query_budget_review_test.go
new file mode 100644
index 000000000..092e79529
--- /dev/null
+++ b/banyand/protector/query_budget_review_test.go
@@ -0,0 +1,159 @@
+// 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. The 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 protector
+
+import (
+ "context"
+ "errors"
+ "math"
+ "sync"
+ "sync/atomic"
+ "testing"
+)
+
+func TestQueryBudgetSharedOwners(t *testing.T) {
+ fallback := QueryBudgetFor(nil)
+ if QueryBudgetFor(Nop{}) != fallback || QueryBudgetFor(&Nop{}) != fallback {
+ t.Fatal("nil and no-op protectors must share a finite pool")
+ }
+ pm := &budgetProtector{limit: 64 << 20, avail: 0}
+ budget := QueryBudgetFor(pm)
+ if budget != QueryBudgetFor(pm) {
+ t.Fatal("one protector must not acquire independent query pools")
+ }
+ release, admissionErr := budget.Admit(context.Background(), 1, 0, 20)
+ if admissionErr == nil {
+ release()
+ t.Fatal("custom protector reporting zero available memory was ignored")
+ }
+}
+
+func TestQueryBudgetFreshUsageAndLateInitialization(t *testing.T) {
+ pm := &memory{}
+ budget := QueryBudgetFor(pm)
+ initialMaximum := budget.MaxQuery()
+ // Allow ample space for test-process runtime memory without allocating it.
+ pm.limit.Store(1 << 40)
+ atomic.StoreUint64(&pm.usage, 1<<40)
+ if budget.MaxQuery() <= initialMaximum {
+ t.Fatal("query budget retained the pre-initialization fallback ceiling")
+ }
+ release, admissionErr := budget.Admit(context.Background(), 1, 0, 20)
+ if admissionErr != nil {
+ t.Fatalf("admission failed to refresh stale full-memory usage: %v", admissionErr)
+ }
+ defer release()
+ if atomic.LoadUint64(&pm.usage) == 1<<40 {
+ t.Fatal("admission did not refresh runtime usage")
+ }
+}
+
+func TestQueryBudgetAdmitConcurrentHeldReservations(t *testing.T) {
+ budget := NewQueryBudget(nil)
+ const callers = 32
+ start := make(chan struct{})
+ releases := make(chan func(), callers)
+ var workers sync.WaitGroup
+ for caller := 0; caller < callers; caller++ {
+ workers.Add(1)
+ go func() {
+ defer workers.Done()
+ <-start
+ release, admissionErr := budget.Admit(context.Background(), 1, 0, 20)
+ if admissionErr == nil {
+ releases <- release
+ }
+ }()
+ }
+ close(start)
+ workers.Wait()
+ close(releases)
+ if len(releases) != 8 {
+ t.Errorf("expected eight held full-budget reservations, got %d", len(releases))
+ }
+ if budget.Reserved() > budget.Limit() {
+ t.Errorf("pool oversubscribed: %d > %d", budget.Reserved(), budget.Limit())
+ }
+ for release := range releases {
+ release()
+ release() // Every owner cleanup is idempotent.
+ }
+ if budget.Reserved() != 0 {
+ t.Fatalf("reservation leak: %d", budget.Reserved())
+ }
+}
+
+func TestQueryBudgetWindowShrinksWithAvailableMemory(t *testing.T) {
+ const limit = 256 << 20
+ pm := &budgetProtector{limit: limit, avail: limit}
+ budget := NewQueryBudget(pm)
+ release, initialErr := budget.Admit(context.Background(), 1000, 0, 20)
+ if initialErr != nil {
+ t.Fatal(initialErr)
+ }
+ release()
+ // Leave only 2 MiB above headroom: 1 MiB overhead plus 256 estimated entries.
+ pm.avail = limit/10 + (2 << 20)
+ unexpectedRelease, pressureErr := budget.Admit(context.Background(), 1000, 0, 20)
+ if pressureErr == nil {
+ unexpectedRelease()
+ t.Fatal("large query was admitted despite a smaller dynamic window")
+ }
+ if !errors.Is(pressureErr, ErrQueryWindowPressure) {
+ t.Fatalf("expected window-pressure rejection, got %v", pressureErr)
+ }
+ smallRelease, smallErr := budget.Admit(context.Background(), 100, 0, 20)
+ if smallErr != nil {
+ t.Fatalf("small query should still fit: %v", smallErr)
+ }
+ smallRelease()
+}
+
+func TestQueryReservationDoubleReleaseAndChargeOverflow(t *testing.T) {
+ budget := NewQueryBudget(nil)
+ ctx, outerRelease, admissionErr := budget.AdmitContext(context.Background(), 1, 0, 20)
+ if admissionErr != nil {
+ t.Fatal(admissionErr)
+ }
+ _, childRelease, childErr := budget.AdmitContext(ctx, 1, 0, 20)
+ if childErr != nil {
+ outerRelease()
+ t.Fatal(childErr)
+ }
+ outerRelease()
+ outerRelease()
+ if budget.Reserved() == 0 {
+ t.Fatal("repeated parent release freed the child's reservation")
+ }
+ childRelease()
+ childRelease()
+ if budget.Reserved() != 0 {
+ t.Fatal("child release leaked the reservation")
+ }
+
+ lease := &QueryReservation{owner: budget, assigned: 1024, refs: 1}
+ if chargeErr := lease.Charge(100); chargeErr != nil {
+ t.Fatal(chargeErr)
+ }
+ if chargeErr := lease.Charge(math.MaxUint64); !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Fatalf("overflow charge accepted: %v", chargeErr)
+ }
+ if lease.Used() != 100 {
+ t.Fatal("failed charge changed usage")
+ }
+ lease.released.Store(true)
+ if chargeErr := lease.Charge(1); !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Fatalf("charge against released lease accepted: %v", chargeErr)
+ }
+}
diff --git a/banyand/protector/query_budget_test.go b/banyand/protector/query_budget_test.go
new file mode 100644
index 000000000..f4ce5479f
--- /dev/null
+++ b/banyand/protector/query_budget_test.go
@@ -0,0 +1,107 @@
+// Licensed to the 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. The 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 protector
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+
+ "github.com/apache/skywalking-banyandb/pkg/run"
+)
+
+type budgetProtector struct {
+ limit uint64
+ avail int64
+}
+
+func (p *budgetProtector) AvailableBytes() int64 { return p.avail }
+func (p *budgetProtector) GetLimit() uint64 { return p.limit }
+func (*budgetProtector) AcquireResource(context.Context, uint64) error { return nil }
+func (*budgetProtector) ShouldCache(int64) bool { return false }
+func (*budgetProtector) State() State { return StateLow }
+func (*budgetProtector) Name() string { return "test" }
+func (*budgetProtector) FlagSet() *run.FlagSet { return run.NewFlagSet("test") }
+func (*budgetProtector) Validate() error { return nil }
+func (*budgetProtector) PreRun(context.Context) error { return nil }
+func (*budgetProtector) Serve() run.StopNotify { return make(chan struct{}) }
+func (*budgetProtector) GracefulStop() {}
+
+func TestQueryBudgetReserveRelease(t *testing.T) {
+ b := NewQueryBudget(&budgetProtector{limit: 1024, avail: 1024})
+ if b.Limit() != 256 || b.MaxQuery() != 32 {
+ t.Fatalf("unexpected policy: pool=%d max=%d", b.Limit(), b.MaxQuery())
+ }
+ releases := make([]func(), 0, 8)
+ release, err := b.Reserve(context.Background(), 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ releases = append(releases, release)
+ for i := 0; i < 7; i++ {
+ nextRelease, reserveErr := b.Reserve(context.Background(), 32)
+ if reserveErr != nil {
+ t.Fatal(reserveErr)
+ }
+ releases = append(releases, nextRelease)
+ }
+ if _, err = b.Reserve(context.Background(), 1); !errors.Is(err, ErrQueryResourceExhausted) {
+ t.Fatalf("want exhaustion, got %v", err)
+ }
+ for _, reservedRelease := range releases {
+ reservedRelease()
+ }
+ if b.Reserved() != 0 {
+ t.Fatalf("reservation leaked: %d", b.Reserved())
+ }
+}
+
+func TestQueryBudgetConcurrentReservations(t *testing.T) {
+ b := NewQueryBudget(&budgetProtector{limit: 1 << 20, avail: 1 << 20})
+ var wg sync.WaitGroup
+ for i := 0; i < 128; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ innerRelease, reserveErr := b.Reserve(context.Background(), 1024)
+ if reserveErr == nil {
+ innerRelease()
+ }
+ }()
+ }
+ wg.Wait()
+ if b.Reserved() != 0 {
+ t.Fatalf("reservation leaked: %d", b.Reserved())
+ }
+}
+
+func TestQueryBudgetFallbackIsFinite(t *testing.T) {
+ b := NewQueryBudget(&Nop{})
+ if b.Limit() == 0 || b.MaxQuery() == 0 {
+ t.Fatal("disabled protector must retain finite limits")
+ }
+ release, err := b.Admit(context.Background(), 100, 0, 20)
+ if err != nil {
+ t.Fatalf("default-sized query should fit finite fallback: %v", err)
+ }
+ release()
+}
+
+func TestQueryBudgetAdmitRejectsWindowUnderPressure(t *testing.T) {
+ b := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ if _, err := b.Admit(context.Background(), 100000, 0, 20); !errors.Is(err, ErrQueryWindowPressure) {
+ t.Fatalf("want dynamic window rejection, got %v", err)
+ }
+}
diff --git a/banyand/protector/query_reservation_test.go b/banyand/protector/query_reservation_test.go
new file mode 100644
index 000000000..b8a8bcadf
--- /dev/null
+++ b/banyand/protector/query_reservation_test.go
@@ -0,0 +1,145 @@
+// Licensed to the 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. The 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 protector
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+func TestQueryReservationRetainsOwnerUntilChildRelease(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ ctx, releaseOuter, err := budget.AdmitContext(context.Background(), 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, releaseChild, err := budget.AdmitContext(ctx, 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ releaseOuter()
+ if budget.Reserved() == 0 {
+ t.Fatal("outer release freed reservation while child was active")
+ }
+ releaseChild()
+ if budget.Reserved() != 0 {
+ t.Fatalf("reservation leaked after child release: %d", budget.Reserved())
+ }
+}
+
+func TestQueryReservationTransportScopeRetainsLocalAdmission(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ transportCtx, scope := query.NewTransportScope(context.Background())
+ _, release, err := budget.AdmitContext(transportCtx, 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ release()
+ if budget.Reserved() == 0 {
+ t.Fatal("local release freed reservation before transport completion")
+ }
+ scope.Close()
+ if budget.Reserved() != 0 {
+ t.Fatalf("reservation leaked after transport close: %d", budget.Reserved())
+ }
+}
+
+func TestQueryReservationTransportScopeCloseBeforeLocalRelease(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ transportCtx, scope := query.NewTransportScope(context.Background())
+ _, release, err := budget.AdmitContext(transportCtx, 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ scope.Close()
+ if budget.Reserved() == 0 {
+ t.Fatal("transport close freed active local reservation")
+ }
+ release()
+ if budget.Reserved() != 0 {
+ t.Fatalf("reservation leaked after local release: %d", budget.Reserved())
+ }
+}
+
+func TestQueryReservationTransportScopeNestedReleases(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ transportCtx, scope := query.NewTransportScope(context.Background())
+ ctx, releaseOuter, err := budget.AdmitContext(transportCtx, 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, releaseChild, err := budget.AdmitContext(ctx, 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ releaseOuter()
+ releaseOuter()
+ releaseChild()
+ if budget.Reserved() == 0 {
+ t.Fatal("transport scope releases were not retained")
+ }
+ scope.Close()
+ if budget.Reserved() != 0 {
+ t.Fatalf("reservation leaked after nested transport releases: %d", budget.Reserved())
+ }
+}
+
+func TestQueryReservationTransportScopeAlreadyClosed(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ transportCtx, scope := query.NewTransportScope(context.Background())
+ scope.Close()
+ _, release, err := budget.AdmitContext(transportCtx, 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if budget.Reserved() == 0 {
+ t.Fatal("admission returned a released lease")
+ }
+ release()
+ if budget.Reserved() != 0 {
+ t.Fatalf("reservation leaked after closed-scope admission: %d", budget.Reserved())
+ }
+}
+
+func TestQueryReservationRejectsCanceledAndReleasedContext(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ ctx, release, err := budget.AdmitContext(context.Background(), 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ canceled, cancel := context.WithCancel(ctx)
+ cancel()
+ if _, _, err = budget.AdmitContext(canceled, 20, 0, 20); !errors.Is(err, context.Canceled) {
+ t.Fatalf("want canceled borrowed context, got %v", err)
+ }
+ release()
+ if _, _, err = budget.AdmitContext(ctx, 20, 0, 20); !errors.Is(err, ErrQueryResourceExhausted) {
+ t.Fatalf("want released context rejection, got %v", err)
+ }
+}
+
+func TestQueryReservationRejectsWindowBeyondAssignment(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ ctx, release, err := budget.AdmitContext(context.Background(), 20, 0, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if _, _, err = budget.AdmitContext(ctx, 100000, 0, 20); !errors.Is(err, ErrQueryWindowPressure) {
+ t.Fatalf("want window pressure, got %v", err)
+ }
+}
diff --git a/banyand/protector/query_scan_budget_test.go b/banyand/protector/query_scan_budget_test.go
new file mode 100644
index 000000000..5dbaf3966
--- /dev/null
+++ b/banyand/protector/query_scan_budget_test.go
@@ -0,0 +1,193 @@
+// Licensed to the 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. The 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 protector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+func TestQueryScanBudgetActualResultCeiling(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 32 << 30, avail: 32 << 30})
+ ctx, release, err := budget.AdmitScanContext(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ lease, ok := query.BudgetLeaseFromContext(ctx)
+ if !ok {
+ t.Fatal("admitted scan has no lease")
+ }
+ initialUsed := lease.Used()
+ for result := 0; result < 100000; result++ {
+ if chargeErr := query.ChargeResult(ctx, 1); chargeErr != nil {
+ t.Fatalf("result %d must fit: %v", result+1, chargeErr)
+ }
+ }
+ if lease.Used() != initialUsed+100000 {
+ t.Fatalf("unexpected charged bytes: %d", lease.Used())
+ }
+ if chargeErr := query.ChargeResult(ctx, 1); !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Fatalf("result 100001 must fail, got %v", chargeErr)
+ }
+ if lease.Used() != initialUsed+100000 {
+ t.Fatal("rejected result consumed byte allowance")
+ }
+}
+
+func TestQueryScanBudgetBytePressurePreservesResultAllowance(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ ctx, release, err := budget.AdmitScanContext(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ lease, ok := query.BudgetLeaseFromContext(ctx)
+ if !ok {
+ t.Fatal("admitted scan has no lease")
+ }
+ initialUsed := lease.Used()
+ if chargeErr := query.ChargeResult(ctx, lease.Limit()-initialUsed+1); !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Fatalf("oversized result must fail, got %v", chargeErr)
+ }
+ if lease.Used() != initialUsed {
+ t.Fatal("rejected result consumed byte allowance")
+ }
+ if chargeErr := query.ChargeResult(ctx, lease.Limit()-initialUsed); chargeErr != nil {
+ t.Fatal(chargeErr)
+ }
+ if chargeErr := query.ChargeResult(ctx, 1); !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Fatalf("exhausted byte budget accepted result: %v", chargeErr)
+ }
+ for result := 1; result < 100000; result++ {
+ if chargeErr := query.ChargeResult(ctx, 0); chargeErr != nil {
+ t.Fatalf("failed byte charge consumed result allowance at %d: %v", result+1, chargeErr)
+ }
+ }
+ if chargeErr := query.ChargeResult(ctx, 0); !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Fatalf("zero-byte result bypassed result ceiling: %v", chargeErr)
+ }
+}
+
+func TestQueryScanBudgetNestedTransportLifetime(t *testing.T) {
+ for _, closeTransportFirst := range []bool{false, true} {
+ t.Run(fmt.Sprintf("transport_first_%t", closeTransportFirst), func(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 64 << 20})
+ transportCtx, scope := query.NewTransportScope(context.Background())
+ defer scope.Close()
+ outerCtx, releaseOuter, err := budget.AdmitScanContext(transportCtx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer releaseOuter()
+ reserved := budget.Reserved()
+ childCtx, releaseChild, err := budget.AdmitScanContext(outerCtx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer releaseChild()
+ outerLease, _ := query.BudgetLeaseFromContext(outerCtx)
+ childLease, _ := query.BudgetLeaseFromContext(childCtx)
+ if outerLease != childLease || budget.Reserved() != reserved {
+ t.Fatal("nested scan did not share reservation")
+ }
+ releaseOuter()
+ releaseOuter()
+ if closeTransportFirst {
+ scope.Close()
+ } else {
+ releaseChild()
+ }
+ if budget.Reserved() != reserved {
+ t.Fatal("reservation released while still retained")
+ }
+ if chargeErr := query.ChargeResult(childCtx, 1); chargeErr != nil {
+ t.Fatal(chargeErr)
+ }
+ if closeTransportFirst {
+ releaseChild()
+ } else {
+ scope.Close()
+ }
+ if budget.Reserved() != 0 {
+ t.Fatalf("reservation leaked: %d", budget.Reserved())
+ }
+ if chargeErr := query.ChargeResult(childCtx, 0); !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Fatalf("released lease accepted result: %v", chargeErr)
+ }
+ if _, unexpectedRelease, admissionErr := budget.AdmitScanContext(childCtx); !errors.Is(admissionErr, ErrQueryResourceExhausted) {
+ if unexpectedRelease != nil {
+ unexpectedRelease()
+ }
+ t.Fatalf("released scan context readmitted: %v", admissionErr)
+ }
+ })
+ }
+}
+
+func TestQueryScanBudgetRejectsMemoryPressureAndCancellation(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 64 << 20, avail: 1})
+ if _, release, err := budget.AdmitScanContext(context.Background()); !errors.Is(err, ErrQueryResourceExhausted) {
+ if release != nil {
+ release()
+ }
+ t.Fatalf("memory pressure admitted scan: %v", err)
+ }
+ canceledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, release, err := budget.AdmitScanContext(canceledCtx); !errors.Is(err, context.Canceled) {
+ if release != nil {
+ release()
+ }
+ t.Fatalf("canceled context admitted scan: %v", err)
+ }
+ if budget.Reserved() != 0 {
+ t.Fatalf("rejection leaked reservation: %d", budget.Reserved())
+ }
+}
+
+func TestQueryScanBudgetConcurrentResultCeiling(t *testing.T) {
+ budget := NewQueryBudget(&budgetProtector{limit: 32 << 30, avail: 32 << 30})
+ ctx, release, err := budget.AdmitScanContext(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ var accepted atomic.Uint64
+ var workers sync.WaitGroup
+ for worker := 0; worker < 8; worker++ {
+ workers.Add(1)
+ go func() {
+ defer workers.Done()
+ for result := 0; result < 15000; result++ {
+ chargeErr := query.ChargeResult(ctx, 1)
+ if chargeErr == nil {
+ accepted.Add(1)
+ } else if !errors.Is(chargeErr, ErrQueryResourceExhausted) {
+ t.Errorf("unexpected charge error: %v", chargeErr)
+ }
+ }
+ }()
+ }
+ workers.Wait()
+ if accepted.Load() != 100000 {
+ t.Fatalf("concurrent admission accepted %d results", accepted.Load())
+ }
+}
diff --git a/banyand/query/processor.go b/banyand/query/processor.go
index 7096c565e..32e483117 100644
--- a/banyand/query/processor.go
+++ b/banyand/query/processor.go
@@ -36,6 +36,7 @@ import (
tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
"github.com/apache/skywalking-banyandb/banyand/measure"
"github.com/apache/skywalking-banyandb/banyand/observability"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
"github.com/apache/skywalking-banyandb/banyand/stream"
"github.com/apache/skywalking-banyandb/banyand/trace"
"github.com/apache/skywalking-banyandb/pkg/bus"
@@ -88,6 +89,22 @@ func (p *streamQueryProcessor) Rev(ctx context.Context, message bus.Message) (re
resp = bus.NewMessage(bus.MessageID(now), common.NewError("invalid event data type"))
return
}
+ budget := p.queryBudget
+ if budget == nil {
+ budget = protector.QueryBudgetFor(nil)
+ }
+ admittedCtx, release, admissionErr := budget.AdmitRequestContext(ctx, queryCriteria.GetLimit(), queryCriteria.GetOffset(), 20)
+ if admissionErr != nil {
+ resp = bus.NewMessage(bus.MessageID(now), common.NewError("query admission failed: %v", admissionErr))
+ return
+ }
+ defer release()
+ ctx = admittedCtx
+ defer func() {
+ if chargeErr := query.ChargeResponse(ctx, resp.Data()); chargeErr != nil {
+ resp = bus.NewMessage(resp.ID(), common.NewError("query response memory exhausted: %v", chargeErr))
+ }
+ }()
if p.log.Debug().Enabled() {
p.log.Debug().RawJSON("criteria", logger.Proto(queryCriteria)).Msg("received a query request")
}
@@ -145,7 +162,8 @@ func (p *streamQueryProcessor) Rev(ctx context.Context, message bus.Message) (re
case *common.Error:
span.Error(errors.New(d.Error()))
span.Stop()
- resp = bus.NewMessage(bus.MessageID(now), &streamv1.QueryResponse{Trace: tracer.ToProto()})
+ // Preserve the error response. A tracing request must not turn
+ // admission or execution failures into a successful empty result.
default:
panic("unexpected data type")
}
@@ -897,6 +915,22 @@ func (p *traceQueryProcessor) Rev(ctx context.Context, message bus.Message) (res
resp = bus.NewMessage(bus.MessageID(now), common.NewError("invalid event data type"))
return
}
+ budget := p.queryBudget
+ if budget == nil {
+ budget = protector.QueryBudgetFor(nil)
+ }
+ admittedCtx, release, admissionErr := budget.AdmitRequestContext(ctx, queryCriteria.GetLimit(), queryCriteria.GetOffset(), 20)
+ if admissionErr != nil {
+ resp = bus.NewMessage(bus.MessageID(now), common.NewError("query admission failed: %v", admissionErr))
+ return
+ }
+ defer release()
+ ctx = admittedCtx
+ defer func() {
+ if chargeErr := query.ChargeResponse(ctx, resp.Data()); chargeErr != nil {
+ resp = bus.NewMessage(resp.ID(), common.NewError("query response memory exhausted: %v", chargeErr))
+ }
+ }()
if p.log.Debug().Enabled() {
p.log.Debug().RawJSON("criteria", logger.Proto(queryCriteria)).Msg("received a trace query request")
}
@@ -999,7 +1033,7 @@ func (p *traceQueryProcessor) setupTraceMonitor(ctx context.Context, queryCriter
}
}
-func (tm *traceMonitor) finishTrace(resp *bus.Message, messageID int64) {
+func (tm *traceMonitor) finishTrace(resp *bus.Message) {
if tm == nil {
return
}
@@ -1013,7 +1047,7 @@ func (tm *traceMonitor) finishTrace(resp *bus.Message, messageID int64) {
case *common.Error:
tm.span.Error(errors.New(d.Error()))
tm.span.Stop()
- *resp = bus.NewMessage(bus.MessageID(messageID), &tracev1.QueryResponse{TraceQueryResult: tm.tracer.ToProto()})
+ // Preserve the error response; tracing must not make failures look successful.
default:
panic("unexpected data type")
}
@@ -1259,7 +1293,7 @@ func (p *traceQueryProcessor) executeQuery(ctx context.Context, queryCriteria *t
ctx, traceMonitor := p.setupTraceMonitor(ctx, queryCriteria, plan, n)
if traceMonitor != nil {
- defer traceMonitor.finishTrace(&resp, now)
+ defer traceMonitor.finishTrace(&resp)
}
te := plan.(executor.TraceExecutable)
diff --git a/banyand/query/query.go b/banyand/query/query.go
index c4e5ca843..e010eaeee 100644
--- a/banyand/query/query.go
+++ b/banyand/query/query.go
@@ -30,6 +30,7 @@ import (
"github.com/apache/skywalking-banyandb/banyand/measure"
"github.com/apache/skywalking-banyandb/banyand/metadata"
"github.com/apache/skywalking-banyandb/banyand/observability"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
"github.com/apache/skywalking-banyandb/banyand/queue"
"github.com/apache/skywalking-banyandb/banyand/stream"
"github.com/apache/skywalking-banyandb/banyand/trace"
@@ -41,6 +42,7 @@ type queryService struct {
metaService metadata.Repo
pipeline queue.Server
log *logger.Logger
+ queryBudget *protector.QueryBudget
sqp *streamQueryProcessor
mqp *measureQueryProcessor
imqp *measureInternalQueryProcessor
@@ -53,10 +55,16 @@ type queryService struct {
// NewService return a new query service.
func NewService(_ context.Context, streamService stream.Service, measureService measure.Service, traceService trace.Service,
metaService metadata.Repo, pipeline queue.Server, metricSvc observability.MetricsRegistry, distributed bool,
+ protectors ...protector.Memory,
) (run.Unit, error) {
+ var pm protector.Memory
+ if len(protectors) > 0 {
+ pm = protectors[0]
+ }
svc := &queryService{
metaService: metaService,
pipeline: pipeline,
+ queryBudget: protector.QueryBudgetFor(pm),
}
// measure query processor
svc.mqp = &measureQueryProcessor{
diff --git a/banyand/queue/sub/sub.go b/banyand/queue/sub/sub.go
index e0bd6ba80..1810b758a 100644
--- a/banyand/queue/sub/sub.go
+++ b/banyand/queue/sub/sub.go
@@ -34,6 +34,7 @@ import (
"github.com/apache/skywalking-banyandb/banyand/internal/storage"
"github.com/apache/skywalking-banyandb/pkg/bus"
"github.com/apache/skywalking-banyandb/pkg/logger"
+ "github.com/apache/skywalking-banyandb/pkg/query"
)
func checkVersionCompatibility(versionInfo *clusterv1.VersionInfo) (*clusterv1.VersionCompatibility, modelv1.Status) {
@@ -264,13 +265,15 @@ func (s *server) dispatchMessage(
if len(listeners) > 1 {
logger.Panicf("multiple listeners found for topic %s", topic)
}
+ queryCtx, queryScope := query.NewTransportScope(stream.Context())
+ defer queryScope.Close()
// Tick started immediately before Rev so that started and finished are always paired;
// pre-Rev early returns (no-listener) produce no metric change.
if s.metrics != nil {
s.metrics.totalStarted.Inc(1, identity.operation, identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
s.metrics.totalMessageStarted.Inc(1, identity.operation, identity.group, identity.senderNode, identity.senderRole, identity.senderTier)
}
- m = listeners[0].Rev(stream.Context(), m)
+ m = listeners[0].Rev(queryCtx, m)
// The BatchMod fork in Send routes each SendRequest to exactly one of
// handleBatch→handleEOF (batch) or dispatchMessage (non-batch), so no message is double-counted.
if s.metrics != nil {
diff --git a/banyand/stream/query_by_idx.go b/banyand/stream/query_by_idx.go
index cdcbaddc4..f39b19f3a 100644
--- a/banyand/stream/query_by_idx.go
+++ b/banyand/stream/query_by_idx.go
@@ -52,7 +52,7 @@ type idxResult struct {
func (qr *idxResult) Pull(ctx context.Context) *model.StreamResult {
if !qr.loaded {
- qr.elementIDsSorted = make([]uint64, 0, qr.qo.MaxElementSize)
+ qr.elementIDsSorted = make([]uint64, 0, queryCapacity(qr.qo.MaxElementSize))
return qr.loadSortingData(ctx)
}
if v := qr.nextValue(); v != nil {
@@ -100,8 +100,11 @@ func (qr *idxResult) scanParts(ctx context.Context, qo queryOptions) error {
}
}
hit++
- bc := generateBlockCursor()
p := ti.piHeap[0]
+ if chargeErr := query.Charge(ctx, p.curBlock.uncompressedSizeBytes); chargeErr != nil {
+ return chargeErr
+ }
+ bc := generateBlockCursor()
bc.init(p.p, p.curBlock, qo)
qr.data = append(qr.data, bc)
totalBlockBytes += bc.bm.uncompressedSizeBytes
@@ -229,6 +232,9 @@ func (qr *idxResult) loadSortingData(ctx context.Context) *model.StreamResult {
count--
continue
}
+ if chargeErr := query.Charge(ctx, 64); chargeErr != nil {
+ return &model.StreamResult{Error: chargeErr}
+ }
seenElementIDs[val.DocID] = true
qo.elementFilter.Insert(val.DocID)
if val.Timestamp > qo.maxTimestamp {
diff --git a/banyand/stream/query_capacity.go b/banyand/stream/query_capacity.go
new file mode 100644
index 000000000..e419db601
--- /dev/null
+++ b/banyand/stream/query_capacity.go
@@ -0,0 +1,26 @@
+// Licensed to the 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. The 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 stream
+
+const queryCapacityHint = 1024
+
+func queryCapacity(size int) int {
+ if size > queryCapacityHint {
+ return queryCapacityHint
+ }
+ if size < 0 {
+ return 0
+ }
+ return size
+}
diff --git a/banyand/stream/query_capacity_test.go b/banyand/stream/query_capacity_test.go
new file mode 100644
index 000000000..cc41f7191
--- /dev/null
+++ b/banyand/stream/query_capacity_test.go
@@ -0,0 +1,77 @@
+// Licensed to the 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. The 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 stream
+
+import (
+ "context"
+ "math"
+ "testing"
+
+ "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/index"
+ "github.com/apache/skywalking-banyandb/pkg/query/model"
+)
+
+// The stream index-order path derived MaxElementSize from limit+offset and
+// preallocated elementIDsSorted with that capacity. When the vectorized path
+// declines (order tag omitted from projection), this row path must stay bounded.
+func TestQueryCapacityCapsIndexOrderElementIDs(t *testing.T) {
+ if got := queryCapacity(math.MaxInt32); got != queryCapacityHint {
+ t.Fatalf("queryCapacity(MaxInt32)=%d, want %d", got, queryCapacityHint)
+ }
+ if got := queryCapacity(64); got != 64 {
+ t.Fatalf("queryCapacity(64)=%d, want 64", got)
+ }
+ const oversized = 4096
+ ids := make([]uint64, 0, queryCapacity(oversized))
+ if cap(ids) != queryCapacityHint {
+ t.Fatalf("elementIDsSorted capacity %d, want %d", cap(ids), queryCapacityHint)
+ }
+}
+
+func TestIndexOrderFallbackElementIDsCapacityBounded(t *testing.T) {
+ const oversizedMaxElementSize = 4096
+ indexRule := &databasev1.IndexRule{
+ Metadata: &commonv1.Metadata{Name: "filter-idx", Id: indexOrderRuleID},
+ Tags: []string{"filter-tag"},
+ }
+ s, tr := buildIndexOrderStream(t)
+ sqo := model.StreamQueryOptions{
+ Name: "benchmark",
+ TimeRange: &tr,
+ Entities: parityEntities(indexOrderSeriesCount),
+ // Order by filter-tag but project only entity-tag so the vectorized path
+ // declines and the row index-sort path allocates elementIDsSorted.
+ TagProjection: []model.TagProjection{{
+ Family: "benchmark-family",
+ Names: []string{"entity-tag"},
+ }},
+ Order: &index.OrderBy{Index: indexRule, Sort: modelv1.Sort_SORT_ASC},
+ MaxElementSize: oversizedMaxElementSize,
+ }
+ res, err := s.Query(context.Background(), sqo)
+ require.NoError(t, err)
+ require.NotNil(t, res)
+ defer res.Release()
+
+ qr, ok := res.(*idxResult)
+ require.True(t, ok, "expected idxResult from indexed-order query")
+ _ = qr.Pull(context.Background())
+ require.Equal(t, queryCapacityHint, cap(qr.elementIDsSorted),
+ "idxResult.elementIDsSorted must stay hint-capped for oversized MaxElementSize")
+}
diff --git a/banyand/stream/query_vectorized.go b/banyand/stream/query_vectorized.go
index 50c7c9881..850bfb1c4 100644
--- a/banyand/stream/query_vectorized.go
+++ b/banyand/stream/query_vectorized.go
@@ -27,6 +27,7 @@ import (
"github.com/apache/skywalking-banyandb/banyand/internal/storage"
"github.com/apache/skywalking-banyandb/pkg/index"
pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+ "github.com/apache/skywalking-banyandb/pkg/query"
"github.com/apache/skywalking-banyandb/pkg/query/model"
"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
vstream "github.com/apache/skywalking-banyandb/pkg/query/vectorized/stream"
@@ -201,6 +202,12 @@ func (v *streamVecScan) fillFromScanner(ctx context.Context) error {
}
for i := range batch.bss {
bs := &batch.bss[i]
+ if chargeErr := query.Charge(ctx, bs.bm.uncompressedSizeBytes); chargeErr != nil {
+ if v.budgetErr == nil {
+ v.budgetErr = chargeErr
+ }
+ continue
+ }
// Metadata preflight for the QueryMemoryMiB soft budget: predict whether
// decoding this block would push cumulative uncompressed bytes over the
// budget and, if so, FAIL LOUD before the expensive decode. The first block
diff --git a/banyand/trace/query_capacity_test.go b/banyand/trace/query_capacity_test.go
new file mode 100644
index 000000000..bcd88459b
--- /dev/null
+++ b/banyand/trace/query_capacity_test.go
@@ -0,0 +1,45 @@
+// Licensed to the 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. The 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 trace
+
+import (
+ "math"
+ "strconv"
+ "testing"
+)
+
+// Trace queries previously turned limit(+offset) into MaxTraceSize / MaxBatchSize
+// and sized the legacy keys map from that value. newTraceBatch must keep the
+// initial map hint bounded for oversized requests.
+func TestTraceBatchCapacityCapsOversizedLimit(t *testing.T) {
+ if got := traceBatchCapacity(math.MaxInt32); got != 1024 {
+ t.Fatalf("traceBatchCapacity(MaxInt32)=%d, want 1024", got)
+ }
+ if got := traceBatchCapacity(16); got != 16 {
+ t.Fatalf("traceBatchCapacity(16)=%d, want 16", got)
+ }
+ // Modest oversized input: a guard regression fails the helper assert above /
+ // would only allocate a few KiB here, never MaxInt32 map buckets.
+ const oversized = 4096
+ if got := traceBatchCapacity(oversized); got != 1024 {
+ t.Fatalf("traceBatchCapacity(%d)=%d, want 1024", oversized, got)
+ }
+ batch := newTraceBatch(0, oversized)
+ if batch.keys == nil {
+ t.Fatal("expected keys map for positive capped capacity")
+ }
+ for i := 0; i < 1025; i++ {
+ batch.keys[strconv.Itoa(i)] = int64(i)
+ }
+}
diff --git a/banyand/trace/query_vectorized.go b/banyand/trace/query_vectorized.go
index cc18f6e0e..4ba101332 100644
--- a/banyand/trace/query_vectorized.go
+++ b/banyand/trace/query_vectorized.go
@@ -32,6 +32,7 @@ import (
"github.com/apache/skywalking-banyandb/pkg/convert"
itersort "github.com/apache/skywalking-banyandb/pkg/iter/sort"
pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+ "github.com/apache/skywalking-banyandb/pkg/query"
"github.com/apache/skywalking-banyandb/pkg/query/model"
vtrace "github.com/apache/skywalking-banyandb/pkg/query/vectorized/trace"
)
@@ -203,6 +204,10 @@ func assembleVectorizedTraceResults(
cursor.resolveTagProjection()
tmpBlock.reset()
+ if chargeErr := query.Charge(ctx, cursor.bm.uncompressedSpanSizeBytes); chargeErr != nil {
+ releaseRemainingBlockCursors(batch, i)
+ return nil, chargeErr
+ }
tmpBlock.mustReadFrom(&cursor.tagValuesDecoder, cursor.p, cursor.bm)
if len(tmpBlock.spans) == 0 {
releaseBlockCursor(cursor)
diff --git a/banyand/trace/streaming_pipeline.go b/banyand/trace/streaming_pipeline.go
index 766bd2787..c3d236959 100644
--- a/banyand/trace/streaming_pipeline.go
+++ b/banyand/trace/streaming_pipeline.go
@@ -58,12 +58,25 @@ func newTraceBatch(seq int, capacity int) traceBatch {
seq: seq,
traceIDs: make(map[uint64][]string),
}
+ // Keep the initial map allocation bounded; the batch remains semantically
+ // bounded by its caller while large requests cannot force a giant allocation.
+ capacity = traceBatchCapacity(capacity)
if capacity > 0 {
tb.keys = make(map[string]int64, capacity)
}
return tb
}
+func traceBatchCapacity(capacity int) int {
+ if capacity > 1024 {
+ return 1024
+ }
+ if capacity < 0 {
+ return 0
+ }
+ return capacity
+}
+
func staticTraceBatchSource(ctx context.Context, traceIDs []string, maxTraceSize int, keys map[string]int64) <-chan traceBatch {
out := make(chan traceBatch)
batchSourceLogger := logger.GetLogger("trace-batch-source")
@@ -79,6 +92,17 @@ func staticTraceBatchSource(ctx context.Context, traceIDs []string, maxTraceSize
limit = maxTraceSize
}
+ var retainedBytes uint64
+ for _, traceID := range traceIDs[:limit] {
+ retainedBytes += uint64(len(traceID)) + 64
+ }
+ if chargeErr := query.Charge(ctx, retainedBytes); chargeErr != nil {
+ select {
+ case out <- traceBatch{err: chargeErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
orderedIDs := append([]string(nil), traceIDs[:limit]...)
// Determine batch size. When maxTraceSize is zero, emit everything in one batch.
@@ -558,6 +582,9 @@ func (r *sidxStreamRunner) consumeShard(shard *sidxStreamShard) (bool, error) {
r.duplicates.Add(1)
return false, nil
}
+ if chargeErr := query.Charge(r.streamCtx, uint64(len(traceID))+128); chargeErr != nil {
+ return false, chargeErr
+ }
// Extract partID from SIDX response
partID := shard.response.PartIDs[shard.idx]
@@ -876,21 +903,26 @@ func (t *trace) scanPartsInlineSync(ctx context.Context, parts []*part, groupedI
}
hit++
- // Create block cursor and get size before checking quota
- bc := generateBlockCursor()
p := tstIter.piPool[tstIter.idx]
+ blockSize := p.curBlock.uncompressedSpanSizeBytes
+ if chargeErr := query.Charge(ctx, blockSize); chargeErr != nil {
+ spanErr = chargeErr
+ for _, cursor := range cursors {
+ releaseBlockCursor(cursor)
+ }
+ return nil, spanErr
+ }
+ // Create block cursor only after charge and quota checks.
+ bc := generateBlockCursor()
bc.init(p.p, p.curBlock, qo)
- blockSize := bc.bm.uncompressedSpanSizeBytes
// Check if adding this block would exceed quota
if quota >= 0 && spanBlockBytes+blockSize > uint64(quota) {
releaseBlockCursor(bc)
- if cursorCount > 0 {
- // Have results, return them successfully by just closing channel
- return cursors, nil
- }
- // No results, send error
spanErr = fmt.Errorf("block scan quota exceeded: block size %d bytes, quota is %d bytes", blockSize, quota)
+ for _, cursor := range cursors {
+ releaseBlockCursor(cursor)
+ }
return nil, spanErr
}
diff --git a/banyand/trace/streaming_pipeline_test.go b/banyand/trace/streaming_pipeline_test.go
index 62b585bbe..fccc6ec5c 100644
--- a/banyand/trace/streaming_pipeline_test.go
+++ b/banyand/trace/streaming_pipeline_test.go
@@ -29,8 +29,34 @@ import (
"github.com/apache/skywalking-banyandb/banyand/internal/sidx"
"github.com/apache/skywalking-banyandb/banyand/queue"
"github.com/apache/skywalking-banyandb/pkg/index"
+ "github.com/apache/skywalking-banyandb/pkg/query"
)
+type traceTinyLease struct{ remaining uint64 }
+
+func (l *traceTinyLease) Charge(bytes uint64) error {
+ if bytes > l.remaining {
+ return errors.New("trace test budget exhausted")
+ }
+ l.remaining -= bytes
+ return nil
+}
+func (*traceTinyLease) Limit() uint64 { return 1024 }
+func (l *traceTinyLease) Used() uint64 { return 1024 - l.remaining }
+func (*traceTinyLease) Owner() any { return "trace-test" }
+
+func TestStaticTraceBatchSourceReportsChargeFailure(t *testing.T) {
+ ctx := query.WithBudgetLease(context.Background(), &traceTinyLease{remaining: 1})
+ ids := []string{string(make([]byte, 4096))}
+ batch, ok := <-staticTraceBatchSource(ctx, ids, 0, nil)
+ if !ok {
+ t.Fatal("source closed without reporting charge failure")
+ }
+ if batch.err == nil {
+ t.Fatal("expected charge failure")
+ }
+}
+
type fakeSIDX struct {
responses []*sidx.QueryResponse
}
diff --git a/fodc/proxy/internal/integration/failure_scenarios_test.go b/fodc/proxy/internal/integration/failure_scenarios_test.go
index f8cac993f..c810e2573 100644
--- a/fodc/proxy/internal/integration/failure_scenarios_test.go
+++ b/fodc/proxy/internal/integration/failure_scenarios_test.go
@@ -183,6 +183,7 @@ var _ = Describe("Failure Scenarios", func() {
It("handles agent disconnection gracefully", func() {
agent := startAgentForFixture(fixture, "127.0.0.10", "liaison")
+ defer stopAgent(agent)
registerAgent(agent)
Expect(testhelper.UpdateMetrics(agent.flight, []testhelper.RawMetric{{
@@ -195,19 +196,15 @@ var _ = Describe("Failure Scenarios", func() {
}})).To(Succeed())
var metricsList []map[string]interface{}
- Eventually(func() error {
+ Eventually(func(g Gomega) {
resp, err := http.Get(fmt.Sprintf("http://%s/metrics-windows", fixture.proxyHTTPAddr))
- if err != nil {
- return err
- }
+ g.Expect(err).NotTo(HaveOccurred())
defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
- return json.NewDecoder(resp.Body).Decode(&metricsList)
+ g.Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ g.Expect(json.NewDecoder(resp.Body).Decode(&metricsList)).To(Succeed())
+ g.Expect(metricsList).NotTo(BeEmpty())
}, 10*time.Second, 500*time.Millisecond).Should(Succeed())
- Expect(len(metricsList)).To(BeNumerically(">=", 1))
Expect(fixture.aggregator.ActiveCollections()).To(Equal(0))
stopAgent(agent)
@@ -240,7 +237,9 @@ var _ = Describe("Failure Scenarios", func() {
It("continues metrics collection when part of the cluster is partitioned", func() {
first := startAgentForFixture(fixture, "127.0.0.21", "datanode-hot")
+ defer stopAgent(first)
second := startAgentForFixture(fixture, "127.0.0.22", "datanode-warm")
+ defer stopAgent(second)
registerAgent(first)
registerAgent(second)
@@ -276,19 +275,15 @@ var _ = Describe("Failure Scenarios", func() {
}, 5*time.Second, 500*time.Millisecond).Should(Equal(1))
var metricsList []map[string]interface{}
- Eventually(func() error {
+ Eventually(func(g Gomega) {
resp, err := http.Get(fmt.Sprintf("http://%s/metrics-windows?pod_name=%s", fixture.proxyHTTPAddr, first.podName))
- if err != nil {
- return err
- }
+ g.Expect(err).NotTo(HaveOccurred())
defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
- return json.NewDecoder(resp.Body).Decode(&metricsList)
+ g.Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ g.Expect(json.NewDecoder(resp.Body).Decode(&metricsList)).To(Succeed())
+ g.Expect(metricsList).NotTo(BeEmpty())
}, 15*time.Second, 500*time.Millisecond).Should(Succeed())
- Expect(len(metricsList)).To(BeNumerically(">=", 1))
for _, metric := range metricsList {
Expect(metric["labels"].(map[string]interface{})["node_role"]).To(Equal("datanode-hot"))
}
@@ -299,6 +294,7 @@ var _ = Describe("Failure Scenarios", func() {
It("recovers after the proxy's gRPC service restarts", func() {
agent := startAgentForFixture(fixture, "127.0.0.30", "datanode-cold")
+ defer stopAgent(agent)
registerAgent(agent)
Expect(testhelper.UpdateMetrics(agent.flight, []testhelper.RawMetric{{
@@ -311,20 +307,15 @@ var _ = Describe("Failure Scenarios", func() {
}})).To(Succeed())
var metricsList []map[string]interface{}
- Eventually(func() error {
+ Eventually(func(g Gomega) {
resp, err := http.Get(fmt.Sprintf("http://%s/metrics-windows", fixture.proxyHTTPAddr))
- if err != nil {
- return err
- }
+ g.Expect(err).NotTo(HaveOccurred())
defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
- return json.NewDecoder(resp.Body).Decode(&metricsList)
+ g.Expect(resp.StatusCode).To(Equal(http.StatusOK))
+ g.Expect(json.NewDecoder(resp.Body).Decode(&metricsList)).To(Succeed())
+ g.Expect(metricsList).NotTo(BeEmpty())
}, 10*time.Second, 500*time.Millisecond).Should(Succeed())
- Expect(len(metricsList)).To(BeNumerically(">=", 1))
-
agent.cancel()
Expect(agent.client.Disconnect()).To(Succeed())
@@ -338,6 +329,7 @@ var _ = Describe("Failure Scenarios", func() {
// Create a new agent client for reconnection after server restart
agent = startAgentForFixture(fixture, "127.0.0.30", "datanode-cold")
+ defer stopAgent(agent)
registerAgent(agent)
Expect(testhelper.UpdateMetrics(agent.flight, []testhelper.RawMetric{{
@@ -349,32 +341,28 @@ var _ = Describe("Failure Scenarios", func() {
},
}})).To(Succeed())
- Eventually(func() error {
+ Eventually(func(g Gomega) {
resp, err := http.Get(fmt.Sprintf("http://%s/metrics-windows", fixture.proxyHTTPAddr))
- if err != nil {
- return err
- }
+ g.Expect(err).NotTo(HaveOccurred())
defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
+ g.Expect(resp.StatusCode).To(Equal(http.StatusOK))
metricsList = nil
- return json.NewDecoder(resp.Body).Decode(&metricsList)
- }, 20*time.Second, 500*time.Millisecond).Should(Succeed())
-
- found := false
- for _, metric := range metricsList {
- if metric["name"].(string) == "recovery_metric" {
- Expect(metric["data"]).NotTo(BeNil())
- for _, data := range metric["data"].([]interface{}) {
- dataPoint := data.(map[string]interface{})
- if dataPoint["value"].(float64) == 500.0 {
- found = true
+ g.Expect(json.NewDecoder(resp.Body).Decode(&metricsList)).To(Succeed())
+
+ found := false
+ for _, metric := range metricsList {
+ if metric["name"].(string) == "recovery_metric" {
+ g.Expect(metric["data"]).NotTo(BeNil())
+ for _, data := range metric["data"].([]interface{}) {
+ dataPoint := data.(map[string]interface{})
+ if dataPoint["value"].(float64) == 500.0 {
+ found = true
+ }
}
}
}
- }
- Expect(found).To(BeTrue())
+ g.Expect(found).To(BeTrue())
+ }, 20*time.Second, 500*time.Millisecond).Should(Succeed())
stopAgent(agent)
})
diff --git a/pkg/cmdsetup/data.go b/pkg/cmdsetup/data.go
index 93891ff68..83696d663 100644
--- a/pkg/cmdsetup/data.go
+++ b/pkg/cmdsetup/data.go
@@ -76,7 +76,7 @@ func newDataCmd(runners ...run.Unit) *cobra.Command {
if err != nil {
l.Fatal().Err(err).Msg("failed to initiate trace service")
}
- q, err := query.NewService(ctx, streamSvc, measureSvc, traceSvc, metaSvc, pipeline, metricSvc, true)
+ q, err := query.NewService(ctx, streamSvc, measureSvc, traceSvc, metaSvc, pipeline, metricSvc, true, pm)
if err != nil {
l.Fatal().Err(err).Msg("failed to initiate query processor")
}
diff --git a/pkg/cmdsetup/liaison.go b/pkg/cmdsetup/liaison.go
index a261e74b7..a433d2ec8 100644
--- a/pkg/cmdsetup/liaison.go
+++ b/pkg/cmdsetup/liaison.go
@@ -112,6 +112,7 @@ func newLiaisonCmd(runners ...run.Unit) *cobra.Command {
units = append(units,
metricSvc,
metaSvc,
+ pm,
localPipeline,
internalPipeline,
tire1Client,
diff --git a/pkg/cmdsetup/liaison_protector_test.go b/pkg/cmdsetup/liaison_protector_test.go
new file mode 100644
index 000000000..03f258b05
--- /dev/null
+++ b/pkg/cmdsetup/liaison_protector_test.go
@@ -0,0 +1,54 @@
+// 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 cmdsetup
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// Liaison must register the memory protector in its run group. Without PreRun,
+// GetLimit stays 0 and query admission falls back to a 64MiB pool that rejects
+// OAP property list-all during Cluster/Rover e2e.
+func TestLiaisonSourceRegistersMemoryProtector(t *testing.T) {
+ _, thisFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ src, err := os.ReadFile(filepath.Join(filepath.Dir(thisFile), "liaison.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(src)
+ unitsIdx := strings.Index(text, "units = append(units,")
+ if unitsIdx < 0 {
+ t.Fatal("liaison units append not found")
+ }
+ // Bound the search to the first run-group registration after units append.
+ registerIdx := strings.Index(text[unitsIdx:], "liaisonGroup.Register")
+ if registerIdx < 0 {
+ t.Fatal("liaisonGroup.Register not found after units append")
+ }
+ unitsBlock := text[unitsIdx : unitsIdx+registerIdx]
+ if !strings.Contains(unitsBlock, "\tpm,") && !strings.Contains(unitsBlock, " pm,") {
+ t.Fatal("liaison run units must include pm so memory-protector PreRun enables query budgets")
+ }
+}
diff --git a/pkg/cmdsetup/standalone.go b/pkg/cmdsetup/standalone.go
index 86d715c91..c9a7af870 100644
--- a/pkg/cmdsetup/standalone.go
+++ b/pkg/cmdsetup/standalone.go
@@ -72,7 +72,7 @@ func newStandaloneCmd(runners ...run.Unit) *cobra.Command {
if err != nil {
l.Fatal().Err(err).Msg("failed to initiate measure service")
}
- q, err := query.NewService(ctx, streamSvc, measureSvc, traceSvc, metaSvc, dataPipeline, metricSvc, false)
+ q, err := query.NewService(ctx, streamSvc, measureSvc, traceSvc, metaSvc, dataPipeline, metricSvc, false, pm)
if err != nil {
l.Fatal().Err(err).Msg("failed to initiate query processor")
}
diff --git a/pkg/index/inverted/inverted_series.go b/pkg/index/inverted/inverted_series.go
index 9c749ec00..b32282b4f 100644
--- a/pkg/index/inverted/inverted_series.go
+++ b/pkg/index/inverted/inverted_series.go
@@ -34,6 +34,7 @@ import (
"github.com/apache/skywalking-banyandb/pkg/convert"
"github.com/apache/skywalking-banyandb/pkg/index"
"github.com/apache/skywalking-banyandb/pkg/index/analyzer"
+ querypkg "github.com/apache/skywalking-banyandb/pkg/query"
"github.com/apache/skywalking-banyandb/pkg/timestamp"
)
@@ -223,7 +224,7 @@ func (s *store) Search(ctx context.Context,
if err != nil {
return nil, err
}
- return parseResult(dmi, projection, limit)
+ return parseResult(ctx, dmi, projection, limit)
}
// StoredFields implements index.SeriesStore.
@@ -279,63 +280,82 @@ func (s *store) StoredFields(ctx context.Context, docID []byte, projection ...in
return fields, nil
}
-func parseResult(dmi search.DocumentMatchIterator, loadedFields []index.FieldKey, limit int) ([]index.SeriesDocument, error) {
- result := make([]index.SeriesDocument, 0, 10)
- next, err := dmi.Next()
- if err != nil {
- return nil, errors.WithMessage(err, "iterate document match iterator")
+func parseResult(ctx context.Context, dmi search.DocumentMatchIterator, loadedFields []index.FieldKey, limit int) ([]index.SeriesDocument, error) {
+ if chargeErr := querypkg.Charge(ctx, 1024+uint64(len(loadedFields))*64); chargeErr != nil {
+ return nil, chargeErr
}
+ result := make([]index.SeriesDocument, 0, 10)
fields := make([]string, 0, len(loadedFields))
- for i := range loadedFields {
- fields = append(fields, loadedFields[i].Marshal())
- }
- var hitNumber int
- for err == nil && next != nil {
- hitNumber = next.HitNumber
- var doc index.SeriesDocument
- if len(loadedFields) > 0 {
- doc.Fields = make(map[string][]byte)
- for i := range loadedFields {
- doc.Fields[fields[i]] = nil
- }
+ for _, loadedField := range loadedFields {
+ fields = append(fields, loadedField.Marshal())
+ }
+ for {
+ if contextErr := ctx.Err(); contextErr != nil {
+ return nil, contextErr
}
- var errTime error
- err = next.VisitStoredFields(func(field string, value []byte) bool {
- switch field {
- case docIDField:
- doc.Key.EntityValues = value
- case timestampField:
- var ts time.Time
- ts, errTime = bluge.DecodeDateTime(value)
- if errTime != nil {
- err = errTime
- return false
- }
- doc.Timestamp = ts.UnixNano()
- case versionField:
- doc.Version = convert.BytesToInt64(value)
- default:
- if _, ok := doc.Fields[field]; ok {
- doc.Fields[field] = bytes.Clone(value)
- }
- }
- return true
- })
- if err = multierr.Combine(err, errTime); err != nil {
- return nil, errors.WithMessagef(err, "visit stored fields, hit: %d", hitNumber)
+ next, nextErr := dmi.Next()
+ if nextErr != nil {
+ return nil, errors.WithMessage(nextErr, "iterate document match iterator")
+ }
+ if next == nil {
+ return result, nil
+ }
+ doc, readErr := readSeriesDocument(ctx, next, fields)
+ if readErr != nil {
+ return nil, readErr
}
if len(doc.Key.EntityValues) > 0 {
result = append(result, doc)
}
if limit > 0 && len(result) >= limit {
- break
+ return result, nil
}
- next, err = dmi.Next()
}
- if err != nil {
- return nil, errors.WithMessagef(err, "iterate document match iterator, hit: %d", hitNumber)
+}
+
+func readSeriesDocument(ctx context.Context, match *search.DocumentMatch, fields []string) (index.SeriesDocument, error) {
+ var doc index.SeriesDocument
+ // Charge the result container and projected-field map before retaining this hit.
+ if chargeErr := querypkg.ChargeResult(ctx, 256+uint64(len(fields))*64); chargeErr != nil {
+ return doc, chargeErr
+ }
+ if len(fields) > 0 {
+ doc.Fields = make(map[string][]byte, len(fields))
+ for _, fieldName := range fields {
+ doc.Fields[fieldName] = nil
+ }
+ }
+ var fieldErr error
+ visitErr := match.VisitStoredFields(func(field string, value []byte) bool {
+ switch field {
+ case docIDField:
+ if fieldErr = querypkg.Charge(ctx, uint64(len(value))); fieldErr != nil {
+ return false
+ }
+ doc.Key.EntityValues = bytes.Clone(value)
+ case timestampField:
+ var ts time.Time
+ ts, fieldErr = bluge.DecodeDateTime(value)
+ if fieldErr != nil {
+ return false
+ }
+ doc.Timestamp = ts.UnixNano()
+ case versionField:
+ doc.Version = convert.BytesToInt64(value)
+ default:
+ if _, ok := doc.Fields[field]; ok {
+ if fieldErr = querypkg.Charge(ctx, uint64(len(value))); fieldErr != nil {
+ return false
+ }
+ doc.Fields[field] = bytes.Clone(value)
+ }
+ }
+ return true
+ })
+ if readErr := multierr.Combine(visitErr, fieldErr); readErr != nil {
+ return index.SeriesDocument{}, errors.WithMessagef(readErr, "visit stored fields, hit: %d", match.HitNumber)
}
- return result, nil
+ return doc, nil
}
func (s *store) SeriesSort(ctx context.Context, indexQuery index.Query, orderBy *index.OrderBy,
diff --git a/pkg/index/inverted/query_scan_budget_test.go b/pkg/index/inverted/query_scan_budget_test.go
new file mode 100644
index 000000000..efde2e65c
--- /dev/null
+++ b/pkg/index/inverted/query_scan_budget_test.go
@@ -0,0 +1,86 @@
+// Licensed to the 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. The 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 inverted
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/index"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+ querypkg "github.com/apache/skywalking-banyandb/pkg/query"
+)
+
+func TestSearchLargeLimitUsesScanBudget(t *testing.T) {
+ store, openErr := NewStore(StoreOpts{Path: t.TempDir(), Logger: logger.GetLogger("scan-budget-test")})
+ require.NoError(t, openErr)
+ t.Cleanup(func() { require.NoError(t, store.Close()) })
+ payloadKey := index.FieldKey{TagName: "payload"}
+ require.NoError(t, store.InsertSeriesBatch(index.Batch{Documents: []index.Document{
+ {EntityValues: []byte("first"), Fields: []index.Field{field(payloadKey, []byte(strings.Repeat("a", 4096)), false)}},
+ {EntityValues: []byte("second"), Fields: []index.Field{field(payloadKey, []byte(strings.Repeat("b", 4096)), false)}},
+ }}))
+ searchQuery, buildErr := store.BuildQuery([]index.SeriesMatcher{
+ {Type: index.SeriesMatcherTypeExact, Match: []byte("first")},
+ {Type: index.SeriesMatcherTypeExact, Match: []byte("second")},
+ }, nil, nil)
+ require.NoError(t, buildErr)
+ budget := protector.NewQueryBudget(nil)
+ for _, limit := range []int{math.MaxInt32, math.MaxUint32} {
+ t.Run(stringLimitName(limit), func(t *testing.T) {
+ ctx, release, admitErr := budget.AdmitScanContext(context.Background())
+ require.NoError(t, admitErr)
+ result, searchErr := store.Search(ctx, []index.FieldKey{payloadKey}, searchQuery, limit)
+ release()
+ require.NoError(t, searchErr)
+ require.Len(t, result, 2)
+ require.Zero(t, budget.Reserved())
+ })
+ }
+ t.Run("byte exhaustion returns no partial result", func(t *testing.T) {
+ ctx, release, admitErr := budget.AdmitScanContext(context.Background())
+ require.NoError(t, admitErr)
+ defer release()
+ lease, found := querypkg.BudgetLeaseFromContext(ctx)
+ require.True(t, found)
+ require.NoError(t, querypkg.Charge(ctx, lease.Limit()-lease.Used()-2048))
+ result, searchErr := store.Search(ctx, []index.FieldKey{payloadKey}, searchQuery, math.MaxInt32)
+ require.ErrorIs(t, searchErr, protector.ErrQueryResourceExhausted)
+ require.Nil(t, result)
+ })
+ t.Run("result ceiling returns no partial result", func(t *testing.T) {
+ ctx, release, admitErr := budget.AdmitScanContext(context.Background())
+ require.NoError(t, admitErr)
+ defer release()
+ // Consume count credits only; do not allocate a large fixture.
+ for count := 0; count < 99999; count++ {
+ require.NoError(t, querypkg.ChargeResult(ctx, 0))
+ }
+ result, searchErr := store.Search(ctx, []index.FieldKey{payloadKey}, searchQuery, math.MaxInt32)
+ require.ErrorIs(t, searchErr, protector.ErrQueryResourceExhausted)
+ require.Nil(t, result)
+ })
+}
+
+func stringLimitName(limit int) string {
+ if limit == math.MaxInt32 {
+ return "signed maximum"
+ }
+ return "unsigned maximum"
+}
diff --git a/pkg/query/budget.go b/pkg/query/budget.go
new file mode 100644
index 000000000..df7faca45
--- /dev/null
+++ b/pkg/query/budget.go
@@ -0,0 +1,88 @@
+// Licensed to the 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. The 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 query
+
+import (
+ "context"
+
+ "google.golang.org/protobuf/proto"
+)
+
+// Charge accounts bytes against the active query lease before an allocation.
+// Calls outside admitted query contexts are intentionally no-ops.
+func Charge(ctx context.Context, bytes uint64) error {
+ if bytes == 0 {
+ return nil
+ }
+ lease, ok := BudgetLeaseFromContext(ctx)
+ if !ok {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return lease.Charge(bytes)
+}
+
+// ChargeResult accounts one retained result and its bytes before allocation.
+// Leases without result accounting retain byte-only charging; unadmitted calls are no-ops.
+func ChargeResult(ctx context.Context, bytes uint64) error {
+ lease, ok := BudgetLeaseFromContext(ctx)
+ if !ok {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if resultLease, supportsResults := lease.(interface{ ChargeResult(uint64) error }); supportsResults {
+ return resultLease.ChargeResult(bytes)
+ }
+ return lease.Charge(bytes)
+}
+
+// BudgetLease is a query-owned byte reservation that can charge subsequent
+// allocations before they occur.
+type BudgetLease interface {
+ Charge(uint64) error
+ Limit() uint64
+ Used() uint64
+ Owner() any
+}
+
+type budgetLeaseContextKey struct{}
+
+// WithBudgetLease attaches a lease to a query context.
+func WithBudgetLease(ctx context.Context, lease BudgetLease) context.Context {
+ return context.WithValue(ctx, budgetLeaseContextKey{}, lease)
+}
+
+// BudgetLeaseFromContext returns the lease attached to ctx, if any.
+func BudgetLeaseFromContext(ctx context.Context) (BudgetLease, bool) {
+ lease, ok := ctx.Value(budgetLeaseContextKey{}).(BudgetLease)
+ return lease, ok
+}
+
+// ChargeResponse reserves estimated encoding and transport-copy space before a
+// protobuf response is serialized. Non-protobuf replies have no payload charge.
+func ChargeResponse(ctx context.Context, response any) error {
+ if _, admitted := BudgetLeaseFromContext(ctx); !admitted {
+ return nil
+ }
+ message, isProto := response.(proto.Message)
+ if !isProto {
+ return nil
+ }
+ const encodingCopies = 2
+ return Charge(ctx, uint64(proto.Size(message))*encodingCopies)
+}
diff --git a/pkg/query/budget_result_test.go b/pkg/query/budget_result_test.go
new file mode 100644
index 000000000..746610b64
--- /dev/null
+++ b/pkg/query/budget_result_test.go
@@ -0,0 +1,58 @@
+// Licensed to the 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. The 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 query
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+type byteOnlyResultTestLease struct {
+ used uint64
+}
+
+func (lease *byteOnlyResultTestLease) Charge(bytes uint64) error {
+ lease.used += bytes
+ return nil
+}
+
+func (*byteOnlyResultTestLease) Limit() uint64 { return 1024 }
+func (lease *byteOnlyResultTestLease) Used() uint64 { return lease.used }
+func (lease *byteOnlyResultTestLease) Owner() any { return lease }
+
+func TestChargeResultFallsBackToByteOnlyLease(t *testing.T) {
+ lease := &byteOnlyResultTestLease{}
+ ctx := WithBudgetLease(context.Background(), lease)
+ if err := ChargeResult(ctx, 17); err != nil {
+ t.Fatal(err)
+ }
+ if lease.Used() != 17 {
+ t.Fatalf("byte-only lease was not charged: %d", lease.Used())
+ }
+ canceledCtx, cancel := context.WithCancel(ctx)
+ cancel()
+ if err := ChargeResult(canceledCtx, 13); !errors.Is(err, context.Canceled) {
+ t.Fatalf("canceled result charge: %v", err)
+ }
+ if lease.Used() != 17 {
+ t.Fatal("canceled result consumed budget")
+ }
+}
+
+func TestChargeResultWithoutAdmissionIsNoop(t *testing.T) {
+ if err := ChargeResult(context.Background(), ^uint64(0)); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/pkg/query/limit.go b/pkg/query/limit.go
new file mode 100644
index 000000000..c78690c0d
--- /dev/null
+++ b/pkg/query/limit.go
@@ -0,0 +1,30 @@
+// Licensed to the 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. The 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 query
+
+import "math"
+
+// AddWindow adds a result limit and offset without uint32 overflow.
+func AddWindow(limit, offset uint32) (uint64, bool) {
+ window := uint64(limit) + uint64(offset)
+ return window, window <= uint64(^uint32(0))
+}
+
+// IsUnboundedLimit reports historical list-all / max-limit sentinels with no
+// offset. OAP uses Integer.MAX_VALUE (MaxInt32); some fixtures use MaxUint32.
+// Callers should admit these as scans and bound retained results incrementally
+// instead of pre-reserving the window.
+func IsUnboundedLimit(limit, offset uint32) bool {
+ return offset == 0 && (limit == math.MaxUint32 || limit == math.MaxInt32)
+}
diff --git a/pkg/query/limit_test.go b/pkg/query/limit_test.go
new file mode 100644
index 000000000..9745cee11
--- /dev/null
+++ b/pkg/query/limit_test.go
@@ -0,0 +1,68 @@
+// Licensed to the 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. The 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 query
+
+import (
+ "math"
+ "testing"
+)
+
+func TestAddWindowRejectsUint32Overflow(t *testing.T) {
+ if window, valid := AddWindow(^uint32(0), 1); valid || window != uint64(^uint32(0))+1 {
+ t.Fatalf("unexpected overflow result %d %v", window, valid)
+ }
+}
+
+// Combined limit+offset must be computed in uint64 so MaxUint32+1 is detected
+// instead of wrapping to 0.
+func TestAddWindowOversizedPatterns(t *testing.T) {
+ cases := []struct {
+ name string
+ limit, offset uint32
+ wantValid bool
+ wantWindow uint64
+ }{
+ {name: "max_limit_zero_offset", limit: ^uint32(0), offset: 0, wantValid: true, wantWindow: uint64(^uint32(0))},
+ {name: "one_plus_max_offset_overflow", limit: 1, offset: ^uint32(0), wantValid: false, wantWindow: uint64(^uint32(0)) + 1},
+ {name: "max_limit_plus_one_overflow", limit: ^uint32(0), offset: 1, wantValid: false, wantWindow: uint64(^uint32(0)) + 1},
+ {name: "ordinary_window", limit: 20, offset: 5, wantValid: true, wantWindow: 25},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ window, valid := AddWindow(tc.limit, tc.offset)
+ if valid != tc.wantValid || window != tc.wantWindow {
+ t.Fatalf("AddWindow(%d,%d)=(%d,%v), want (%d,%v)",
+ tc.limit, tc.offset, window, valid, tc.wantWindow, tc.wantValid)
+ }
+ })
+ }
+}
+
+func TestIsUnboundedLimit(t *testing.T) {
+ if !IsUnboundedLimit(^uint32(0), 0) {
+ t.Fatal("MaxUint32 with zero offset is the list-all sentinel")
+ }
+ if !IsUnboundedLimit(math.MaxInt32, 0) {
+ t.Fatal("MaxInt32 with zero offset is the OAP list-all sentinel")
+ }
+ if IsUnboundedLimit(^uint32(0), 1) {
+ t.Fatal("MaxUint32 with offset must not be treated as unbounded")
+ }
+ if IsUnboundedLimit(math.MaxInt32, 1) {
+ t.Fatal("MaxInt32 with offset must not be treated as unbounded")
+ }
+ if IsUnboundedLimit(100001, 0) {
+ t.Fatal("finite oversized windows are not the list-all sentinel")
+ }
+}
diff --git a/pkg/query/logical/stream/stream_analyzer.go b/pkg/query/logical/stream/stream_analyzer.go
index d2e7a0a87..9ba7ec409 100644
--- a/pkg/query/logical/stream/stream_analyzer.go
+++ b/pkg/query/logical/stream/stream_analyzer.go
@@ -24,6 +24,7 @@ import (
commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ "github.com/apache/skywalking-banyandb/pkg/query"
"github.com/apache/skywalking-banyandb/pkg/query/executor"
"github.com/apache/skywalking-banyandb/pkg/query/logical"
)
@@ -83,6 +84,10 @@ func Analyze(criteria *streamv1.QueryRequest, metadata []*commonv1.Metadata, ss
if limitParameter == 0 {
limitParameter = defaultLimit
}
+ window, validWindow := query.AddWindow(limitParameter, criteria.GetOffset())
+ if !validWindow {
+ return nil, fmt.Errorf("query limit and offset exceed supported range")
+ }
plan = newLimit(plan, criteria.GetOffset(), limitParameter)
p, err := plan.Analyze(s)
@@ -91,7 +96,7 @@ func Analyze(criteria *streamv1.QueryRequest, metadata []*commonv1.Metadata, ss
}
rules := []logical.OptimizeRule{
logical.NewPushDownOrder(criteria.OrderBy),
- logical.NewPushDownMaxSize(int(limitParameter + criteria.GetOffset())),
+ logical.NewPushDownMaxSize(int(window)),
}
if err := logical.ApplyRules(p, rules...); err != nil {
return nil, err
@@ -118,6 +123,9 @@ func DistributedAnalyze(criteria *streamv1.QueryRequest, ss []logical.Schema) (l
if limitParameter == 0 {
limitParameter = defaultLimit
}
+ if _, validWindow := query.AddWindow(limitParameter, criteria.GetOffset()); !validWindow {
+ return nil, fmt.Errorf("query limit and offset exceed supported range")
+ }
plan = newDistributedLimit(plan, criteria.Offset, limitParameter)
return plan.Analyze(s)
}
diff --git a/pkg/query/logical/stream/stream_plan_distributed.go b/pkg/query/logical/stream/stream_plan_distributed.go
index da9f44a05..9cee2b1c5 100644
--- a/pkg/query/logical/stream/stream_plan_distributed.go
+++ b/pkg/query/logical/stream/stream_plan_distributed.go
@@ -73,12 +73,16 @@ func (ud *unresolvedDistributed) Analyze(s logical.Schema) (logical.Plan, error)
if limit == 0 {
limit = defaultLimit
}
+ window, validWindow := query.AddWindow(limit, ud.originalQuery.Offset)
+ if !validWindow {
+ return nil, fmt.Errorf("query limit and offset exceed supported range")
+ }
temp := &streamv1.QueryRequest{
Projection: ud.originalQuery.Projection,
Name: ud.originalQuery.Name,
Groups: ud.originalQuery.Groups,
Criteria: ud.originalQuery.Criteria,
- Limit: limit + ud.originalQuery.Offset,
+ Limit: uint32(window),
OrderBy: ud.originalQuery.OrderBy,
}
if ud.originalQuery.OrderBy == nil {
diff --git a/pkg/query/logical/trace/trace_analyzer.go b/pkg/query/logical/trace/trace_analyzer.go
index d84312e1b..3b2bc539a 100644
--- a/pkg/query/logical/trace/trace_analyzer.go
+++ b/pkg/query/logical/trace/trace_analyzer.go
@@ -24,6 +24,7 @@ import (
commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
"github.com/apache/skywalking-banyandb/pkg/iter"
+ "github.com/apache/skywalking-banyandb/pkg/query"
"github.com/apache/skywalking-banyandb/pkg/query/executor"
"github.com/apache/skywalking-banyandb/pkg/query/logical"
"github.com/apache/skywalking-banyandb/pkg/query/model"
@@ -84,6 +85,10 @@ func Analyze(criteria *tracev1.QueryRequest, metadata []*commonv1.Metadata, ss [
if limitParameter == 0 {
limitParameter = defaultLimit
}
+ window, validWindow := query.AddWindow(limitParameter, criteria.GetOffset())
+ if !validWindow {
+ return nil, fmt.Errorf("query limit and offset exceed supported range")
+ }
plan = newTraceLimit(plan, criteria.GetOffset(), limitParameter)
p, err := plan.Analyze(s)
@@ -92,7 +97,7 @@ func Analyze(criteria *tracev1.QueryRequest, metadata []*commonv1.Metadata, ss [
}
rules := []logical.OptimizeRule{
logical.NewPushDownOrder(criteria.OrderBy),
- logical.NewPushDownMaxSize(int(limitParameter + criteria.GetOffset())),
+ logical.NewPushDownMaxSize(int(window)),
}
if err := logical.ApplyRules(p, rules...); err != nil {
return nil, err
@@ -119,6 +124,9 @@ func DistributedAnalyze(criteria *tracev1.QueryRequest, ss []logical.Schema) (lo
if limitParameter == 0 {
limitParameter = defaultLimit
}
+ if _, validWindow := query.AddWindow(limitParameter, criteria.GetOffset()); !validWindow {
+ return nil, fmt.Errorf("query limit and offset exceed supported range")
+ }
plan = newDistributedTraceLimit(plan, criteria.Offset, limitParameter)
return plan.Analyze(s)
}
diff --git a/pkg/query/logical/trace/trace_plan_distributed.go b/pkg/query/logical/trace/trace_plan_distributed.go
index 902fb6d31..aa4d5b488 100644
--- a/pkg/query/logical/trace/trace_plan_distributed.go
+++ b/pkg/query/logical/trace/trace_plan_distributed.go
@@ -71,12 +71,16 @@ func (t *unresolvedTraceDistributed) Analyze(s logical.Schema) (logical.Plan, er
if limit == 0 {
limit = defaultLimit
}
+ window, validWindow := query.AddWindow(limit, t.originalQuery.Offset)
+ if !validWindow {
+ return nil, fmt.Errorf("query limit and offset exceed supported range")
+ }
temp := &tracev1.QueryRequest{
TagProjection: t.originalQuery.TagProjection,
Name: t.originalQuery.Name,
Groups: t.originalQuery.Groups,
Criteria: t.originalQuery.Criteria,
- Limit: limit + t.originalQuery.Offset,
+ Limit: uint32(window),
OrderBy: t.originalQuery.OrderBy,
}
if t.originalQuery.OrderBy == nil {
diff --git a/pkg/query/transport_scope.go b/pkg/query/transport_scope.go
new file mode 100644
index 000000000..16f65b3d7
--- /dev/null
+++ b/pkg/query/transport_scope.go
@@ -0,0 +1,82 @@
+// Licensed to the 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. The 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 query
+
+import (
+ "context"
+ "sync"
+)
+
+type transportScopeKey struct{}
+
+// TransportScope retains query reservations until a transport has finished
+// serializing and sending its response.
+type TransportScope struct {
+ releases []func()
+ mu sync.Mutex
+ closed bool
+}
+
+// NewTransportScope returns a context carrying a scope and the scope itself.
+func NewTransportScope(ctx context.Context) (context.Context, *TransportScope) {
+ scope := &TransportScope{}
+ return context.WithValue(ctx, transportScopeKey{}, scope), scope
+}
+
+// RegisterTransportRelease registers release with the transport scope in ctx.
+// If no scope is present, release is returned unchanged. When a scope exists,
+// the returned function is safe to defer but the scope owns the actual release.
+func RegisterTransportRelease(ctx context.Context, release func()) func() {
+ if release == nil {
+ return nil
+ }
+ scope, ok := ctx.Value(transportScopeKey{}).(*TransportScope)
+ if !ok {
+ return release
+ }
+ scope.mu.Lock()
+ if scope.closed {
+ scope.mu.Unlock()
+ release()
+ return func() {}
+ }
+ scope.releases = append(scope.releases, release)
+ scope.mu.Unlock()
+ return func() {}
+}
+
+// TransportScopeFromContext returns the transport scope attached to ctx.
+func TransportScopeFromContext(ctx context.Context) (*TransportScope, bool) {
+ scope, ok := ctx.Value(transportScopeKey{}).(*TransportScope)
+ return scope, ok
+}
+
+// Close releases all reservations registered with the scope exactly once.
+func (scope *TransportScope) Close() {
+ if scope == nil {
+ return
+ }
+ scope.mu.Lock()
+ if scope.closed {
+ scope.mu.Unlock()
+ return
+ }
+ scope.closed = true
+ releases := scope.releases
+ scope.releases = nil
+ scope.mu.Unlock()
+ for _, release := range releases {
+ release()
+ }
+}
diff --git a/pkg/query/transport_scope_test.go b/pkg/query/transport_scope_test.go
new file mode 100644
index 000000000..7cd6db8e2
--- /dev/null
+++ b/pkg/query/transport_scope_test.go
@@ -0,0 +1,41 @@
+// Licensed to the 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. The 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 query
+
+import (
+ "context"
+ "sync/atomic"
+ "testing"
+)
+
+func TestTransportScopeClosesRegisteredReleasesOnce(t *testing.T) {
+ ctx, scope := NewTransportScope(context.Background())
+ var released atomic.Int32
+ release := RegisterTransportRelease(ctx, func() { released.Add(1) })
+ release()
+ scope.Close()
+ scope.Close()
+ if got := released.Load(); got != 1 {
+ t.Fatalf("release count = %d, want 1", got)
+ }
+}
+
+func TestRegisterTransportReleaseWithoutScopeReturnsRelease(t *testing.T) {
+ var released atomic.Int32
+ release := RegisterTransportRelease(context.Background(), func() { released.Add(1) })
+ release()
+ if got := released.Load(); got != 1 {
+ t.Fatalf("release count = %d, want 1", got)
+ }
+}
diff --git a/pkg/query/vectorized/retained_bytes.go b/pkg/query/vectorized/retained_bytes.go
new file mode 100644
index 000000000..aabe1b0c2
--- /dev/null
+++ b/pkg/query/vectorized/retained_bytes.go
@@ -0,0 +1,116 @@
+// Licensed to the 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. The 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 vectorized
+
+import (
+ "math"
+ "unsafe"
+
+ "google.golang.org/protobuf/proto"
+
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// EstimatedRetainedBytes estimates row references, column capacity and payloads
+// retained by a merge operator. Protobuf object overhead is estimated from wire
+// size; this is not an exact Go heap measurement. Shared payloads may be counted twice.
+func EstimatedRetainedBytes(batch *RecordBatch) uint64 {
+ if batch == nil {
+ return 0
+ }
+ total := saturatingAdd(64, saturatingMultiply(uint64(batch.ActiveLen()), 32))
+ total = saturatingAdd(total, saturatingMultiply(uint64(cap(batch.Columns)), 16))
+ total = saturatingAdd(total, saturatingMultiply(uint64(cap(batch.Selection)), 2))
+ for _, column := range batch.Columns {
+ switch column.Type() {
+ case ColumnTypeInt64:
+ total = saturatingAdd(total, retainedColumnBytes(column.(*TypedColumn[int64])))
+ case ColumnTypeFloat64:
+ total = saturatingAdd(total, retainedColumnBytes(column.(*TypedColumn[float64])))
+ case ColumnTypeString:
+ typed := column.(*TypedColumn[string])
+ total = saturatingAdd(total, retainedColumnBytes(typed))
+ for _, value := range typed.Data() {
+ total = saturatingAdd(total, uint64(len(value)))
+ }
+ case ColumnTypeBytes:
+ typed := column.(*TypedColumn[[]byte])
+ total = saturatingAdd(total, retainedColumnBytes(typed))
+ for _, value := range typed.Data() {
+ total = saturatingAdd(total, uint64(cap(value)))
+ }
+ case ColumnTypeInt64Array:
+ typed := column.(*TypedColumn[[]int64])
+ total = saturatingAdd(total, retainedColumnBytes(typed))
+ for _, value := range typed.Data() {
+ total = saturatingAdd(total, saturatingMultiply(uint64(cap(value)), 8))
+ }
+ case ColumnTypeStrArray:
+ typed := column.(*TypedColumn[[]string])
+ total = saturatingAdd(total, retainedColumnBytes(typed))
+ for _, arrayValues := range typed.Data() {
+ total = saturatingAdd(total, saturatingMultiply(uint64(cap(arrayValues)), 16))
+ for _, value := range arrayValues {
+ total = saturatingAdd(total, uint64(len(value)))
+ }
+ }
+ case ColumnTypeTagValue:
+ typed := column.(*TypedColumn[*modelv1.TagValue])
+ total = saturatingAdd(total, retainedColumnBytes(typed))
+ for _, value := range typed.Data() {
+ if value != nil {
+ total = saturatingAdd(total, estimatedProtoBytes(value))
+ }
+ }
+ case ColumnTypeFieldValue:
+ typed := column.(*TypedColumn[*modelv1.FieldValue])
+ total = saturatingAdd(total, retainedColumnBytes(typed))
+ for _, value := range typed.Data() {
+ if value != nil {
+ total = saturatingAdd(total, estimatedProtoBytes(value))
+ }
+ }
+ default:
+ return math.MaxUint64
+ }
+ }
+ return total
+}
+
+func retainedColumnBytes[T any](column *TypedColumn[T]) uint64 {
+ var element T
+ backing := saturatingMultiply(uint64(cap(column.data)), uint64(unsafe.Sizeof(element)))
+ validity := saturatingMultiply(uint64(cap(column.validity.bits)), 8)
+ return saturatingAdd(uint64(unsafe.Sizeof(*column)), saturatingAdd(backing, validity))
+}
+
+func estimatedProtoBytes(message proto.Message) uint64 {
+ const objectExpansion = 8
+ const objectOverhead = 256
+ return saturatingAdd(saturatingMultiply(uint64(proto.Size(message)), objectExpansion), objectOverhead)
+}
+
+func saturatingAdd(left, right uint64) uint64 {
+ if math.MaxUint64-left < right {
+ return math.MaxUint64
+ }
+ return left + right
+}
+
+func saturatingMultiply(left, right uint64) uint64 {
+ if right != 0 && left > math.MaxUint64/right {
+ return math.MaxUint64
+ }
+ return left * right
+}
diff --git a/pkg/query/vectorized/retained_bytes_test.go b/pkg/query/vectorized/retained_bytes_test.go
new file mode 100644
index 000000000..13cc71f19
--- /dev/null
+++ b/pkg/query/vectorized/retained_bytes_test.go
@@ -0,0 +1,26 @@
+// Licensed to the 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. The 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 vectorized
+
+import "testing"
+
+func TestEstimatedRetainedBytesIncludesByteCapacity(t *testing.T) {
+ column := NewBytesColumn(1)
+ backing := make([]byte, 1, 1<<20)
+ column.Append(backing)
+ batch := &RecordBatch{Columns: []Column{column}, Len: 1}
+ if estimated := EstimatedRetainedBytes(batch); estimated < 1<<20 {
+ t.Fatalf("estimated retained bytes %d did not include backing capacity", estimated)
+ }
+}
diff --git a/pkg/query/vectorized/stream/sorted_merge.go b/pkg/query/vectorized/stream/sorted_merge.go
index 815368482..aa8cd986a 100644
--- a/pkg/query/vectorized/stream/sorted_merge.go
+++ b/pkg/query/vectorized/stream/sorted_merge.go
@@ -23,6 +23,7 @@ import (
"fmt"
"sort"
+ "github.com/apache/skywalking-banyandb/pkg/query"
"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
)
@@ -130,13 +131,17 @@ func (s *SortedMerge) OutputSchema() *vectorized.BatchSchema {
// buffer back to the in-order top-N once it outgrows the prune threshold. The
// batch is retained (not copied); callers must not recycle a consumed batch
// until this operator is closed.
-func (s *SortedMerge) Consume(_ context.Context, batch *vectorized.RecordBatch) error {
+func (s *SortedMerge) Consume(ctx context.Context, batch *vectorized.RecordBatch) error {
if batch == nil || batch.ActiveLen() == 0 {
return nil
}
if batch.Schema != s.schema {
return fmt.Errorf("SortedMerge: foreign batch schema")
}
+ chargeBytes := vectorized.EstimatedRetainedBytes(batch)
+ if chargeErr := query.Charge(ctx, chargeBytes); chargeErr != nil {
+ return chargeErr
+ }
if batch.Selection == nil {
for row := 0; row < batch.Len; row++ {
s.rows = append(s.rows, mergeRowRef{batch: batch, row: row, arrive: s.arriveSeq})
diff --git a/pkg/query/vectorized/stream/sorted_merge_test.go b/pkg/query/vectorized/stream/sorted_merge_test.go
index 67c323445..2a98df763 100644
--- a/pkg/query/vectorized/stream/sorted_merge_test.go
+++ b/pkg/query/vectorized/stream/sorted_merge_test.go
@@ -26,6 +26,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/apache/skywalking-banyandb/pkg/convert"
+ "github.com/apache/skywalking-banyandb/pkg/query"
"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
)
@@ -38,6 +39,36 @@ func mergePipeline(t *testing.T, source *staticBatchSource, desc bool, batchSize
return tss
}
+type tinyMergeLease struct{ remaining uint64 }
+
+func (l *tinyMergeLease) Charge(bytes uint64) error {
+ if bytes > l.remaining {
+ return fmt.Errorf("budget exhausted")
+ }
+ l.remaining -= bytes
+ return nil
+}
+func (*tinyMergeLease) Limit() uint64 { return 1 << 20 }
+func (l *tinyMergeLease) Used() uint64 { return (1 << 20) - l.remaining }
+func (*tinyMergeLease) Owner() any { return "merge-test" }
+
+func TestSortedMergeChargesRetainedVariablePayloadBeforeRows(t *testing.T) {
+ schema := idxSchema()
+ large := buildBatch(schema, []testRow{{ts: 1, elemID: 1, tag: string(make([]byte, 1<<20))}})
+ merge := NewSortedMerge(schema, false, 8)
+ require.NoError(t, merge.Init(context.Background()))
+ ctx := query.WithBudgetLease(context.Background(), &tinyMergeLease{remaining: 1024})
+ require.Error(t, merge.Consume(ctx, large))
+ require.Empty(t, merge.rows)
+
+ small := buildBatch(schema, []testRow{{ts: 1, elemID: 1, tag: "small"}})
+ merge = NewSortedMerge(schema, false, 8)
+ require.NoError(t, merge.Init(context.Background()))
+ ctx = query.WithBudgetLease(context.Background(), &tinyMergeLease{remaining: 1024})
+ require.NoError(t, merge.Consume(ctx, small))
+ require.Len(t, merge.rows, 1)
+}
+
func TestSortedMergeTimeOrderAscending(t *testing.T) {
schema := tsSchema()
b1 := buildBatch(schema, []testRow{{ts: 10, elemID: 1}, {ts: 30, elemID: 3}, {ts: 50, elemID: 5}})
diff --git a/pkg/test/helpers/constant.go b/pkg/test/helpers/constant.go
index a391c9d8c..8706b1308 100644
--- a/pkg/test/helpers/constant.go
+++ b/pkg/test/helpers/constant.go
@@ -22,6 +22,7 @@ import (
"github.com/onsi/gomega"
grpclib "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -74,6 +75,7 @@ type Args struct {
Stages []string
Offset time.Duration
Duration time.Duration
+ WantErrCode codes.Code
WantEmpty bool
WantErr bool
DisOrder bool
diff --git a/test/cases/property/data/input/all_max_int.ql b/test/cases/property/data/input/all_max_int.ql
new file mode 100644
index 000000000..70a65aa54
--- /dev/null
+++ b/test/cases/property/data/input/all_max_int.ql
@@ -0,0 +1,19 @@
+# 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.
+
+
+SELECT menu_name, configuration, update_time FROM PROPERTY ui_menu IN sw LIMIT 2147483647
diff --git a/test/cases/property/data/input/all_max_int.yaml b/test/cases/property/data/input/all_max_int.yaml
new file mode 100644
index 000000000..ba38df8d6
--- /dev/null
+++ b/test/cases/property/data/input/all_max_int.yaml
@@ -0,0 +1,24 @@
+# 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.
+
+name: "ui_menu"
+groups: ["sw"]
+tag_projection:
+ - menu_name
+ - configuration
+ - update_time
+limit: 2147483647
diff --git a/test/cases/property/data/input/all_max_uint.ql b/test/cases/property/data/input/all_max_uint.ql
new file mode 100644
index 000000000..d8387f85c
--- /dev/null
+++ b/test/cases/property/data/input/all_max_uint.ql
@@ -0,0 +1,19 @@
+# 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.
+
+
+SELECT menu_name, configuration, update_time FROM PROPERTY ui_menu IN sw LIMIT 4294967295
diff --git a/test/cases/property/data/input/all_max_uint.yaml b/test/cases/property/data/input/all_max_uint.yaml
new file mode 100644
index 000000000..12fff6017
--- /dev/null
+++ b/test/cases/property/data/input/all_max_uint.yaml
@@ -0,0 +1,24 @@
+# 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.
+
+name: "ui_menu"
+groups: ["sw"]
+tag_projection:
+ - menu_name
+ - configuration
+ - update_time
+limit: 4294967295
diff --git a/test/cases/property/property.go b/test/cases/property/property.go
index 985247591..d35cb21d4 100644
--- a/test/cases/property/property.go
+++ b/test/cases/property/property.go
@@ -40,6 +40,8 @@ var (
var propertyEntries = []any{
g.Entry("all", helpers.Args{Input: "all"}),
+ g.Entry("all with large signed limit", helpers.Args{Input: "all_max_int", Want: "all"}),
+ g.Entry("all with large unsigned limit", helpers.Args{Input: "all_max_uint", Want: "all"}),
g.Entry("limit", helpers.Args{Input: "limit"}),
g.Entry("query by criteria", helpers.Args{Input: "query_by_criteria"}),
g.Entry("query by criteria with bound parameters", helpers.Args{Input: "params_bind", Want: "query_by_criteria"}),
diff --git a/test/cases/stream/data/data.go b/test/cases/stream/data/data.go
index 43dad13c2..602ebbfd6 100644
--- a/test/cases/stream/data/data.go
+++ b/test/cases/stream/data/data.go
@@ -37,6 +37,8 @@ import (
gm "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
grpclib "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -100,8 +102,9 @@ var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args
defer cancel()
resp, err := c.Query(ctx, query)
if args.WantErr {
- if err == nil {
- g.Fail("expect error")
+ innerGm.Expect(err).To(gm.HaveOccurred())
+ if args.WantErrCode != codes.OK {
+ innerGm.Expect(status.Code(err)).To(gm.Equal(args.WantErrCode))
}
return
}
@@ -265,7 +268,7 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *stream
transformCtx, transformCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer transformCancel()
transform, err := transformer.Transform(transformCtx, query)
- if args.WantErr && err != nil {
+ if args.WantErr && args.WantErrCode == codes.OK && err != nil {
return
}
innerGm.Expect(err).NotTo(gm.HaveOccurred())
@@ -292,6 +295,9 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *stream
})
if args.WantErr {
innerGm.Expect(err).To(gm.HaveOccurred())
+ if args.WantErrCode != codes.OK {
+ innerGm.Expect(status.Code(err)).To(gm.Equal(args.WantErrCode))
+ }
return
}
innerGm.Expect(err).NotTo(gm.HaveOccurred())
diff --git a/test/cases/stream/data/input/all_above_window.ql b/test/cases/stream/data/input/all_above_window.ql
new file mode 100644
index 000000000..b0e3c197b
--- /dev/null
+++ b/test/cases/stream/data/input/all_above_window.ql
@@ -0,0 +1,20 @@
+# 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.
+
+SELECT trace_id, data_binary FROM STREAM sw IN default
+TIME > '-15m'
+LIMIT 100001
diff --git a/test/cases/stream/data/input/all_above_window.yaml b/test/cases/stream/data/input/all_above_window.yaml
new file mode 100644
index 000000000..aa6823c59
--- /dev/null
+++ b/test/cases/stream/data/input/all_above_window.yaml
@@ -0,0 +1,26 @@
+# 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.
+
+name: "sw"
+groups: ["default"]
+projection:
+ tagFamilies:
+ - name: "searchable"
+ tags: ["trace_id"]
+ - name: "data"
+ tags: ["data_binary"]
+limit: 100001
diff --git a/test/cases/stream/stream.go b/test/cases/stream/stream.go
index e1fec1391..c231246e8 100644
--- a/test/cases/stream/stream.go
+++ b/test/cases/stream/stream.go
@@ -24,6 +24,7 @@ import (
g "github.com/onsi/ginkgo/v2"
gm "github.com/onsi/gomega"
+ "google.golang.org/grpc/codes"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/apache/skywalking-banyandb/pkg/test"
@@ -50,6 +51,9 @@ var streamEntries = []any{
g.Entry("projection with http.method", helpers.Args{Input: "all_with_http_method", Duration: 1 * time.Hour}),
g.Entry("limit", helpers.Args{Input: "limit", Duration: 1 * time.Hour}),
g.Entry("max limit", helpers.Args{Input: "all_max_limit", Want: "all", Duration: 1 * time.Hour}),
+ g.Entry("above absolute window", helpers.Args{
+ Input: "all_above_window", WantErr: true, WantErrCode: codes.InvalidArgument, Duration: 1 * time.Hour,
+ }),
g.Entry("offset", helpers.Args{Input: "offset", Duration: 1 * time.Hour}),
g.Entry("order asc", helpers.Args{Input: "order_asc", Duration: 1 * time.Hour}),
g.Entry("order desc", helpers.Args{Input: "order_desc", Duration: 1 * time.Hour}),
diff --git a/test/e2e-v2/cases/cluster/docker-compose.yml b/test/e2e-v2/cases/cluster/docker-compose.yml
index 2b43dfb87..d67599707 100644
--- a/test/e2e-v2/cases/cluster/docker-compose.yml
+++ b/test/e2e-v2/cases/cluster/docker-compose.yml
@@ -20,6 +20,7 @@ services:
extends:
file: ../../script/docker-compose/base-compose.yml
service: data
+ mem_limit: 1536m
command: data --node-discovery-mode=file --node-discovery-file-path=/etc/banyandb/nodes.yaml ${BANYANDB_MEASURE_VECTORIZED_FLAGS:---measure-vectorized-enabled=false} ${BANYANDB_TRACE_VECTORIZED_FLAGS:---trace-vectorized-enabled=false}
volumes:
- ./nodes.yaml:/etc/banyandb/nodes.yaml
@@ -30,6 +31,7 @@ services:
extends:
file: ../../script/docker-compose/base-compose.yml
service: liaison
+ mem_limit: 512m
command: liaison --node-discovery-mode=file --node-discovery-file-path=/etc/banyandb/nodes.yaml ${BANYANDB_MEASURE_VECTORIZED_FLAGS:---measure-vectorized-enabled=false} ${BANYANDB_TRACE_VECTORIZED_FLAGS:---trace-vectorized-enabled=false}
volumes:
- ./nodes.yaml:/etc/banyandb/nodes.yaml
diff --git a/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment-vec.yaml b/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment-vec.yaml
index f62b35ec2..9506a578d 100644
--- a/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment-vec.yaml
+++ b/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment-vec.yaml
@@ -38,6 +38,13 @@ spec:
- standalone
- --measure-vectorized-enabled=true
- --trace-vectorized-enabled=true
+ resources:
+ requests:
+ memory: 512Mi
+ limits:
+ # Bound cgroup memory so the query budget uses a real pool instead of
+ # the 64MiB fallback (Kind pods are otherwise often unlimited).
+ memory: 2Gi
ports:
- name: grpc
containerPort: 17912
diff --git a/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment.yaml b/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment.yaml
index 3f7ea5f80..482185e82 100644
--- a/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment.yaml
+++ b/test/e2e-v2/cases/rover/process/istio/banyandb/banyandb-deployment.yaml
@@ -38,6 +38,13 @@ spec:
- standalone
- --measure-vectorized-enabled=false
- --trace-vectorized-enabled=false
+ resources:
+ requests:
+ memory: 512Mi
+ limits:
+ # Bound cgroup memory so the query budget uses a real pool instead of
+ # the 64MiB fallback (Kind pods are otherwise often unlimited).
+ memory: 2Gi
ports:
- name: grpc
containerPort: 17912
(skywalking-banyandb) 15/15: fix(logger): restore pflag import dropped during cherry-pick
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 0f4aa5e1e063c9c01d704fd06c38c63b0b54c799
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Tue Sep 15 06:34:22 2026 +0000
fix(logger): restore pflag import dropped during cherry-pick
Co-authored-by: Cursor <cu...@cursor.com>
---
pkg/logger/setting.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/pkg/logger/setting.go b/pkg/logger/setting.go
index bfa8429d1..3d31071a2 100644
--- a/pkg/logger/setting.go
+++ b/pkg/logger/setting.go
@@ -28,6 +28,7 @@ import (
"github.com/pkg/errors"
"github.com/rs/zerolog"
+ "github.com/spf13/pflag"
)
const (
(skywalking-banyandb) 13/15: fix(observability): stop liaison gauge from stealing memory_state schema (#1346)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit a1e3c8524c446e96bb43adce9270d8a8df7f2f41
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Tue Sep 15 11:40:09 2026 +0800
fix(observability): stop liaison gauge from stealing memory_state schema (#1346)
(cherry picked from commit a86a15bd73e584d494894556f07f6a7ad8d433b0)
---
banyand/liaison/grpc/metrics.go | 10 +-
.../standalone/observability/measure_helper.go | 37 +++++-
.../observability/native_metrics_test.go | 147 ++++++++++++++++++++-
3 files changed, 182 insertions(+), 12 deletions(-)
diff --git a/banyand/liaison/grpc/metrics.go b/banyand/liaison/grpc/metrics.go
index 710b3e39e..1127b3ca4 100644
--- a/banyand/liaison/grpc/metrics.go
+++ b/banyand/liaison/grpc/metrics.go
@@ -46,7 +46,7 @@ type metrics struct {
memoryLoadSheddingRejections meter.Counter
grpcBufferSize meter.Gauge // Shared gauge for both conn and stream buffer sizes
- memoryState meter.Gauge
+ memoryLoadSheddingState meter.Gauge
// Step 2.7 — schema-barrier observability. The three Await* RPCs each
// emit one histogram observation per call labeled by `result`
@@ -95,7 +95,7 @@ func newMetrics(factory observability.Factory) *metrics {
totalRegistryLatency: factory.NewCounter("total_registry_latency", "group", "service", "method"),
memoryLoadSheddingRejections: factory.NewCounter("memory_load_shedding_rejections_total", "service"),
grpcBufferSize: factory.NewGauge("grpc_buffer_size_bytes", "type"),
- memoryState: factory.NewGauge("memory_state"),
+ memoryLoadSheddingState: factory.NewGauge("memory_load_shedding_state"),
schemaAwaitRevisionAppliedDuration: factory.NewHistogram("schema_await_revision_applied_duration_seconds", meter.DefBuckets, "result"),
schemaAwaitSchemaAppliedDuration: factory.NewHistogram("schema_await_schema_applied_duration_seconds", meter.DefBuckets, "result"),
schemaAwaitSchemaDeletedDuration: factory.NewHistogram("schema_await_schema_deleted_duration_seconds", meter.DefBuckets, "result"),
@@ -120,7 +120,9 @@ func (m *metrics) updateBufferSizeMetrics(connSize, streamSize int32) {
}
}
-// updateMemoryState updates the memory state metric.
+// updateMemoryState updates the memory load-shedding state metric.
+// Named distinctly from the system host gauge "memory_state" so native
+// self-observability does not collide on the _monitoring measure schema.
func (m *metrics) updateMemoryState(state int) {
- m.memoryState.Set(float64(state))
+ m.memoryLoadSheddingState.Set(float64(state))
}
diff --git a/test/integration/standalone/observability/measure_helper.go b/test/integration/standalone/observability/measure_helper.go
index d907bf343..b1cf3f409 100644
--- a/test/integration/standalone/observability/measure_helper.go
+++ b/test/integration/standalone/observability/measure_helper.go
@@ -24,6 +24,8 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
+ 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"
modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
"github.com/apache/skywalking-banyandb/pkg/meter/native"
@@ -35,6 +37,39 @@ type Point struct {
Value float64
}
+var baseEntityTags = []string{"node_type", "node_id", "grpc_address", "http_address"}
+
+// GetObservabilityMeasureTags returns the tag names declared on a _monitoring measure schema.
+func GetObservabilityMeasureTags(metricName string) ([]string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ client := databasev1.NewMeasureRegistryServiceClient(sharedContext.Connection)
+ resp, err := client.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{
+ Group: native.ObservabilityGroupName,
+ Name: metricName,
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("get measure schema %s failed: %w", metricName, err)
+ }
+ measure := resp.GetMeasure()
+ if measure == nil {
+ return nil, fmt.Errorf("get measure schema %s returned nil measure", metricName)
+ }
+ var tags []string
+ for _, family := range measure.GetTagFamilies() {
+ for _, tag := range family.GetTags() {
+ if tag.GetName() == "" {
+ continue
+ }
+ tags = append(tags, tag.GetName())
+ }
+ }
+ return tags, nil
+}
+
// QueryObservabilityMeasure queries a measure from the _monitoring group and returns flattened datapoints.
// tagNames are additional tag names to project beyond the default entity tags (node_type, node_id, grpc_address, http_address).
// For example, cpu_state and memory_state need "kind"; disk needs "path" and "kind".
@@ -56,7 +91,7 @@ func QueryObservabilityMeasure(metricName string, tagNames ...string) ([]Point,
TagFamilies: []*modelv1.TagProjection_TagFamily{
{
Name: "default",
- Tags: append([]string{"node_type", "node_id", "grpc_address", "http_address"}, tagNames...),
+ Tags: append(append([]string{}, baseEntityTags...), tagNames...),
},
},
},
diff --git a/test/integration/standalone/observability/native_metrics_test.go b/test/integration/standalone/observability/native_metrics_test.go
index 5c4f050f6..6b5135713 100644
--- a/test/integration/standalone/observability/native_metrics_test.go
+++ b/test/integration/standalone/observability/native_metrics_test.go
@@ -18,13 +18,114 @@
package observability
import (
+ "fmt"
+ "slices"
"time"
g "github.com/onsi/ginkgo/v2"
gm "github.com/onsi/gomega"
)
+// System host metrics that native self-observability must publish into _monitoring.
+// Extra tags beyond the shared entity tags must match the dashboard query shape.
+var systemNativeMetrics = []struct {
+ name string
+ extraTags []string
+}{
+ {name: "up_time"},
+ {name: "cpu_num"},
+ {name: "cpu_state", extraTags: []string{"kind"}},
+ {name: "memory_state", extraTags: []string{"kind"}},
+ {name: "disk", extraTags: []string{"path", "kind"}},
+ {name: "net_state", extraTags: []string{"kind", "name"}},
+}
+
+var (
+ cpuStateKinds = []string{"user", "system", "idle", "nice", "iowait", "irq", "softirq", "steal"}
+ memoryStateKinds = []string{"used", "total", "used_percent"}
+ diskStateKinds = []string{"used", "total", "used_percent"}
+)
+
var _ = g.Describe("Native self-observability metrics in _monitoring group", func() {
+ g.It("declares the expected system measure schemas and label tags", func() {
+ for _, metric := range systemNativeMetrics {
+ expected := append(append([]string{}, baseEntityTags...), metric.extraTags...)
+ gm.Eventually(func() error {
+ tags, err := GetObservabilityMeasureTags(metric.name)
+ if err != nil {
+ return err
+ }
+ for _, want := range expected {
+ if !slices.Contains(tags, want) {
+ return fmt.Errorf("measure %s missing tag %q; have %v", metric.name, want, tags)
+ }
+ }
+ return nil
+ }, 90*time.Second, 2*time.Second).Should(gm.Succeed(),
+ "measure %s must keep entity tags plus %v", metric.name, metric.extraTags)
+ }
+ })
+
+ g.It("keeps liaison load-shedding memory pressure off the system memory_state measure", func() {
+ // The liaison gauge used to register as "memory_state" and stole the
+ // system schema (no kind tag), breaking dashboard queries that project kind.
+ gm.Eventually(func() error {
+ tags, err := GetObservabilityMeasureTags("memory_load_shedding_state")
+ if err != nil {
+ return err
+ }
+ for _, want := range baseEntityTags {
+ if !slices.Contains(tags, want) {
+ return fmt.Errorf("memory_load_shedding_state missing tag %q; have %v", want, tags)
+ }
+ }
+ return nil
+ }, 90*time.Second, 2*time.Second).Should(gm.Succeed(),
+ "liaison load-shedding must use a distinct measure name")
+
+ tags, err := GetObservabilityMeasureTags("memory_state")
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ gm.Expect(tags).To(gm.ContainElement("kind"),
+ "system memory_state must retain the kind tag after liaison metrics register")
+ })
+
+ g.It("serves dashboard-shaped queries with well-formed label values", func() {
+ gm.Eventually(func() error {
+ if _, upTimeErr := QueryObservabilityMeasure("up_time"); upTimeErr != nil {
+ return upTimeErr
+ }
+ cpuPoints, cpuErr := QueryObservabilityMeasure("cpu_state", "kind")
+ if cpuErr != nil {
+ return cpuErr
+ }
+ if labelErr := requireLabeledSeries(cpuPoints, "kind", cpuStateKinds, true); labelErr != nil {
+ return labelErr
+ }
+ memoryPoints, memoryErr := QueryObservabilityMeasure("memory_state", "kind")
+ if memoryErr != nil {
+ return memoryErr
+ }
+ if labelErr := requireLabeledSeries(memoryPoints, "kind", memoryStateKinds, true); labelErr != nil {
+ return labelErr
+ }
+ diskPoints, diskErr := QueryObservabilityMeasure("disk", "path", "kind")
+ if diskErr != nil {
+ return diskErr
+ }
+ if labelErr := requireLabeledSeries(diskPoints, "kind", diskStateKinds, false); labelErr != nil {
+ return labelErr
+ }
+ if _, cpuNumErr := QueryObservabilityMeasure("cpu_num"); cpuNumErr != nil {
+ return cpuNumErr
+ }
+ // net_state may be empty when the host has no eth*/en* interfaces; schema+query must still succeed.
+ if _, netErr := QueryObservabilityMeasure("net_state", "kind", "name"); netErr != nil {
+ return netErr
+ }
+ return nil
+ }, 90*time.Second, 2*time.Second).Should(gm.Succeed())
+ })
+
g.It("collects up_time metric", func() {
gm.Eventually(func() (float64, error) {
points, err := QueryObservabilityMeasure("up_time")
@@ -70,23 +171,55 @@ var _ = g.Describe("Native self-observability metrics in _monitoring group", fun
}, 90*time.Second, 5*time.Second).Should(gm.BeTrue())
})
- g.It("collects memory_state metric with basic sanity", func() {
- // Note: memory_state may be created by liaison without "kind" tag, so we query with base tags only.
- // Validate that we get at least one datapoint with a positive value (total/used bytes or used_percent).
+ g.It("collects memory_state metric with kind labels", func() {
gm.Eventually(func() (bool, error) {
- points, err := QueryObservabilityMeasure("memory_state")
+ points, err := QueryObservabilityMeasure("memory_state", "kind")
if err != nil {
return false, err
}
if len(points) == 0 {
return false, nil
}
+ seen := map[string]bool{}
for _, p := range points {
- if p.Value > 0 {
- return true, nil
+ kind, ok := p.Tags["kind"]
+ if !ok || !slices.Contains(memoryStateKinds, kind) {
+ return false, nil
}
+ if kind == "used" || kind == "total" {
+ if p.Value <= 0 {
+ return false, nil
+ }
+ }
+ if kind == "used_percent" {
+ if p.Value < 0.0 || p.Value > 1.0 {
+ return false, nil
+ }
+ }
+ seen[kind] = true
}
- return false, nil
+ return seen["used"] && seen["total"] && seen["used_percent"], nil
}, 90*time.Second, 5*time.Second).Should(gm.BeTrue())
})
})
+
+func requireLabeledSeries(points []Point, label string, allowed []string, requireData bool) error {
+ if requireData && len(points) == 0 {
+ return fmt.Errorf("expected datapoints with label %q", label)
+ }
+ for _, p := range points {
+ value, ok := p.Tags[label]
+ if !ok || value == "" {
+ return fmt.Errorf("datapoint missing non-empty %q label: tags=%v", label, p.Tags)
+ }
+ if !slices.Contains(allowed, value) {
+ return fmt.Errorf("unexpected %q=%q; allowed=%v tags=%v", label, value, allowed, p.Tags)
+ }
+ for _, entityTag := range baseEntityTags {
+ if p.Tags[entityTag] == "" {
+ return fmt.Errorf("datapoint missing entity tag %q: tags=%v", entityTag, p.Tags)
+ }
+ }
+ }
+ return nil
+}
(skywalking-banyandb) 14/15: chore(release): add CHANGES.md notes for v0.11.1
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 8aad00ce2b9327a34b97d711914344528306bf2e
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Tue Sep 15 06:30:29 2026 +0000
chore(release): add CHANGES.md notes for v0.11.1
Co-authored-by: Cursor <cu...@cursor.com>
---
CHANGES.md | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/CHANGES.md b/CHANGES.md
index 11bee928e..5d5a167ad 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,24 @@
Release Notes.
+## 0.11.1
+
+### Bug Fixes
+
+- Fix standalone TopN write ordering.
+- Fix stale Canopy query clauses after resource changes.
+- Bound the vectorized stream Top-N merge by limit+offset, and bound the filtered index-order vectorized Top-N merge.
+- Enforce the Canopy readonly role on the `/monitoring/*` proxy the same way as `/api/*`.
+- Fix FODC proxy `/metrics` returning partial data or timing out when concurrent scrapes overlap.
+- Pre-set the logging level before flag parsing and honor the configured logging level in native observability metrics.
+- Enforce trace query time ranges independently of the sort index, skipping row timestamp checks when the query fully covers a part.
+- Retry property schema registry initialization indefinitely, logging an error every 10 attempts.
+- Bound Property, Stream, and Trace query allocations with shared memory admission and capacity hints so oversized limit/offset windows cannot force huge result buffers.
+- Preserve list-all / max-limit queries (`limit=MaxUint32` or OAP `Integer.MAX_VALUE`) with incremental scan admission and result-count accounting, without over-charging Property source payloads against the liaison fallback pool.
+- Register the memory protector on the liaison role so query admission uses the cgroup-backed pool instead of the 64MiB fallback.
+- Reject deeply nested ByDBQL WHERE parentheses to prevent stack overflow.
+- Keep system native `memory_state` (with `kind` labels) from being overwritten by the liaison load-shedding gauge so self-observability dashboard queries succeed.
+
## 0.11.0
### Features
(skywalking-banyandb) 02/15: Fix stale query clauses after Canopy resource changes (#1312)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 393b28d8a382624eaf98e20d37e5c4636b55e79c
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Thu Aug 27 13:54:55 2026 +0800
Fix stale query clauses after Canopy resource changes (#1312)
(cherry picked from commit 8b4e153a159a76389077c02d25dd8575d6a3cf20)
---
canopy/web/src/query/QueryBuilder.test.tsx | 135 ++++++++++++++++++++++++++++-
canopy/web/src/query/QueryBuilder.tsx | 23 ++++-
canopy/web/src/query/QueryConsole.test.tsx | 110 +++++++++++++++++++++++
canopy/web/src/query/QueryConsole.tsx | 28 +++---
canopy/web/src/query/bydbql.test.ts | 23 ++++-
canopy/web/src/query/bydbql.ts | 19 +++-
6 files changed, 319 insertions(+), 19 deletions(-)
diff --git a/canopy/web/src/query/QueryBuilder.test.tsx b/canopy/web/src/query/QueryBuilder.test.tsx
index 41943264b..c3c1bd248 100644
--- a/canopy/web/src/query/QueryBuilder.test.tsx
+++ b/canopy/web/src/query/QueryBuilder.test.tsx
@@ -22,7 +22,8 @@
// where.test.ts / bydbql.test.ts).
import { describe, it, expect, vi } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { useState } from 'react';
import { MemoryRouter } from 'react-router';
import { QueryBuilder, type QueryBuilderProps } from './QueryBuilder.js';
import type { QBBuilderState, QBWhereLeafWithConn } from './bydbql.js';
@@ -75,6 +76,94 @@ function renderBuilder(state: QBBuilderState, onChange = vi.fn()) {
return { onChange };
}
+function staleMeasureState(): QBBuilderState {
+ return {
+ ...stateWithLeaf({ tag: 'old_tag', op: 'BINARY_OP_EQ', value: 'old-value' }),
+ catalog: 'measures',
+ resource: 'old_measure',
+ select: [{ field: 'old_field', fn: 'MEAN' }],
+ projection: ['old_tag'],
+ groupBy: ['old_tag'],
+ fromResource: 'g1/old_measure',
+ };
+}
+
+/** Mirrors #14043: SELECT entity_id carried from a prior resource into Trace zipkin_span. */
+function staleTraceState(): QBBuilderState {
+ return {
+ ...stateWithLeaf({ tag: 'entity_id', op: 'BINARY_OP_EQ', value: 'svc-1' }),
+ catalog: 'traces',
+ group: 'sw_zipkin',
+ resource: 'old_span',
+ select: [],
+ projection: ['entity_id'],
+ groupBy: [],
+ orderField: 'time',
+ fromResource: 'sw_zipkin/old_span',
+ };
+}
+
+function StatefulQueryBuilder({
+ autoPickOnGroupChange,
+ initialState = staleMeasureState(),
+}: {
+ readonly autoPickOnGroupChange?: boolean;
+ readonly initialState?: QBBuilderState;
+}) {
+ const [state, setState] = useState(initialState);
+ const isTrace = state.catalog === 'traces';
+ const tags = isTrace
+ ? (state.resource === 'zipkin_span' ? ['trace_id', 'span_id', 'name'] : ['entity_id', 'trace_id'])
+ : (state.resource === 'new_measure' ? ['new_tag'] : ['old_tag']);
+ const fields = isTrace
+ ? []
+ : (state.resource === 'new_measure' ? ['new_field'] : ['old_field']);
+ const resourceNames = isTrace
+ ? ['old_span', 'zipkin_span']
+ : (state.group === 'g2' ? ['new_measure'] : ['old_measure', 'new_measure']);
+ const groupNames = isTrace ? ['sw_zipkin'] : ['g1', 'g2'];
+ const onChange = (patch: Partial<QBBuilderState>) => {
+ setState((current) => {
+ const next = { ...current, ...patch };
+ return autoPickOnGroupChange && next.group === 'g2' && !next.resource
+ ? { ...next, resource: 'new_measure' }
+ : next;
+ });
+ };
+
+ return (
+ <MemoryRouter>
+ <QueryBuilder
+ state={state}
+ onChange={onChange}
+ tags={tags}
+ fields={fields}
+ groupNames={groupNames}
+ resourceNames={resourceNames}
+ topnAggNames={[]}
+ groups={[]}
+ groupResources={new Map()}
+ groupTopnAggs={new Map()}
+ onPickResource={vi.fn()}
+ isRunning={false}
+ onEjectToCode={vi.fn()}
+ onRun={vi.fn()}
+ hasRun={false}
+ compact={false}
+ setCompact={vi.fn()}
+ openSection={null}
+ setOpenSection={vi.fn()}
+ />
+ </MemoryRouter>
+ );
+}
+
+function queryPreview(container: HTMLElement): HTMLElement {
+ const preview = container.querySelector('.qb-gen-line');
+ if (!preview) throw new Error('Generated query preview was not rendered');
+ return preview as HTMLElement;
+}
+
describe('QueryBuilder WHERE — MATCH analyzer + operator fields', () => {
it('shows the Analyzer + Match operator controls only for the MATCH operator', () => {
renderBuilder(stateWithLeaf({ tag: 'name', op: 'BINARY_OP_MATCH', value: 'nodea' }));
@@ -105,6 +194,50 @@ describe('QueryBuilder WHERE — MATCH analyzer + operator fields', () => {
});
});
+describe('QueryBuilder resource changes', () => {
+ it('clears dependent clauses when the resource dropdown selects a new schema', async () => {
+ const { container } = render(<StatefulQueryBuilder />);
+
+ fireEvent.change(screen.getByLabelText('Resource'), { target: { value: 'new_measure' } });
+
+ await waitFor(() => expect(queryPreview(container)).toHaveTextContent('FROM MEASURE new_measure'));
+ const query = queryPreview(container).getAttribute('title') ?? '';
+ expect(query).toContain('SELECT new_tag');
+ expect(query).not.toContain('old_tag');
+ expect(query).not.toContain('old_field');
+ expect(query).not.toContain('WHERE');
+ expect(query).not.toContain('GROUP BY');
+ });
+
+ it('does not carry clauses into an auto-selected resource after changing groups', async () => {
+ const { container } = render(<StatefulQueryBuilder autoPickOnGroupChange />);
+
+ fireEvent.change(screen.getByLabelText('Group'), { target: { value: 'g2' } });
+
+ await waitFor(() => expect(screen.getByLabelText('Resource')).toHaveValue('new_measure'));
+ const query = queryPreview(container).getAttribute('title') ?? '';
+ expect(query).toContain('SELECT new_tag');
+ expect(query).not.toContain('old_tag');
+ expect(query).not.toContain('old_field');
+ expect(query).not.toContain('WHERE');
+ expect(query).not.toContain('GROUP BY');
+ });
+
+ it('drops a stale SELECT tag when switching Trace resources (#14043)', async () => {
+ const { container } = render(<StatefulQueryBuilder initialState={staleTraceState()} />);
+
+ fireEvent.change(screen.getByLabelText('Resource'), { target: { value: 'zipkin_span' } });
+
+ await waitFor(() => expect(queryPreview(container)).toHaveTextContent('FROM TRACE zipkin_span'));
+ const query = queryPreview(container).getAttribute('title') ?? '';
+ // Empty projection on Trace expands to the new resource's tag list (not SELECT *).
+ expect(query).toContain('SELECT trace_id, span_id, name');
+ expect(query).not.toContain('entity_id');
+ expect(query).toContain('WHERE trace_id');
+ expect(query).not.toContain('ORDER BY');
+ });
+});
+
// ORDER BY resolves to an index-rule NAME server-side, so the dropdown is
// confined to the rules bound to the current resource once they load.
describe('QueryBuilder ORDER BY — index-rule-confined options', () => {
diff --git a/canopy/web/src/query/QueryBuilder.tsx b/canopy/web/src/query/QueryBuilder.tsx
index 9962786e9..6d3aa8d49 100644
--- a/canopy/web/src/query/QueryBuilder.tsx
+++ b/canopy/web/src/query/QueryBuilder.tsx
@@ -29,7 +29,7 @@
import React from 'react';
import {
QB_CATALOGS, QB_CAT, QB_UI_KW, QB_OPS, QB_OP, QB_AGGS, QB_TOPN_OPS, QB_TOPN_AGGS, QB_TIMES, QB_COMBINATORS,
- qbDataCatalog, qbIsGroup, qbNewCond, qbNewGroup, qbEmptyWhere, qbPruneWhere,
+ qbDataCatalog, qbIsGroup, qbNewCond, qbNewGroup, qbEmptyWhere, qbDefaultTraceWhere, qbPruneWhere,
qbSearchIndex, qbSearchResults, qbConnSummary,
buildBydbQL,
type QBWhereNode, type QBWhereLeafWithConn,
@@ -525,8 +525,23 @@ export function QueryBuilder({
fromResource: null,
});
};
- const setGroup = (group: string) => onChange({ group, resource: '' });
- const setResource = (resource: string) => onChange({ resource });
+ // Match onPickResource / fuzzy From pick: Trace resource changes re-seed an
+ // empty trace_id condition (and clear ORDER BY) so the analyzer stays valid.
+ const resetResourceClauses = (): Partial<QBBuilderState> => {
+ const isTrace = state.catalog === 'traces';
+ return {
+ select: [],
+ projection: [],
+ where: isTrace ? qbDefaultTraceWhere() : qbEmptyWhere(),
+ groupBy: [],
+ ...(isTrace ? { orderField: '' as const } : {}),
+ fromAgg: null,
+ fromResource: null,
+ offset: 0,
+ };
+ };
+ const setGroup = (group: string) => onChange({ group, resource: '', ...resetResourceClauses() });
+ const setResource = (resource: string) => onChange({ resource, ...resetResourceClauses() });
const toggleProjection = (tag: string) => {
const cur = state.projection;
const next = cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag];
@@ -1059,4 +1074,4 @@ export function QueryBuilder({
}
// Re-export helper so QueryConsole can build a default state.
-export { qbDataCatalog, qbPruneWhere };
\ No newline at end of file
+export { qbDataCatalog, qbPruneWhere };
diff --git a/canopy/web/src/query/QueryConsole.test.tsx b/canopy/web/src/query/QueryConsole.test.tsx
new file mode 100644
index 000000000..e124dce91
--- /dev/null
+++ b/canopy/web/src/query/QueryConsole.test.tsx
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router';
+
+import { apiDataSource } from '../data/api.js';
+import { QueryConsole } from './QueryConsole.js';
+import type { QBBuilderState } from './bydbql.js';
+
+vi.mock('../data/api.js', () => ({
+ apiDataSource: {
+ listGroups: vi.fn(),
+ listResourcesInGroup: vi.fn(),
+ listTopNAggregations: vi.fn(),
+ listIndexRuleBindings: vi.fn(),
+ listIndexRules: vi.fn(),
+ runQuery: vi.fn(),
+ },
+}));
+
+const GROUPS = [{ name: 'g1', catalog: 'CATALOG_MEASURE', resourceOpts: { shardNum: 1 } }];
+
+const STALE_STATE: QBBuilderState = {
+ catalog: 'measures',
+ group: 'g1',
+ resource: 'old_measure',
+ select: [{ field: 'old_field', fn: 'MEAN' }],
+ projection: ['old_tag'],
+ where: { combinator: 'AND', children: [{ tag: 'old_tag', op: 'BINARY_OP_EQ', value: 'old-value' }] },
+ groupBy: ['old_tag'],
+ time: { mode: 'relative', rel: '-30m', from: '', to: '' },
+ orderField: 'time',
+ orderDir: 'DESC',
+ limit: 100,
+ offset: 0,
+ trace: false,
+ topN: 10,
+ aggFn: '',
+ fromAgg: null,
+ fromResource: 'g1/old_measure',
+};
+
+function makeWrapper() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ function Wrapper({ children }: { readonly children: React.ReactNode }) {
+ return <MemoryRouter><QueryClientProvider client={queryClient}>{children}</QueryClientProvider></MemoryRouter>;
+ }
+ Wrapper.displayName = 'QueryConsoleTestWrapper';
+ return Wrapper;
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ localStorage.setItem('canopy.query.v3', JSON.stringify({ builder: STALE_STATE }));
+ vi.mocked(apiDataSource.listGroups).mockResolvedValue({ groups: GROUPS } as never);
+ vi.mocked(apiDataSource.listResourcesInGroup).mockResolvedValue([
+ {
+ metadata: { name: 'old_measure', group: 'g1' },
+ tagFamilies: [{ tags: [{ name: 'old_tag', type: 'TAG_TYPE_STRING' }] }],
+ fields: [{ name: 'old_field' }],
+ },
+ {
+ metadata: { name: 'new_measure', group: 'g1' },
+ tagFamilies: [{ tags: [{ name: 'new_tag', type: 'TAG_TYPE_STRING' }] }],
+ fields: [{ name: 'new_field' }],
+ },
+ ] as never);
+ vi.mocked(apiDataSource.listTopNAggregations).mockResolvedValue([] as never);
+ vi.mocked(apiDataSource.listIndexRuleBindings).mockResolvedValue([] as never);
+ vi.mocked(apiDataSource.listIndexRules).mockResolvedValue([] as never);
+});
+
+describe('QueryConsole fuzzy resource pick', () => {
+ it('keeps the fuzzy-pick reset baseline for dependent clauses', async () => {
+ render(<QueryConsole />, { wrapper: makeWrapper() });
+
+ fireEvent.change(screen.getByLabelText('Search resources by name'), { target: { value: 'new_measure' } });
+ const fuzzyResult = await within(screen.getByRole('listbox')).findByRole('option');
+ fireEvent.click(fuzzyResult);
+
+ const query = await screen.findByTitle(/FROM MEASURE new_measure/);
+ const generated = query.getAttribute('title') ?? '';
+ expect(generated).toContain('SELECT new_tag');
+ expect(generated).not.toContain('old_tag');
+ expect(generated).not.toContain('old_field');
+ expect(generated).not.toContain('WHERE');
+ expect(generated).not.toContain('GROUP BY');
+ });
+});
diff --git a/canopy/web/src/query/QueryConsole.tsx b/canopy/web/src/query/QueryConsole.tsx
index 80d8ef50b..80922fecc 100644
--- a/canopy/web/src/query/QueryConsole.tsx
+++ b/canopy/web/src/query/QueryConsole.tsx
@@ -33,7 +33,7 @@ import { TopNResultView } from './results/TopNResultView.js';
import { ResultEmpty } from './results/ResultEmpty.js';
import { ResultError } from './results/ResultError.js';
import {
- buildBydbQL, qbDataCatalog, qbEmptyWhere, qbPruneWhere, qbNewCond,
+ buildBydbQL, qbDataCatalog, qbEmptyWhere, qbDefaultTraceWhere, qbPruneProjection, qbPruneSelect, qbPruneWhere, qbNewCond,
qbProtoCatalog, qbHasAnyFilter, qbHasTraceIdCondition,
type QBBuilderState, type QB_CATALOG_VALUE, type GroupTopnAggMap,
} from './bydbql.js';
@@ -201,9 +201,7 @@ export function QueryConsole() {
// ORDER BY clause; seed a trace_id condition for trace resources as a
// convenient starting point. The user can change the tag freely; if no
// trace_id condition remains, an effect defaults orderField to 'time'.
- const defaultWhere = catalog === 'traces'
- ? { combinator: 'AND' as const, children: [{ tag: 'trace_id', op: 'BINARY_OP_EQ' as const, value: '' }] }
- : qbEmptyWhere();
+ const defaultWhere = catalog === 'traces' ? qbDefaultTraceWhere() : qbEmptyWhere();
patch({
catalog,
group,
@@ -467,13 +465,21 @@ export function QueryConsole() {
return { begin: new Date(beginMs).toISOString(), end: new Date(now).toISOString() };
};
- // Re-prune WHERE tags when resource changes. Guard on a non-empty tag list:
- // right after a pick the new schema hasn't loaded yet (tags === []), and
- // pruning against an empty list would wipe the freshly seeded trace_id
- // condition for traces.
+ // Re-prune WHERE / SELECT / GROUP BY / measure field rows when the resource
+ // schema loads. Guard on a non-empty tag list: right after a pick the new
+ // schema hasn't loaded yet (tags === []), and pruning against an empty list
+ // would wipe the freshly seeded trace_id condition for traces. Measure
+ // select[] is pruned only once fields are known for the same reason.
useEffect(() => {
- if (tags.length > 0) setState((s) => ({ ...s, where: qbPruneWhere(s.where, tags) }));
- }, [state.resource, tags]);
+ if (tags.length === 0) return;
+ setState((s) => ({
+ ...s,
+ projection: qbPruneProjection(s.projection, tags),
+ where: qbPruneWhere(s.where, tags),
+ groupBy: qbPruneProjection(s.groupBy, tags),
+ select: s.catalog === 'measures' && fields.length > 0 ? qbPruneSelect(s.select, fields) : s.select,
+ }));
+ }, [state.resource, tags, fields]);
// Auto-pick a group once groups load so the rail renders real content
// on initial load instead of "— none —". Prefers the canonical handoff
@@ -949,4 +955,4 @@ function ResultViewRouter({ state, response, showTrace, setShowTrace, hasMore, o
}
// Re-export to keep tree-shaking honest
-export { qbNewCond };
\ No newline at end of file
+export { qbNewCond };
diff --git a/canopy/web/src/query/bydbql.test.ts b/canopy/web/src/query/bydbql.test.ts
index 5283521fc..033497860 100644
--- a/canopy/web/src/query/bydbql.test.ts
+++ b/canopy/web/src/query/bydbql.test.ts
@@ -22,7 +22,7 @@ import {
QB_CAT, QB_OP, QB_OPS, QB_UI_KW, QB_CATALOGS, qbDataCatalog,
qbSearchIndex, qbSearchResults,
buildBydbQL, qbNodeSQL, qbQuote, qbTimeSQL,
- qbNewCond, qbEmptyWhere, qbPruneWhere, qbConnSummary,
+ qbNewCond, qbEmptyWhere, qbPruneWhere, qbPruneProjection, qbPruneSelect, qbDefaultTraceWhere, qbConnSummary,
type QBBuilderState, type QBWhereGroupWithConn, type QBWhereLeafWithConn,
type GroupResourcesMap, type GroupTopnAggMap,
} from './bydbql.js';
@@ -472,6 +472,25 @@ describe('WHERE tree helpers', () => {
const p = qbPruneWhere(where, ['keep']);
expect(p.children.map((c) => (c as QBWhereLeafWithConn).tag)).toEqual(['keep']);
});
+ it('prunes WHERE and projected tags when a resource has no tags', () => {
+ const where: QBWhereGroupWithConn = {
+ combinator: 'AND',
+ children: [{ tag: 'old_tag', op: 'BINARY_OP_EQ', value: 'old-value' }],
+ };
+
+ expect(qbPruneWhere(where, []).children).toEqual([]);
+ expect(qbPruneProjection(['old_tag'], [])).toEqual([]);
+ });
+ it('qbPruneSelect drops measure field rows absent from the new measure', () => {
+ expect(qbPruneSelect(
+ [{ field: 'total', fn: 'MEAN' }, { field: 'gone', fn: '' }],
+ ['total', 'value'],
+ )).toEqual([{ field: 'total', fn: 'MEAN' }]);
+ });
+ it('qbDefaultTraceWhere seeds an empty trace_id equality leaf', () => {
+ const where = qbDefaultTraceWhere();
+ expect(where.children).toEqual([{ tag: 'trace_id', op: 'BINARY_OP_EQ', value: '' }]);
+ });
it('qbConnSummary reports AND / OR / mixed', () => {
expect(qbConnSummary({ combinator: 'AND', children: [] })).toBe('AND');
expect(qbConnSummary({ combinator: 'AND', children: [
@@ -485,4 +504,4 @@ describe('WHERE tree helpers', () => {
{ tag: 'c', op: 'BINARY_OP_EQ', value: '3' },
] })).toBe('mixed');
});
-});
\ No newline at end of file
+});
diff --git a/canopy/web/src/query/bydbql.ts b/canopy/web/src/query/bydbql.ts
index e23a4fb81..d72d508ed 100644
--- a/canopy/web/src/query/bydbql.ts
+++ b/canopy/web/src/query/bydbql.ts
@@ -623,6 +623,23 @@ export const qbPruneWhere = (
return { ...group, children: pruned as readonly QBWhereLeafWithConn[] };
};
+/** Drop projected tags that do not exist on the chosen resource. */
+export const qbPruneProjection = (projection: readonly string[], tags: readonly string[]): readonly string[] =>
+ projection.filter((tag) => tags.includes(tag));
+
+/** Drop measure SELECT field rows whose field no longer exists on the chosen measure. */
+export const qbPruneSelect = (
+ rows: readonly { readonly field: string; readonly fn: string }[],
+ fields: readonly string[],
+): readonly { readonly field: string; readonly fn: string }[] =>
+ rows.filter((row) => !row.field || fields.includes(row.field));
+
+/** Empty trace_id equality seed used when switching to a Trace resource. */
+export const qbDefaultTraceWhere = (): QBWhereGroupWithConn => ({
+ combinator: 'AND',
+ children: [{ tag: 'trace_id', op: 'BINARY_OP_EQ', value: '' }],
+});
+
// ── Fuzzy resource search (From-row search box) ─────────────────────────────
/** A resource row in the search index, before scoring. */
@@ -753,4 +770,4 @@ export const qbSearchResults = (
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit || 8);
-};
\ No newline at end of file
+};
(skywalking-banyandb) 09/15: fix(trace): enforce time ranges for non-timestamp sorted queries (#1333)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit e075769c682f8d761026a66231c46a5d77bda50c
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Fri Sep 11 10:40:55 2026 +0800
fix(trace): enforce time ranges for non-timestamp sorted queries (#1333)
Filter partially covered SIDX parts before trace deduplication and bypass timestamp checks for fully covered parts. Preserve user filters and projections, handle timestamp entities and conservative merge bounds, and add regression coverage and part-scan benchmarks.
Fixes apache/skywalking#14058
(cherry picked from commit 17da658000a624847a1fa87eeece13ed72438d9c)
---
TRACE_QUERY_TIME_RANGE_DESIGN.md | 400 +++++++++++++++++++++
banyand/internal/sidx/interfaces.go | 27 +-
banyand/internal/sidx/merge.go | 28 +-
banyand/internal/sidx/query.go | 58 ++-
banyand/internal/sidx/sidx.go | 30 +-
banyand/internal/sidx/timestamp_test.go | 140 ++++++++
banyand/trace/query.go | 233 +++++++++++-
banyand/trace/query_test.go | 94 +++++
banyand/trace/query_time_benchmark_test.go | 355 ++++++++++++++++++
banyand/trace/streaming_pipeline.go | 2 +-
pkg/query/logical/trace/index_filter_test.go | 45 +++
pkg/query/logical/trace/trace_analyzer.go | 1 +
pkg/query/logical/trace/trace_plan_tag_filter.go | 6 +-
.../cases/trace/data/want/time_range_trace_001.yml | 30 ++
test/cases/trace/trace.go | 13 +
15 files changed, 1412 insertions(+), 50 deletions(-)
diff --git a/TRACE_QUERY_TIME_RANGE_DESIGN.md b/TRACE_QUERY_TIME_RANGE_DESIGN.md
new file mode 100644
index 000000000..56522d18b
--- /dev/null
+++ b/TRACE_QUERY_TIME_RANGE_DESIGN.md
@@ -0,0 +1,400 @@
+# Exact time filtering for ordered trace queries
+
+Status: core fix implemented and validated locally, including the part-coverage
+fast path and real-matcher benchmarks.
+
+Upstream issue: [apache/skywalking#14058](https://github.com/apache/skywalking/issues/14058).
+
+Investigated revision: `d8ce8281`. Context: HAPI session
+`ff9632fa-3b97-45cd-8ab2-183b82e776b9` (PR CI Fail).
+
+## Confirmed bug
+
+An ordered trace search can return traces with no span inside its requested time
+window when the ordering key is duration rather than the schema timestamp.
+
+The existing `TestIssue14058LatencyOrderIgnoresTimeRange` reproduces this with an
+empty 15-minute interval inside a populated daily segment:
+
+| Experiment | Timestamp order | Duration order |
+| --- | ---: | ---: |
+| Current code | 0 traces | 8 traces (incorrect) |
+| Add only SIDX MinTimestamp/MaxTimestamp | 0 traces | 8 traces (still incorrect) |
+| Experimental stored-tag matcher plus internal timestamp projection | 0 traces | 0 traces (test passes) |
+
+All three investigation experiments were run locally before implementation. Build overlays supplied the missing generated
+mocks and replaced the missing UI bundle embed with an existing HTML asset; no
+production source or existing reproduction file was edited. The second experiment
+also overlaid the proposed two timestamp bounds. Logs are
+`/tmp/repro-14058-design.log` and `/tmp/repro-14058-bounds-only.log`.
+
+The passing prototype was also overlay-only; its log is
+`/tmp/repro-14058-exact-prototype.log`. It validates the ordinary duration-index
+approach against this reproduction, not the full design: composite entities,
+internal exclusive bounds, and the regression matrix below are not implemented or
+validated by that prototype. Overlays are under `/tmp/trace-time-range-design/`.
+
+## Root cause
+
+1. `pkg/query/logical/trace/trace_plan_tag_filter.go:75-78` translates the time
+ window to key bounds only for timestamp ordering. This distinction is correct:
+ duration keys must not be compared with epoch timestamps.
+2. `banyand/trace/query.go:301-310` forwards key restrictions and existing filters,
+ but adds neither exact time filtering nor timestamp part bounds.
+3. `banyand/internal/sidx/query.go:208` and `part_wrapper.go:206-222` use timestamp
+ bounds only for whole-part overlap tests. They do not enforce a row predicate.
+4. The write paths currently use entire segment bounds for SIDX part metadata
+ (`write_standalone.go:439-442`, `write_liaison.go:226-229`). Part pruning alone
+ therefore does not even repair the supplied empty-window reproduction.
+
+The missing invariant is **time eligibility independent of sort-key eligibility**.
+
+## Contract and scope
+
+For ordered searches, a candidate row must satisfy:
+
+```text
+existing series/key restrictions AND existing row predicate AND timestamp in TimeRange
+```
+
+The same row must satisfy the row predicate and time condition. Two different
+spans, one satisfying time and another satisfying the predicate, are not enough.
+Time qualification must happen before the row supplies a trace's representative
+sort key, before deduplication, and before pagination.
+
+Preserve public inclusive begin/end semantics. The public API validates
+millisecond precision, while storage and ordering use nanoseconds; never convert
+storage bounds with UnixMilli. Internal TimeRange inclusivity flags must also be
+respected, including empty ranges and overflow-safe handling of exclusive bounds.
+
+Preserve the existing distinction between **selecting a trace through a qualifying
+span** and **clipping the spans returned for that trace**. Timestamp ordering today
+selects IDs and then reconstructs their traces. This fix must not silently truncate
+out-of-window sibling spans. Explicit trace-ID queries bypass SIDX; retain that
+lookup behavior and document it with tests rather than accidentally changing it.
+
+## Recommended implementation
+
+### 1. Centralize restriction construction at the trace-to-SIDX boundary
+
+Extract a small request-building helper used by `prepareSIDXStreaming`, shared by
+legacy and vectorized execution. It receives the selected index, schema timestamp
+name, TimeRange, existing key bounds/filter, projection, and resolved series.
+
+Keep key and timestamp ranges separate. Set `MinTimestamp`/`MaxTimestamp` for
+part classification. Trusted conservative part bounds can prove either exclusion
+or complete coverage; overlap alone cannot prove that individual rows qualify.
+Legacy parts without trustworthy timestamp bounds remain eligible for exact
+filtering.
+
+Determine where the timestamp resides from the actual selected index's tag list,
+not from an index name such as `timestamp` or `duration`:
+
+| Timestamp representation | Exact restriction |
+| --- | --- |
+| Last index tag: the ordering key | Intersect existing key bounds with the time interval. |
+| Index prefix: a series entity | Filter resolved series by its timestamp entity value. |
+| Ordinary stored SIDX tag | Apply the time matcher only to partially covered or unknown-range parts. |
+
+An empty key intersection or empty post-filter series set must return an empty
+result, not an invalid SIDX request or the fallback series ID `1`.
+
+### 2. Classify each part before loading timestamp columns
+
+For query interval Q and trusted part timestamp envelope P:
+
+| Relationship | Action |
+| --- | --- |
+| Q and P are disjoint | Skip the part. |
+| Q completely contains P | Scan without the implicit row time predicate. |
+| Q intersects but does not completely contain P | Apply exact row time filtering. |
+| P is missing, incomplete, or not trustworthy | Apply exact row time filtering; never infer coverage. |
+
+For inclusive bounds, full coverage means `queryMin <= partMin` and
+`partMax <= queryMax`. Respect exclusive query endpoints in internal callers.
+The reverse containment (part contains query) does NOT allow bypassing the filter.
+
+Make this decision per part, not once for the whole request. One query may fully
+cover interior parts and only partially cover boundary parts. Use the part
+metadata available at block-cursor construction before loading tag data
+(`banyand/internal/sidx/query.go:582-590`, `sidx.go:542-559`). Classify against
+immutable part metadata during block-cursor construction, not per row. Caching
+request variants across a part's blocks is a potential follow-up if profiling
+demonstrates a benefit.
+
+Keep the implicit time predicate and its extra projection dependency separate
+from the original user predicate/projection in the internal SIDX request. Prepare
+immutable base and time-filtered variants. Covered parts use the base variant;
+partial/unknown parts use the time-filtered variant. Do not mutate the shared
+request or projection maps from concurrent block workers. Apply this consistently
+to synchronous and streaming SIDX cursor loading where the new filter is used.
+
+On the covered path, do not add or decode the timestamp column solely for the
+implicit time check. Still load it if the user projection or another predicate
+requires it. Retain every user predicate, including explicit timestamp criteria:
+coverage eliminates only the implicit TimeRange predicate. Preserve existing
+empty-projection semantics rather than assuming an empty tag map means no I/O.
+
+Segment-wide bounds written today are conservative: a full-segment query can use
+the bypass, but a narrow query must filter rows. Tighter actual-row part bounds
+could expand the fast path later without being required for this fix.
+
+Metadata correctness is a prerequisite for both skipping and bypassing. In
+particular, merging a known-range part with an unknown-range part must preserve
+an unknown envelope unless a trusted encompassing segment envelope is available.
+Audit `sidx/merge.go:124-143`, which currently aggregates non-nil bounds, and test
+mixed legacy/new metadata rather than assuming a non-nil aggregate covers all rows.
+
+### 3. Add a small timestamp matcher for partially covered parts
+
+Implement a trace-owned `model.TagFilterMatcher` decorator. Look up the schema's
+timestamp tag by name, check its typed value against TimeRange, then delegate to
+the original matcher if present. Retain the original decoder when wrapping one;
+otherwise use the trace decoder. No user criteria is a supported case.
+
+Compose as `time AND (original predicate)`, preserving all original OR grouping.
+Use exact TimeRange membership; null or wrong-type values must not accidentally
+match. Missing timestamps that are actually represented as keys/entities must
+take their corresponding route above, not be mistaken for null stored tags.
+
+For the time-filtered variant, clone the SIDX projection, deduplicate its names,
+and append the timestamp tag.
+Do not modify the caller's projection, logical result schema, or payload projection.
+SIDX loads tags and constructs matcher inputs from projection names, so merely
+adding a matcher is insufficient when the timestamp was not requested.
+
+The existing `sidx.blockCursorBuilder.processWithFilter` calls the matcher before
+`appendElement` marks a payload seen (`sidx.go:370-397,473-480`). Reuse this point.
+Post-response filtering is too late: an invalid row may already have suppressed
+another qualifying row of the same trace or supplied the wrong ordering key.
+
+Keep this matcher allocation-light and immutable: precompute the timestamp name
+and bounds once, with no per-row schema lookup or mutation of shared requests.
+Do not introduce new matcher errors without addressing SIDX's existing behavior
+of logging a matcher error and dropping that block rather than propagating it.
+
+### 4. Resolve composite-index entities correctly
+
+`processIndexRules` removes every index-rule tag from stored SIDX tags
+(`write_standalone.go:315-330`), not just the ordering key. A timestamp in the index
+prefix therefore needs series filtering, not a stored-tag matcher.
+
+There is an existing layout hazard: `pkg/query/logical/trace/schema.go:27-32`
+derives entities from the first rule, rather than necessarily the selected rule.
+Do not blindly index `seriesToEntity` using another rule's positions. Derive the
+query's entity layout from the selected rule before building entity constraints
+and resolving series, and use that same layout for timestamp extraction. Keep
+layout identity explicit in the internal handoff if necessary. Test a selected
+rule that is not first. Missing or inconsistent layout metadata must produce a
+clear planning/request error rather than an unfiltered fallback.
+
+For the ordinary duration index this requires no write-path or storage-format
+change: the timestamp is already stored as a tag. Composite timestamp entities
+are recoverable through their series metadata; no reindex is inherently required.
+
+## Adjacent correctness finding: descending deduplication
+
+Source inspection found a separate ordering hazard. SIDX's block builder scans
+ascending physical keys and deduplicates trace payloads before descending cursor
+traversal is selected (`sidx.go:379-397,504-519`, `query.go:585-590`). Multiple
+qualifying rows of the same trace in one block can therefore retain the minimum
+duration even for DESC.
+
+This is not the cause of the time leak, and the time fix must not be represented
+as repairing it automatically. Add an independent regression. For complete
+ordering correctness, retain the direction-correct qualifying representative per
+payload, while preserving ascending cursor storage and aligned data/tag arrays;
+alternatively defer deduplication to the ordered merge, with measured memory and
+batching costs. Treat this as a separate, independently testable change rather
+than expanding the timestamp matcher into a deduplication rewrite.
+
+## Regression plan
+
+Use a deterministic fixture that forces mixed timestamps into the same block and
+part. Wait for data visibility explicitly; do not depend on sleeps or accidental
+flush boundaries. Distinct traces with durations unrelated to timestamps should
+provide the basic membership oracle.
+
+1. Original empty interval within a populated segment; timestamp and duration
+ ordering both return zero.
+2. Before/begin/inside/end/after timestamps in one part. Public millisecond
+ endpoints, plus internal nanosecond/exclusive-boundary unit tests.
+3. Default timestamp order, explicitly named timestamp index, and differently
+ named duration indexes; ASC and DESC; duration key restrictions still applied.
+4. No criteria, AND/OR criteria, and an existing timestamp criterion. An outside
+ row must never satisfy a user OR branch without satisfying the global window.
+5. Projection excludes timestamp, includes it, or is empty. Assert no mutation or
+ unrequested response fields; repeated requests must behave identically.
+6. Same trace with an outside high-ranked row and an inside lower-ranked row.
+ Outside rows cannot suppress inside rows or supply their ordering keys.
+7. Several invalid rows before valid ones, small LIMIT, nonzero OFFSET, multiple
+ batches/parts/shards. Assert exact IDs and keys, not just counts.
+8. Timestamp as a composite entity; selected rule not first; no surviving series.
+9. Parts lacking timestamp metadata; memory, flushed/reopened, and merged parts.
+10. Legacy/vectorized execution and standalone/distributed ingestion/query parity.
+11. Complete-trace sibling spans and explicit trace-ID lookup compatibility.
+12. Independent same-trace/multiple-inside-duration test for the DESC dedup hazard.
+13. Full part coverage bypasses the implicit matcher, while partial coverage and
+ unknown bounds invoke it. Test a single request mixing covered, partial, and
+ disjoint parts; all rows/results must match an always-filtered reference.
+14. Covered parts retain user predicates and requested timestamp values. Instrument
+ matcher calls and column loading to prove no extra implicit timestamp work
+ occurs on the bypass path; verify shared projections remain unchanged.
+15. Known/unknown metadata merged together cannot incorrectly prove disjointness
+ or full coverage. Include exclusive-boundary containment cases.
+
+Use request-builder unit tests for restriction/projection behavior, SIDX-level
+tests for same-block witness handling, and integration tests for API results.
+Assert the same unpaginated membership across sort modes; assert each mode's
+ordering separately. Cover error/cancellation cleanup if builder signatures change.
+
+## Required benchmark: part scans with and without timestamp filtering
+
+`BenchmarkSIDXPartScanTimestampFilter` is implemented in
+`banyand/trace/query_time_benchmark_test.go`, where it can invoke the actual
+trace-owned timestamp matcher through public SIDX APIs without an import cycle.
+It benchmarks part/block loading and filtering, not only the timestamp comparison.
+This benchmark is part of implementation acceptance, not an optional follow-up.
+
+### Apples-to-apples full-coverage comparison
+
+Use the same immutable duration-keyed part, query bounds, key range, batch size,
+ordering, and response projection for each variant. Choose a query that fully
+covers the part so all variants must return identical IDs, keys, and row counts:
+
+| Variant | Implicit timestamp predicate | Extra timestamp column work |
+| --- | --- | --- |
+| `NoTimeFilter` | Disabled in the benchmark baseline | None unless already requested |
+| `ForcedTimeFilter` | Applied to every row despite full coverage | Load/decode timestamp for filtering |
+| `AutoCoverageBypass` | Production part-coverage decision bypasses it | Same as the baseline |
+
+Keep forced filtering and filter disabling confined to the benchmark harness;
+do not introduce a public configuration switch. The forced variant must exercise
+the real matcher/projection path, not a synthetic equivalent predicate.
+
+Run two projection cases: timestamp omitted, and timestamp already required by
+the base request. The first measures the combined column-loading, decoding, and
+predicate cost; the second holds the required columns constant and measures the
+incremental filter-path cost. Also cover no existing predicate versus a fixed
+existing tag predicate, verifying that bypass preserves the latter.
+
+### Partial-coverage and mixed-part workloads
+
+Add partial-overlap cases with deterministic 0%, 10%, 50%, and 100% matching rows.
+The 0% and 100% cases can use conservative part envelopes wider than the actual
+row timestamps. Compare production automatic filtering against forced filtering;
+both must produce identical results. Do not present an unfiltered partial scan
+as a correct competing implementation: its output cardinality and semantics differ.
+
+Include a multi-part case with fully covered interior parts, partially covered
+boundary parts, and disjoint parts. Compare selective filtering against always
+filtering all overlapping parts. Add unknown-bound parts to exercise the safe
+fallback. Hold candidate parts and the dataset fixed across paired variants.
+
+### Fixture, measurement, and reporting
+
+- Use fixed-seed timestamps independent of duration keys and unique trace IDs for
+ the primary benchmark, avoiding deduplication/selectivity confounders. Exercise
+ ASC and DESC, small and large multi-block parts (for example 10K and 100K rows),
+ and both in-memory and flushed/reopened parts.
+- Construct, flush, and publish parts before timing. Keep the snapshot stable;
+ exclude ingestion, fixture generation, and background merges from measured work.
+- Drain each scan completely and release results/resources within each iteration.
+ Do not use LIMIT or stop after the first result for the full-scan comparison.
+- Validate result equivalence and fast-path matcher/column-loading counters in
+ untimed checks. Avoid a per-row atomic instrumentation cost in timed scans.
+- Report `ns/op`, `B/op`, `allocs/op`, and input rows/second. Record output counts
+ separately so selective workloads cannot appear faster merely by returning fewer
+ rows. If reporting bytes read/decoded, distinguish logical column bytes from
+ physical disk I/O; do not label warmed filesystem-cache runs as cold-storage tests.
+- Run repeated samples on the same machine with fixed Go version and GOMAXPROCS.
+ Use statistical comparisons, not a brittle single-run timing assertion in CI.
+
+Suggested command after implementation and normal test-artifact setup:
+
+```sh
+GOMAXPROCS=2 go test ./banyand/trace -run '^$' \
+ -bench '^BenchmarkSIDXPartScanTimestampFilter$' -benchmem -count=10
+```
+
+Include the benchmark results in the implementation report. The coverage-bypass
+case should remain close to the no-filter baseline, and its savings relative to
+forced filtering must be measured rather than assumed. If metadata checks or
+request/projection handling cause a material regression, investigate before
+acceptance. Initial measurements and the implemented workload coverage are below.
+
+## Delivery and validation
+
+Implement and validate the exact-filter fix before optimizing timestamp metadata.
+Add focused tests in `banyand/trace`, `pkg/query/logical/trace`, and
+`banyand/internal/sidx`; replace/promote the ad-hoc integration reproduction with
+maintained fixtures without weakening its assertion. Exercise the existing
+standalone and distributed trace suites, then focused race tests and repository
+format/lint checks. Compare narrow-window duration-query allocations and latency
+with and without a user predicate; timestamp-key queries should retain their
+key-range fast path without an unnecessary tag decode.
+
+No public API, on-disk encoding, or configuration switch is proposed. Add a
+release note explaining corrected non-timestamp ordered search results. A
+bounds-only patch, response-side filtering, or time values substituted into
+duration key bounds does not satisfy acceptance.
+
+## Implementation and validation record
+
+The implementation adds a separate implicit time-filter request variant, exact
+timestamp-tag matching, timestamp-key intersection, selected-index timestamp-entity
+filtering, and conservative propagation of unknown timestamp envelopes through
+merge. Empty half-open intervals are handled explicitly. User predicates and
+response projections remain separate from the extra timestamp dependency.
+
+Maintained integration cases now live in `test/cases/trace/trace.go`, shared by
+standalone/distributed and legacy/vectorized suites. The empty-gap regression
+fails against baseline production code and passes with the fix. A narrow-window
+case selects the qualifying trace while retaining its out-of-window sibling spans.
+The original user reproduction file and `sortedMIterator` remain untouched.
+
+Validation completed:
+
+- Full SIDX and logical trace package tests, including race runs.
+- Focused trace query, vectorized, timestamp-matcher/entity, and streaming tests,
+ including a race run.
+- Existing standalone and distributed trace integration suites.
+- New shared integration regressions in both standalone and distributed modes.
+- Original `TestIssue14058LatencyOrderIgnoresTimeRange` reproduction.
+- Pinned golangci-lint v1.64.8 for SIDX, trace, logical trace, and shared trace cases;
+ gofumpt and `git diff --check`.
+- The mixed known/unknown merge regression independently fails against the original
+ merge implementation and passes with conservative merged bounds.
+
+Green integration runs used `/tmp/trace-time-range-design/overlay.json` only for
+missing generated build artifacts/UI embedding, never to replace the query fix.
+Generated mock artifacts were also materialized locally for lint's parser.
+
+### Recorded benchmark samples
+
+Command: `GOMAXPROCS=2 go test ./banyand/trace -run '^$'
+-bench '^BenchmarkSIDXPartScanTimestampFilter$' -benchmem -benchtime=100ms -count=3`.
+Linux/amd64, AMD EPYC 7B13, Go 1.25.13. These are warm in-process scans, not
+cold-storage measurements. Medians from three short samples, no existing user
+predicate, timestamp omitted from the response projection:
+
+| Fixture | Variant | ms/op | B/op | allocs/op |
+| --- | --- | ---: | ---: | ---: |
+| Memory, 10K rows, ASC | No time filter | 7.975 | 7,998,063 | 40,829 |
+| Memory, 10K rows, ASC | Forced exact filter | 14.114 | 13,358,037 | 130,876 |
+| Memory, 10K rows, ASC | Coverage bypass | 8.226 | 8,409,034 | 40,848 |
+| Flushed/reopened, 100K rows, ASC | No time filter | 88.688 | 72,964,864 | 407,153 |
+| Flushed/reopened, 100K rows, ASC | Forced exact filter | 133.400 | 124,216,872 | 1,307,356 |
+| Flushed/reopened, 100K rows, ASC | Coverage bypass | 86.651 | 73,258,720 | 407,182 |
+
+The measured bypass is close to the unfiltered baseline, with substantially less
+work than forced filtering. Treat these samples as an initial comparison, not a
+statistical performance guarantee. Full output:
+`/tmp/trace-time-range-final-benchmark.log`.
+
+The benchmark also covers projected timestamps, an existing service predicate,
+10K flushed/reopened DESC scans, and partial-overlap selectivity of 0%, 10%, 50%,
+and 100%, comparing forced and automatic exact filtering with identical IDs/keys.
+Mixed-part performance workloads remain an extension; unknown-envelope correctness
+is covered by tests. The independent descending same-trace deduplication issue
+described above is not changed by this patch.
diff --git a/banyand/internal/sidx/interfaces.go b/banyand/internal/sidx/interfaces.go
index bafeb9415..790ab1a5b 100644
--- a/banyand/internal/sidx/interfaces.go
+++ b/banyand/internal/sidx/interfaces.go
@@ -110,8 +110,15 @@ type WriteRequest struct {
// QueryRequest specifies parameters for a query operation, following StreamQueryOptions pattern.
type QueryRequest struct {
- Filter index.Filter
- TagFilter model.TagFilterMatcher
+ Filter index.Filter
+ TagFilter model.TagFilterMatcher
+ // TimeTagFilter combines exact timestamp eligibility AND TagFilter. It replaces
+ // TagFilter only for partial or unknown timestamp-envelope parts.
+ TimeTagFilter model.TagFilterMatcher
+ // TimeTagName is an internal filter-only column dependency; it is never added
+ // to response projections.
+ TimeTagName string
+ FilterTagNames []string
Order *index.OrderBy
MinKey *int64
MaxKey *int64
@@ -121,6 +128,10 @@ type QueryRequest struct {
SeriesIDs []common.SeriesID
TagProjection []model.TagProjection
MaxBatchSize int
+ // TimeIncludeStart and TimeIncludeEnd are used only when proving full-part
+ // coverage; exact TimeTagFilter membership remains authoritative.
+ TimeIncludeStart bool
+ TimeIncludeEnd bool
}
// ScanProgressFunc is a callback for reporting scan progress.
@@ -369,6 +380,12 @@ func (sqr ScanQueryRequest) Validate() error {
func (qr *QueryRequest) Reset() {
qr.SeriesIDs = nil
qr.Filter = nil
+ qr.TagFilter = nil
+ qr.TimeTagFilter = nil
+ qr.TimeTagName = ""
+ qr.TimeIncludeStart = false
+ qr.TimeIncludeEnd = false
+ qr.FilterTagNames = nil
qr.Order = nil
qr.TagProjection = nil
qr.MaxBatchSize = 0
@@ -388,6 +405,12 @@ func (qr *QueryRequest) CopyFrom(other *QueryRequest) {
}
qr.Filter = other.Filter
+ qr.TagFilter = other.TagFilter
+ qr.TimeTagFilter = other.TimeTagFilter
+ qr.TimeTagName = other.TimeTagName
+ qr.TimeIncludeStart = other.TimeIncludeStart
+ qr.TimeIncludeEnd = other.TimeIncludeEnd
+ qr.FilterTagNames = append(qr.FilterTagNames[:0], other.FilterTagNames...)
qr.Order = other.Order
if other.TagProjection != nil {
diff --git a/banyand/internal/sidx/merge.go b/banyand/internal/sidx/merge.go
index 4d1438aa4..c3b866788 100644
--- a/banyand/internal/sidx/merge.go
+++ b/banyand/internal/sidx/merge.go
@@ -120,25 +120,25 @@ func (s *sidx) mergeParts(fileSystem fs.FileSystem, closeCh <-chan struct{}, par
if err != nil {
return nil, err
}
- // Aggregate optional timestamp range from merged parts
+ // A merged envelope is trustworthy only when every input supplies both bounds.
+ // Aggregating known inputs while silently ignoring a legacy unknown part could make
+ // the resulting metadata prove a disjoint or fully-covered range incorrectly.
var minVal, maxVal int64
- var hasMinTS, hasMaxTS bool
+ hasTimestampEnvelope := len(parts) > 0
for i := range parts {
- p := parts[i].p.partMetadata
- if p.MinTimestamp != nil {
- if !hasMinTS || *p.MinTimestamp < minVal {
- minVal = *p.MinTimestamp
- hasMinTS = true
- }
+ partMetadata := parts[i].p.partMetadata
+ if partMetadata == nil || partMetadata.MinTimestamp == nil || partMetadata.MaxTimestamp == nil {
+ hasTimestampEnvelope = false
+ break
}
- if p.MaxTimestamp != nil {
- if !hasMaxTS || *p.MaxTimestamp > maxVal {
- maxVal = *p.MaxTimestamp
- hasMaxTS = true
- }
+ if i == 0 || *partMetadata.MinTimestamp < minVal {
+ minVal = *partMetadata.MinTimestamp
+ }
+ if i == 0 || *partMetadata.MaxTimestamp > maxVal {
+ maxVal = *partMetadata.MaxTimestamp
}
}
- if hasMinTS && hasMaxTS {
+ if hasTimestampEnvelope {
pm.MinTimestamp = &minVal
pm.MaxTimestamp = &maxVal
}
diff --git a/banyand/internal/sidx/query.go b/banyand/internal/sidx/query.go
index 3519d662e..69867edb9 100644
--- a/banyand/internal/sidx/query.go
+++ b/banyand/internal/sidx/query.go
@@ -537,6 +537,54 @@ func determineTagsToLoad(req QueryRequest) map[string]struct{} {
return tagsToLoad
}
+// requestForPart returns immutable request and tag-loading variants for a part. Timestamp
+// envelopes can only prove that every row is in range; any partial or unknown envelope
+// must keep the exact row predicate.
+func requestForPart(req QueryRequest, tagsToLoad map[string]struct{}, p *part) (QueryRequest, map[string]struct{}) {
+ if !partNeedsTimeFilter(req, p) {
+ return req, tagsToLoad
+ }
+
+ effectiveReq := req
+ effectiveReq.TagFilter = req.TimeTagFilter
+ effectiveReq.FilterTagNames = append([]string(nil), req.FilterTagNames...)
+ if req.TimeTagName != "" {
+ effectiveReq.FilterTagNames = append(effectiveReq.FilterTagNames, req.TimeTagName)
+ }
+ // An empty set deliberately means "load all tags". Preserve that behavior instead
+ // of turning an empty user projection into a timestamp-only projection.
+ if len(tagsToLoad) == 0 || req.TimeTagName == "" {
+ return effectiveReq, tagsToLoad
+ }
+ effectiveTagsToLoad := make(map[string]struct{}, len(tagsToLoad)+1)
+ for tagName := range tagsToLoad {
+ effectiveTagsToLoad[tagName] = struct{}{}
+ }
+ effectiveTagsToLoad[req.TimeTagName] = struct{}{}
+ return effectiveReq, effectiveTagsToLoad
+}
+
+func partNeedsTimeFilter(req QueryRequest, p *part) bool {
+ if req.TimeTagFilter == nil {
+ return false
+ }
+ if req.MinTimestamp == nil || req.MaxTimestamp == nil || p == nil || p.partMetadata == nil {
+ return true
+ }
+ partMin := p.partMetadata.MinTimestamp
+ partMax := p.partMetadata.MaxTimestamp
+ if partMin == nil || partMax == nil {
+ return true
+ }
+ if *req.MinTimestamp > *partMin || *partMax > *req.MaxTimestamp {
+ return true
+ }
+ if *req.MinTimestamp == *partMin && !req.TimeIncludeStart {
+ return true
+ }
+ return *req.MaxTimestamp == *partMax && !req.TimeIncludeEnd
+}
+
func (s *sidx) buildCursorsForBatch(
ctx context.Context,
batch *blockScanResultBatch,
@@ -579,10 +627,11 @@ func (s *sidx) buildCursorsForBatch(
return
}
+ effectiveReq, effectiveTagsToLoad := requestForPart(req, tagsToLoad, bsResult.p)
bc := generateBlockCursor()
- bc.init(bsResult.p, &bsResult.bm, req)
+ bc.init(bsResult.p, &bsResult.bm, effectiveReq)
- if s.loadBlockCursor(bc, tmpBlock, bsResult, tagsToLoad, req, s.pm, metrics) {
+ if s.loadBlockCursor(bc, tmpBlock, bsResult, effectiveTagsToLoad, effectiveReq, s.pm, metrics) {
if asc {
bc.idx = 0
} else {
@@ -658,9 +707,10 @@ func (s *sidx) buildCursorsForBatchSync(
}
return nil, ctxErr
}
+ effectiveReq, effectiveTagsToLoad := requestForPart(req, tagsToLoad, bsResult.p)
bc := generateBlockCursor()
- bc.init(bsResult.p, &bsResult.bm, req)
- if s.loadBlockCursor(bc, tmpBlock, bsResult, tagsToLoad, req, s.pm, metrics) {
+ bc.init(bsResult.p, &bsResult.bm, effectiveReq)
+ if s.loadBlockCursor(bc, tmpBlock, bsResult, effectiveTagsToLoad, effectiveReq, s.pm, metrics) {
if asc {
bc.idx = 0
} else {
diff --git a/banyand/internal/sidx/sidx.go b/banyand/internal/sidx/sidx.go
index 4b2340100..5b179fdd0 100644
--- a/banyand/internal/sidx/sidx.go
+++ b/banyand/internal/sidx/sidx.go
@@ -374,7 +374,7 @@ func (b *blockCursorBuilder) processWithFilter(req QueryRequest, log *logger.Log
tags := make([]*modelv1.Tag, 0, len(b.block.tags))
decoder := req.TagFilter.GetDecoder()
- orderedTagNames := b.orderedTagNamesForFilter(req.TagProjection)
+ orderedTagNames := b.orderedTagNamesForFilter(req.TagProjection, req.FilterTagNames)
for i := 0; i < len(b.block.userKeys); i++ {
dataBytes := b.block.data[i]
@@ -441,24 +441,34 @@ func (b *blockCursorBuilder) collectTagsForFilter(
return buf
}
-func (b *blockCursorBuilder) orderedTagNamesForFilter(projections []model.TagProjection) []string {
+func (b *blockCursorBuilder) orderedTagNamesForFilter(projections []model.TagProjection, filterTagNames ...[]string) []string {
+ var extraTagNames []string
+ if len(filterTagNames) > 0 {
+ extraTagNames = filterTagNames[0]
+ }
// Preserve projection order for reproducible matcher inputs and diagnostics.
// Logical matchers resolve values by tag key rather than relying on this order.
if len(projections) > 0 {
seenTagNames := make(map[string]struct{}, len(b.block.tags))
tagNames := make([]string, 0, len(b.block.tags))
+ appendTagName := func(tagName string) {
+ if _, exists := seenTagNames[tagName]; exists {
+ return
+ }
+ if _, exists := b.block.tags[tagName]; !exists {
+ return
+ }
+ tagNames = append(tagNames, tagName)
+ seenTagNames[tagName] = struct{}{}
+ }
for _, proj := range projections {
for _, tagName := range proj.Names {
- if _, exists := seenTagNames[tagName]; exists {
- continue
- }
- if _, exists := b.block.tags[tagName]; !exists {
- continue
- }
- tagNames = append(tagNames, tagName)
- seenTagNames[tagName] = struct{}{}
+ appendTagName(tagName)
}
}
+ for _, tagName := range extraTagNames {
+ appendTagName(tagName)
+ }
return tagNames
}
diff --git a/banyand/internal/sidx/timestamp_test.go b/banyand/internal/sidx/timestamp_test.go
index b9b8b1731..c154f6745 100644
--- a/banyand/internal/sidx/timestamp_test.go
+++ b/banyand/internal/sidx/timestamp_test.go
@@ -27,9 +27,11 @@ import (
"github.com/stretchr/testify/require"
"github.com/apache/skywalking-banyandb/api/common"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
"github.com/apache/skywalking-banyandb/banyand/observability"
"github.com/apache/skywalking-banyandb/banyand/protector"
"github.com/apache/skywalking-banyandb/pkg/fs"
+ pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
)
// writeTestDataWithTimeRange writes test data with optional min/max timestamps (back-compat: nil means no timestamps).
@@ -396,3 +398,141 @@ func TestMerge_TimestampPropagation(t *testing.T) {
assert.Equal(t, int64(300), *m.MaxTimestamp)
})
}
+
+func TestRequestForPartUsesTimestampFilterOnlyWhenNeeded(t *testing.T) {
+ minTimestamp := int64(100)
+ maxTimestamp := int64(200)
+ timeFilter := &mockTagFilterMatcher{}
+ req := QueryRequest{
+ MinTimestamp: &minTimestamp,
+ MaxTimestamp: &maxTimestamp,
+ TagFilter: nil,
+ TimeTagFilter: timeFilter,
+ TimeTagName: "timestamp",
+ }
+ baseTags := map[string]struct{}{"service": {}}
+
+ coveredMin, coveredMax := int64(120), int64(180)
+ coveredPart := &part{partMetadata: &partMetadata{MinTimestamp: &coveredMin, MaxTimestamp: &coveredMax}}
+ coveredRequest, coveredTags := requestForPart(req, baseTags, coveredPart)
+ assert.Nil(t, coveredRequest.TagFilter)
+ assert.Equal(t, baseTags, coveredTags)
+
+ partialMin, partialMax := int64(80), int64(180)
+ partialPart := &part{partMetadata: &partMetadata{MinTimestamp: &partialMin, MaxTimestamp: &partialMax}}
+ partialRequest, partialTags := requestForPart(req, baseTags, partialPart)
+ assert.Same(t, timeFilter, partialRequest.TagFilter)
+ assert.Contains(t, partialTags, "timestamp")
+ assert.NotContains(t, baseTags, "timestamp")
+
+ unknownPart := &part{partMetadata: &partMetadata{}}
+ unknownRequest, _ := requestForPart(req, baseTags, unknownPart)
+ assert.Same(t, timeFilter, unknownRequest.TagFilter)
+}
+
+func TestTimestampFilterPartVariantsPreserveBaseFilterAndNilProjection(t *testing.T) {
+ sidx := createTestSIDX(t)
+ defer func() {
+ assert.NoError(t, sidx.Close())
+ }()
+
+ partMin, partMax := int64(0), int64(20)
+ requests := []WriteRequest{createTestWriteRequest(1, 1, "trace", Tag{Name: "other", Value: []byte("present"), ValueType: pbv1.ValueTypeStr})}
+ writeTestDataWithTimeRange(t, sidx, requests, 1, 1, &partMin, &partMax)
+ waitForIntroducerLoop()
+
+ queryMin, queryMax := int64(0), int64(20)
+ baseFilter := &mockTagFilterMatcher{decoder: testTagValueDecoder, matchFunc: func(_ []*modelv1.Tag) (bool, error) { return false, nil }}
+ coveredResponses, queryErr := sidx.QuerySync(context.Background(), QueryRequest{
+ SeriesIDs: []common.SeriesID{1},
+ TagFilter: baseFilter,
+ TimeTagFilter: &mockTagFilterMatcher{},
+ TimeTagName: "timestamp",
+ MinTimestamp: &queryMin,
+ MaxTimestamp: &queryMax,
+ TimeIncludeStart: true,
+ TimeIncludeEnd: true,
+ })
+ require.NoError(t, queryErr)
+ require.Empty(t, coveredResponses, "coverage bypass must retain the original predicate")
+
+ // Widening the conservative envelope makes the same row partial. A nil projection
+ // retains its historic all-tags behavior so the time matcher can still inspect
+ // non-projected delegate inputs.
+ partialMin := int64(-1)
+ memPart, convertErr := sidx.ConvertToMemPart(requests, 2, &partialMin, &partMax)
+ require.NoError(t, convertErr)
+ sidx.IntroduceMemPart(2, memPart)
+ waitForIntroducerLoop()
+ seenOtherTag := false
+ partialResponses, queryErr := sidx.QuerySync(context.Background(), QueryRequest{
+ SeriesIDs: []common.SeriesID{1},
+ TimeTagFilter: &mockTagFilterMatcher{decoder: testTagValueDecoder, matchFunc: func(tags []*modelv1.Tag) (bool, error) {
+ for _, tag := range tags {
+ if tag.GetKey() == "other" {
+ seenOtherTag = true
+ }
+ }
+ return true, nil
+ }},
+ TimeTagName: "timestamp",
+ MinTimestamp: &queryMin,
+ MaxTimestamp: &queryMax,
+ TimeIncludeStart: true,
+ TimeIncludeEnd: true,
+ })
+ require.NoError(t, queryErr)
+ require.NotEmpty(t, partialResponses)
+ require.True(t, seenOtherTag)
+}
+
+func TestMergeUnknownTimestampEnvelopeCannotSkipOrBypassFiltering(t *testing.T) {
+ instance := createTestSIDX(t)
+ t.Cleanup(func() {
+ assert.NoError(t, instance.Close())
+ })
+ knownMin, knownMax := int64(100), int64(200)
+ writeTestDataWithTimeRange(t, instance, []WriteRequest{createTestWriteRequest(1, 1, "known")}, 1, 1, &knownMin, &knownMax)
+ writeTestDataWithTimeRange(t, instance, []WriteRequest{createTestWriteRequest(1, 2, "unknown")}, 1, 2, nil, nil)
+ partIDs := map[uint64]struct{}{1: {}, 2: {}}
+ flushIntroduction, flushErr := instance.Flush(partIDs)
+ require.NoError(t, flushErr)
+ require.NotNil(t, flushIntroduction)
+ instance.IntroduceFlushed(flushIntroduction)
+ flushIntroduction.Release()
+ mergeIntroduction, mergeErr := instance.Merge(nil, partIDs, 10, nil)
+ require.NoError(t, mergeErr)
+ require.NotNil(t, mergeIntroduction)
+ instance.IntroduceMerged(mergeIntroduction)()
+
+ queryMin, queryMax := int64(300), int64(400)
+ request := QueryRequest{
+ SeriesIDs: []common.SeriesID{1},
+ MinTimestamp: &queryMin,
+ MaxTimestamp: &queryMax,
+ }
+ responses, queryErr := instance.QuerySync(context.Background(), request)
+ require.NoError(t, queryErr)
+ var values []string
+ for _, response := range responses {
+ for _, value := range response.Data {
+ values = append(values, string(value))
+ }
+ }
+ require.ElementsMatch(t, []string{"known", "unknown"}, values, "unknown merged bounds cannot prove disjointness")
+
+ request.MinTimestamp, request.MaxTimestamp = &knownMin, &knownMax
+ request.TimeIncludeStart, request.TimeIncludeEnd = true, true
+ filterCalls := 0
+ request.TimeTagFilter = &mockTagFilterMatcher{
+ decoder: testTagValueDecoder,
+ matchFunc: func(_ []*modelv1.Tag) (bool, error) {
+ filterCalls++
+ return false, nil
+ },
+ }
+ responses, queryErr = instance.QuerySync(context.Background(), request)
+ require.NoError(t, queryErr)
+ require.Empty(t, responses)
+ require.Equal(t, 2, filterCalls, "known input bounds cannot prove coverage of the unknown input")
+}
diff --git a/banyand/trace/query.go b/banyand/trace/query.go
index 01020fbeb..e2bf39b10 100644
--- a/banyand/trace/query.go
+++ b/banyand/trace/query.go
@@ -21,6 +21,7 @@ import (
"context"
"fmt"
"maps"
+ "math"
"sort"
"time"
@@ -28,6 +29,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/apache/skywalking-banyandb/api/common"
+ 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/banyand/internal/sidx"
"github.com/apache/skywalking-banyandb/banyand/internal/storage"
@@ -38,6 +40,7 @@ import (
"github.com/apache/skywalking-banyandb/pkg/pool"
"github.com/apache/skywalking-banyandb/pkg/query/model"
vtrace "github.com/apache/skywalking-banyandb/pkg/query/vectorized/trace"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
)
var traceQueryResultTracker = pool.RegisterTracker("trace.queryResult")
@@ -49,6 +52,57 @@ const (
var nilResult = model.TraceQueryResult(nil)
+type timestampTagFilterMatcher struct {
+ delegate model.TagFilterMatcher
+ decoder model.TagValueDecoder
+ timeRange timestamp.TimeRange
+ tagName string
+ empty bool
+}
+
+func newTimestampTagFilterMatcher(timeRange timestamp.TimeRange, tagName string, delegate model.TagFilterMatcher) model.TagFilterMatcher {
+ matcher := ×tampTagFilterMatcher{
+ timeRange: timeRange,
+ tagName: tagName,
+ delegate: delegate,
+ decoder: mustDecodeTagValueAndArray,
+ empty: traceTimeRangeEmpty(timeRange),
+ }
+ if delegate != nil && delegate.GetDecoder() != nil {
+ matcher.decoder = delegate.GetDecoder()
+ }
+ return matcher
+}
+
+func (ttfm *timestampTagFilterMatcher) Match(tags []*modelv1.Tag) (bool, error) {
+ if ttfm.empty {
+ return false, nil
+ }
+ for _, tag := range tags {
+ if tag.GetKey() != ttfm.tagName {
+ continue
+ }
+ timestampValue := tag.GetValue().GetTimestamp()
+ if timestampValue == nil || !ttfm.timeRange.Contains(timestampValue.AsTime().UnixNano()) {
+ return false, nil
+ }
+ if ttfm.delegate == nil {
+ return true, nil
+ }
+ return ttfm.delegate.Match(tags)
+ }
+ return false, nil
+}
+
+func (ttfm *timestampTagFilterMatcher) GetDecoder() model.TagValueDecoder {
+ return ttfm.decoder
+}
+
+func traceTimeRangeEmpty(timeRange timestamp.TimeRange) bool {
+ return timeRange.Start.After(timeRange.End) ||
+ timeRange.Start.Equal(timeRange.End) && (!timeRange.IncludeStart || !timeRange.IncludeEnd)
+}
+
type queryOptions struct {
seriesToEntity map[common.SeriesID][]*modelv1.TagValue
schemaTagTypes map[string]pbv1.ValueType
@@ -128,7 +182,12 @@ func (t *trace) Query(ctx context.Context, tqo model.TraceQueryOptions) (model.T
tables := collectTables(segments)
- sidxInstances, sidxQueryRequest, useSIDXStreaming := t.prepareSIDXStreaming(storageTQO, qo, tables)
+ sidxInstances, sidxQueryRequest, useSIDXStreaming, prepareErr := t.prepareSIDXStreaming(storageTQO, qo, tables)
+ if prepareErr != nil {
+ // Assign the outer error so the deferred result cleanup releases segments.
+ err = prepareErr
+ return nil, err
+ }
if len(qo.traceIDs) == 0 && !useSIDXStreaming {
result.Release()
return nilResult, nil
@@ -270,9 +329,12 @@ func (t *trace) prepareSIDXStreaming(
tqo model.TraceQueryOptions,
qo queryOptions,
tables []*tsTable,
-) ([]sidx.SIDX, sidx.QueryRequest, bool) {
+) ([]sidx.SIDX, sidx.QueryRequest, bool, error) {
if len(tqo.TraceIDs) > 0 || tqo.Order == nil {
- return nil, sidx.QueryRequest{}, false
+ return nil, sidx.QueryRequest{}, false, nil
+ }
+ if traceTimeRangeEmpty(*tqo.TimeRange) {
+ return nil, sidx.QueryRequest{}, false, nil
}
sidxName := "default"
@@ -287,32 +349,167 @@ func (t *trace) prepareSIDXStreaming(
}
}
if len(sidxInstances) == 0 {
- return nil, sidx.QueryRequest{}, false
+ return nil, sidx.QueryRequest{}, false, nil
}
- seriesIDs := make([]common.SeriesID, 0, len(qo.seriesToEntity))
- for seriesID := range qo.seriesToEntity {
- seriesIDs = append(seriesIDs, seriesID)
+ selectedRule := t.selectedIndexRule(sidxName)
+ if selectedRule == nil && sidxName != "default" {
+ return nil, sidx.QueryRequest{}, false, fmt.Errorf("selected trace index %q has no schema rule", sidxName)
}
- if len(seriesIDs) == 0 {
- seriesIDs = []common.SeriesID{1}
+ timestampRepresentation := t.selectedIndexTimestampRepresentation(sidxName)
+ seriesIDs := make([]common.SeriesID, 0, len(qo.seriesToEntity))
+ if timestampRepresentation == timestampEntity {
+ var filterErr error
+ seriesIDs, filterErr = filterTimestampEntitySeries(qo.seriesToEntity, selectedRule, t.schema.GetTimestampTagName(), *tqo.TimeRange)
+ if filterErr != nil {
+ return nil, sidx.QueryRequest{}, false, filterErr
+ }
+ if len(seriesIDs) == 0 {
+ return nil, sidx.QueryRequest{}, false, nil
+ }
+ } else {
+ for seriesID := range qo.seriesToEntity {
+ seriesIDs = append(seriesIDs, seriesID)
+ }
+ if len(seriesIDs) == 0 {
+ seriesIDs = []common.SeriesID{1}
+ }
}
+ minTimestamp := tqo.TimeRange.Start.UnixNano()
+ maxTimestamp := tqo.TimeRange.End.UnixNano()
+ minKey, maxKey := tqo.MinVal, tqo.MaxVal
+ if timestampRepresentation == timestampOrderingKey {
+ if !tqo.TimeRange.IncludeStart {
+ if minTimestamp == math.MaxInt64 {
+ return nil, sidx.QueryRequest{}, false, nil
+ }
+ minTimestamp++
+ }
+ if !tqo.TimeRange.IncludeEnd {
+ if maxTimestamp == math.MinInt64 {
+ return nil, sidx.QueryRequest{}, false, nil
+ }
+ maxTimestamp--
+ }
+ if minTimestamp > maxTimestamp {
+ return nil, sidx.QueryRequest{}, false, nil
+ }
+ if minKey < minTimestamp {
+ minKey = minTimestamp
+ }
+ if maxKey > maxTimestamp {
+ maxKey = maxTimestamp
+ }
+ if minKey > maxKey {
+ return nil, sidx.QueryRequest{}, false, nil
+ }
+ }
req := sidx.QueryRequest{
- Filter: tqo.SkippingFilter,
- TagFilter: tqo.TagFilter,
- Order: tqo.Order,
- MaxBatchSize: tqo.MaxTraceSize,
- MinKey: &tqo.MinVal,
- MaxKey: &tqo.MaxVal,
- SeriesIDs: seriesIDs,
- SchemaTagTypes: qo.schemaTagTypes,
+ Filter: tqo.SkippingFilter,
+ TagFilter: tqo.TagFilter,
+ Order: tqo.Order,
+ MaxBatchSize: tqo.MaxTraceSize,
+ MinKey: &minKey,
+ MaxKey: &maxKey,
+ MinTimestamp: &minTimestamp,
+ MaxTimestamp: &maxTimestamp,
+ TimeIncludeStart: tqo.TimeRange.IncludeStart,
+ TimeIncludeEnd: tqo.TimeRange.IncludeEnd,
+ SeriesIDs: seriesIDs,
+ SchemaTagTypes: qo.schemaTagTypes,
+ }
+ if timestampRepresentation == timestampStoredTag {
+ req.TimeTagName = t.schema.GetTimestampTagName()
+ req.TimeTagFilter = newTimestampTagFilterMatcher(*tqo.TimeRange, req.TimeTagName, tqo.TagFilter)
}
if tqo.TagProjection != nil {
req.TagProjection = []model.TagProjection{*tqo.TagProjection}
}
- return sidxInstances, req, true
+ return sidxInstances, req, true, nil
+}
+
+// selectedIndexRule returns the configured rule for an SIDX name.
+func (t *trace) selectedIndexRule(sidxName string) *databasev1.IndexRule {
+ for _, indexRule := range t.GetIndexRules() {
+ if indexRule.GetMetadata().GetName() == sidxName {
+ return indexRule
+ }
+ }
+ return nil
+}
+
+// filterTimestampEntitySeries applies the time range to timestamp values carried
+// by the selected index's series prefix. The logical planner derives the same
+// selected-rule layout before resolving these series.
+func filterTimestampEntitySeries(seriesToEntity map[common.SeriesID][]*modelv1.TagValue,
+ indexRule *databasev1.IndexRule, timestampTagName string, timeRange timestamp.TimeRange,
+) ([]common.SeriesID, error) {
+ if indexRule == nil {
+ return nil, fmt.Errorf("timestamp entity index rule is missing")
+ }
+ if traceTimeRangeEmpty(timeRange) {
+ return nil, nil
+ }
+ timestampPosition := -1
+ for index, tagName := range indexRule.GetTags()[:len(indexRule.GetTags())-1] {
+ if tagName == "" {
+ return nil, fmt.Errorf("timestamp entity index has an empty tag name")
+ }
+ if tagName == timestampTagName {
+ timestampPosition = index
+ break
+ }
+ }
+ if timestampPosition < 0 {
+ return nil, fmt.Errorf("timestamp entity is absent from the selected index prefix")
+ }
+ seriesIDs := make([]common.SeriesID, 0, len(seriesToEntity))
+ for seriesID, entityValues := range seriesToEntity {
+ if len(entityValues) != len(indexRule.GetTags())-1 {
+ return nil, fmt.Errorf("series %d has %d entities, selected index requires %d", seriesID, len(entityValues), len(indexRule.GetTags())-1)
+ }
+ timestampValue := entityValues[timestampPosition].GetTimestamp()
+ if timestampValue != nil && timeRange.Contains(timestampValue.AsTime().UnixNano()) {
+ seriesIDs = append(seriesIDs, seriesID)
+ }
+ }
+ return seriesIDs, nil
+}
+
+type timestampIndexRepresentation uint8
+
+const (
+ timestampOrderingKey timestampIndexRepresentation = iota
+ timestampStoredTag
+ timestampEntity
+)
+
+// selectedIndexTimestampRepresentation identifies where the selected SIDX keeps
+// timestamp. Every index-rule tag is removed from stored tags; only its final tag
+// is the ordering key.
+func (t *trace) selectedIndexTimestampRepresentation(sidxName string) timestampIndexRepresentation {
+ timestampTagName := t.schema.GetTimestampTagName()
+ for _, indexRule := range t.GetIndexRules() {
+ if indexRule.GetMetadata().GetName() != sidxName {
+ continue
+ }
+ tags := indexRule.GetTags()
+ for idx, tagName := range tags {
+ if tagName != timestampTagName {
+ continue
+ }
+ if idx == len(tags)-1 {
+ return timestampOrderingKey
+ }
+ return timestampEntity
+ }
+ return timestampStoredTag
+ }
+ // A default index is timestamp ordered by the logical planner. Unknown named
+ // indexes must not infer a stored timestamp tag and silently filter all rows.
+ return timestampOrderingKey
}
type queryResult struct {
diff --git a/banyand/trace/query_test.go b/banyand/trace/query_test.go
index 24c127973..db4f439e5 100644
--- a/banyand/trace/query_test.go
+++ b/banyand/trace/query_test.go
@@ -26,8 +26,10 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
+ "google.golang.org/protobuf/types/known/timestamppb"
"github.com/apache/skywalking-banyandb/api/common"
+ 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/banyand/protector"
"github.com/apache/skywalking-banyandb/pkg/fs"
@@ -490,3 +492,95 @@ func allPartsFlushed(tst *tsTable) func() bool {
return true
}
}
+
+func TestTimestampTagFilterMatcherHonorsRangeBeforeDelegate(t *testing.T) {
+ instant := time.Unix(0, 100)
+ matcher := newTimestampTagFilterMatcher(timestamp.NewTimeRange(instant, instant, true, true), "timestamp", nil)
+ matched, matchErr := matcher.Match([]*modelv1.Tag{{
+ Key: "timestamp",
+ Value: &modelv1.TagValue{Value: &modelv1.TagValue_Timestamp{
+ Timestamp: timestamppb.New(instant),
+ }},
+ }})
+ require.NoError(t, matchErr)
+ require.True(t, matched)
+
+ exclusiveMatcher := newTimestampTagFilterMatcher(timestamp.NewTimeRange(instant, instant, true, false), "timestamp", nil)
+ matched, matchErr = exclusiveMatcher.Match([]*modelv1.Tag{{
+ Key: "timestamp",
+ Value: &modelv1.TagValue{Value: &modelv1.TagValue_Timestamp{
+ Timestamp: timestamppb.New(instant),
+ }},
+ }})
+ require.NoError(t, matchErr)
+ require.False(t, matched)
+}
+
+func TestTimestampTagFilterMatcherDelegatesOnlyForEligibleRows(t *testing.T) {
+ start := time.Unix(0, 100)
+ delegateCalls := 0
+ delegate := &traceTestTagMatcher{match: func(_ []*modelv1.Tag) (bool, error) {
+ delegateCalls++
+ return true, nil
+ }}
+ matcher := newTimestampTagFilterMatcher(timestamp.NewInclusiveTimeRange(start, start.Add(time.Nanosecond)), "timestamp", delegate)
+ for _, testCase := range []struct {
+ name string
+ nanos int64
+ matched bool
+ }{
+ {name: "before", nanos: 99, matched: false},
+ {name: "start", nanos: 100, matched: true},
+ {name: "end", nanos: 101, matched: true},
+ {name: "after", nanos: 102, matched: false},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ matched, matchErr := matcher.Match([]*modelv1.Tag{{
+ Key: "timestamp",
+ Value: &modelv1.TagValue{Value: &modelv1.TagValue_Timestamp{
+ Timestamp: timestamppb.New(time.Unix(0, testCase.nanos)),
+ }},
+ }})
+ require.NoError(t, matchErr)
+ require.Equal(t, testCase.matched, matched)
+ })
+ }
+ require.Equal(t, 2, delegateCalls)
+}
+
+func TestFilterTimestampEntitySeries(t *testing.T) {
+ indexRule := &databasev1.IndexRule{Tags: []string{"service", "timestamp", "duration"}}
+ series := map[common.SeriesID][]*modelv1.TagValue{
+ 1: {strTagValue("a"), timestampTagValue(0, 100)},
+ 2: {strTagValue("a"), timestampTagValue(0, 200)},
+ }
+ seriesIDs, filterErr := filterTimestampEntitySeries(series, indexRule, "timestamp", timestamp.NewInclusiveTimeRange(time.Unix(0, 100), time.Unix(0, 100)))
+ require.NoError(t, filterErr)
+ require.Equal(t, []common.SeriesID{1}, seriesIDs)
+
+ seriesIDs, filterErr = filterTimestampEntitySeries(series, indexRule, "timestamp", timestamp.NewInclusiveTimeRange(time.Unix(0, 300), time.Unix(0, 300)))
+ require.NoError(t, filterErr)
+ require.Empty(t, seriesIDs)
+
+ emptyRange := timestamp.NewTimeRange(time.Unix(0, 100), time.Unix(0, 100), true, false)
+ seriesIDs, filterErr = filterTimestampEntitySeries(series, indexRule, "timestamp", emptyRange)
+ require.NoError(t, filterErr)
+ require.Empty(t, seriesIDs)
+
+ inconsistentSeries := map[common.SeriesID][]*modelv1.TagValue{1: {strTagValue("a")}}
+ inconsistentRange := timestamp.NewInclusiveTimeRange(time.Unix(0, 0), time.Unix(0, 1))
+ _, filterErr = filterTimestampEntitySeries(inconsistentSeries, indexRule, "timestamp", inconsistentRange)
+ require.Error(t, filterErr)
+}
+
+type traceTestTagMatcher struct {
+ match func([]*modelv1.Tag) (bool, error)
+}
+
+func (tttm *traceTestTagMatcher) Match(tags []*modelv1.Tag) (bool, error) {
+ return tttm.match(tags)
+}
+
+func (tttm *traceTestTagMatcher) GetDecoder() model.TagValueDecoder {
+ return mustDecodeTagValueAndArray
+}
diff --git a/banyand/trace/query_time_benchmark_test.go b/banyand/trace/query_time_benchmark_test.go
new file mode 100644
index 000000000..8a7df55ad
--- /dev/null
+++ b/banyand/trace/query_time_benchmark_test.go
@@ -0,0 +1,355 @@
+// 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. The 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 trace
+
+import (
+ "context"
+ "fmt"
+ "math/bits"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-banyandb/api/common"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ "github.com/apache/skywalking-banyandb/banyand/internal/sidx"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/convert"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+ "github.com/apache/skywalking-banyandb/pkg/index"
+ pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+ "github.com/apache/skywalking-banyandb/pkg/query/model"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+const (
+ benchmarkTimestampTag = "start_time"
+ benchmarkServiceTag = "service"
+ benchmarkServiceValue = "benchmark"
+)
+
+// BenchmarkSIDXPartScanTimestampFilter measures the real SIDX part scan path.
+// Forced cases clear only the request's timestamp selection hints. This is
+// deliberately benchmark-local: the production TimeTagFilter still carries the
+// exact range and must scan the same part. Auto cases retain the hints, allowing
+// the production coverage-bypass decision to omit that matcher.
+func BenchmarkSIDXPartScanTimestampFilter(b *testing.B) {
+ previousMaxProcs := runtime.GOMAXPROCS(2)
+ b.Cleanup(func() {
+ runtime.GOMAXPROCS(previousMaxProcs)
+ })
+
+ fixtures := []struct {
+ name string
+ rows int
+ reopen bool
+ order modelv1.Sort
+ }{
+ {name: "Memory/10K/ASC", rows: 10_000, order: modelv1.Sort_SORT_ASC},
+ {name: "FlushedReopened/10K/DESC", rows: 10_000, reopen: true, order: modelv1.Sort_SORT_DESC},
+ {name: "FlushedReopened/100K/ASC", rows: 100_000, reopen: true, order: modelv1.Sort_SORT_ASC},
+ }
+ for _, fixtureConfig := range fixtures {
+ b.Run(fixtureConfig.name, func(b *testing.B) {
+ fixture := newTimestampScanBenchmarkFixture(b, fixtureConfig.rows, fixtureConfig.reopen, false)
+ fixture.runFullCoverage(b, fixtureConfig.order)
+ partialFixture := newTimestampScanBenchmarkFixture(b, fixtureConfig.rows, fixtureConfig.reopen, true)
+ partialFixture.runPartialOverlap(b, fixtureConfig.order)
+ })
+ }
+}
+
+type timestampScanBenchmarkFixture struct {
+ instance sidx.SIDX
+ rows int
+ minTime int64
+ maxTime int64
+}
+
+func newTimestampScanBenchmarkFixture(b *testing.B, rows int, reopen, conservativeEnvelope bool) *timestampScanBenchmarkFixture {
+ b.Helper()
+
+ root := b.TempDir()
+ fileSystem := fs.NewLocalFileSystem()
+ options, optionsErr := sidx.NewOptions(root, protector.Nop{})
+ if optionsErr != nil {
+ b.Fatalf("create SIDX options: %v", optionsErr)
+ }
+ instance, instanceErr := sidx.NewSIDX(fileSystem, options)
+ if instanceErr != nil {
+ b.Fatalf("create SIDX: %v", instanceErr)
+ }
+
+ const baseTimestamp = int64(1_700_000_000_000_000_000)
+ minTime := baseTimestamp
+ maxTime := baseTimestamp + int64(rows) - 1
+ requests := make([]sidx.WriteRequest, 0, rows)
+ for row := 0; row < rows; row++ {
+ // The key deliberately has no timestamp semantics. Reversing its bits
+ // gives a deterministic ordering independent of the timestamp column.
+ key := int64(bits.Reverse64(uint64(row)))
+ rowTimestamp := minTime + int64(row)
+ requests = append(requests, sidx.WriteRequest{
+ SeriesID: common.SeriesID(1),
+ Key: key,
+ Data: []byte(fmt.Sprintf("trace-%06d", row)),
+ Tags: []sidx.Tag{
+ {Name: benchmarkTimestampTag, Value: convert.Int64ToBytes(rowTimestamp), ValueType: pbv1.ValueTypeTimestamp},
+ {Name: benchmarkServiceTag, Value: []byte(benchmarkServiceValue), ValueType: pbv1.ValueTypeStr},
+ },
+ })
+ }
+ partMinTime, partMaxTime := minTime, maxTime
+ if conservativeEnvelope {
+ partMinTime -= int64(rows) * 2
+ partMaxTime += int64(rows) * 2
+ }
+ memPart, convertErr := instance.ConvertToMemPart(requests, 1, &partMinTime, &partMaxTime)
+ if convertErr != nil {
+ b.Fatalf("convert benchmark part: %v", convertErr)
+ }
+ instance.IntroduceMemPart(1, memPart)
+ snapshot := instance.CurrentSnapshot()
+ if snapshot == nil {
+ b.Fatal("published benchmark part is not visible")
+ }
+ snapshot.DecRef()
+
+ if reopen {
+ flushIntroduction, flushErr := instance.Flush(map[uint64]struct{}{1: {}})
+ if flushErr != nil {
+ b.Fatalf("flush benchmark part: %v", flushErr)
+ }
+ if flushIntroduction == nil {
+ b.Fatal("flush benchmark part: missing introduction")
+ }
+ instance.IntroduceFlushed(flushIntroduction)
+ flushIntroduction.Release()
+ if closeErr := instance.Close(); closeErr != nil {
+ b.Fatalf("close flushed SIDX: %v", closeErr)
+ }
+
+ options.AvailablePartIDs = []uint64{1}
+ instance, instanceErr = sidx.NewSIDX(fileSystem, options)
+ if instanceErr != nil {
+ b.Fatalf("reopen flushed SIDX: %v", instanceErr)
+ }
+ }
+ b.Cleanup(func() {
+ if closeErr := instance.Close(); closeErr != nil {
+ b.Errorf("close benchmark SIDX: %v", closeErr)
+ }
+ })
+
+ return ×tampScanBenchmarkFixture{instance: instance, rows: rows, minTime: minTime, maxTime: maxTime}
+}
+
+func (f *timestampScanBenchmarkFixture) runFullCoverage(b *testing.B, order modelv1.Sort) {
+ b.Helper()
+
+ for _, hasExistingPredicate := range []bool{false, true} {
+ predicateName := "NoExistingPredicate"
+ if hasExistingPredicate {
+ predicateName = "ExistingServicePredicate"
+ }
+ for _, projectTimestamp := range []bool{false, true} {
+ projectionName := "TimestampOmitted"
+ if projectTimestamp {
+ projectionName = "TimestampProjected"
+ }
+ baseRequest := f.request(order, f.minTime, f.maxTime, hasExistingPredicate, projectTimestamp)
+ matcher := newTimestampTagFilterMatcher(
+ timestamp.NewTimeRange(time.Unix(0, f.minTime), time.Unix(0, f.maxTime), true, true),
+ benchmarkTimestampTag,
+ baseRequest.TagFilter,
+ )
+ variants := []struct {
+ name string
+ request sidx.QueryRequest
+ }{
+ {name: "NoTimeFilter", request: baseRequest},
+ {name: "ForcedTimeFilter", request: f.forcedRequest(baseRequest, matcher)},
+ {name: "AutoCoverageBypass", request: f.autoRequest(baseRequest, matcher)},
+ }
+ baseline := f.mustQueryRows(b, variants[0].request)
+ if len(baseline) != f.rows {
+ b.Fatalf("full-coverage baseline returned %d rows, want %d", len(baseline), f.rows)
+ }
+ for _, variant := range variants {
+ rows := f.mustQueryRows(b, variant.request)
+ assertBenchmarkRowsEqual(b, baseline, rows)
+ f.runRequest(b, predicateName+"/"+projectionName+"/"+variant.name, variant.request, len(rows))
+ }
+ }
+ }
+}
+
+func (f *timestampScanBenchmarkFixture) runPartialOverlap(b *testing.B, order modelv1.Sort) {
+ b.Helper()
+
+ for _, matchPercent := range []int{0, 10, 50, 100} {
+ minTime, maxTime := f.partialRange(matchPercent)
+ baseRequest := f.request(order, minTime, maxTime, false, false)
+ matcher := newTimestampTagFilterMatcher(
+ timestamp.NewTimeRange(time.Unix(0, minTime), time.Unix(0, maxTime), true, true),
+ benchmarkTimestampTag,
+ nil,
+ )
+ forcedRequest := f.forcedRequest(baseRequest, matcher)
+ autoRequest := f.autoRequest(baseRequest, matcher)
+ forcedRows := f.mustQueryRows(b, forcedRequest)
+ autoRows := f.mustQueryRows(b, autoRequest)
+ assertBenchmarkRowsEqual(b, forcedRows, autoRows)
+ wantRows := f.rows * matchPercent / 100
+ if len(forcedRows) != wantRows {
+ b.Fatalf("partial %d%% returned %d rows, want %d", matchPercent, len(forcedRows), wantRows)
+ }
+ f.runRequest(b, fmt.Sprintf("Partial/%dPercent/ForcedTimeFilter", matchPercent), forcedRequest, len(forcedRows))
+ f.runRequest(b, fmt.Sprintf("Partial/%dPercent/AutoSelectiveFilter", matchPercent), autoRequest, len(autoRows))
+ }
+}
+
+func (f *timestampScanBenchmarkFixture) partialRange(matchPercent int) (int64, int64) {
+ if matchPercent == 0 {
+ // This intentionally overlaps the conservative envelope used by the
+ // benchmark part selection fixture, while containing no row timestamp.
+ return f.minTime - int64(f.rows), f.minTime - 1
+ }
+ matchingRows := f.rows * matchPercent / 100
+ return f.minTime, f.minTime + int64(matchingRows) - 1
+}
+
+func (f *timestampScanBenchmarkFixture) request(order modelv1.Sort, minTime, maxTime int64, existingPredicate, projectTimestamp bool) sidx.QueryRequest {
+ request := sidx.QueryRequest{
+ SeriesIDs: []common.SeriesID{1},
+ Order: &index.OrderBy{Sort: order},
+ MinTimestamp: &minTime,
+ MaxTimestamp: &maxTime,
+ TimeIncludeStart: true,
+ TimeIncludeEnd: true,
+ MaxBatchSize: 1024,
+ }
+ if existingPredicate {
+ request.TagFilter = benchmarkServiceMatcher{}
+ request.FilterTagNames = []string{benchmarkServiceTag}
+ }
+ // Keep this harmless non-timestamp column projected in every full-scan case.
+ // Without it an empty projection instructs SIDX to load all tags, including
+ // the timestamp column that TimestampOmitted is intended to avoid.
+ projectedTags := []string{benchmarkServiceTag}
+ if projectTimestamp {
+ projectedTags = append(projectedTags, benchmarkTimestampTag)
+ }
+ if len(projectedTags) > 0 {
+ request.TagProjection = []model.TagProjection{{Names: projectedTags}}
+ }
+ return request
+}
+
+func (f *timestampScanBenchmarkFixture) forcedRequest(request sidx.QueryRequest, matcher model.TagFilterMatcher) sidx.QueryRequest {
+ // Clear only selection hints in this benchmark request. The production time
+ // matcher retains the exact range, while partNeedsTimeFilter safely selects
+ // the row predicate for this same immutable part.
+ request.MinTimestamp = nil
+ request.MaxTimestamp = nil
+ request.TimeTagFilter = matcher
+ request.TimeTagName = benchmarkTimestampTag
+ return request
+}
+
+func (f *timestampScanBenchmarkFixture) autoRequest(request sidx.QueryRequest, matcher model.TagFilterMatcher) sidx.QueryRequest {
+ request.TimeTagFilter = matcher
+ request.TimeTagName = benchmarkTimestampTag
+ return request
+}
+
+func (f *timestampScanBenchmarkFixture) mustQueryRows(b *testing.B, request sidx.QueryRequest) map[string]int64 {
+ b.Helper()
+
+ responses, queryErr := f.instance.QuerySync(context.Background(), request)
+ if queryErr != nil {
+ b.Fatalf("query benchmark part: %v", queryErr)
+ }
+ rows := make(map[string]int64, f.rows)
+ for _, response := range responses {
+ if response == nil {
+ continue
+ }
+ if response.Error != nil {
+ b.Fatalf("query response error: %v", response.Error)
+ }
+ for rowIndex, data := range response.Data {
+ rows[string(data)] = response.Keys[rowIndex]
+ }
+ }
+ return rows
+}
+
+func (f *timestampScanBenchmarkFixture) runRequest(b *testing.B, name string, request sidx.QueryRequest, outputRows int) {
+ b.Helper()
+ b.Run(name, func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for iteration := 0; iteration < b.N; iteration++ {
+ responses, queryErr := f.instance.QuerySync(context.Background(), request)
+ if queryErr != nil {
+ b.Fatalf("query benchmark part: %v", queryErr)
+ }
+ rows := 0
+ for _, response := range responses {
+ if response != nil {
+ rows += response.Len()
+ }
+ }
+ if rows != outputRows {
+ b.Fatalf("query returned %d rows, want %d", rows, outputRows)
+ }
+ }
+ b.StopTimer()
+ if elapsed := b.Elapsed(); elapsed > 0 {
+ b.ReportMetric(float64(f.rows*b.N)/elapsed.Seconds(), "input_rows/s")
+ }
+ b.ReportMetric(float64(outputRows), "output_rows/op")
+ })
+}
+
+func assertBenchmarkRowsEqual(b *testing.B, want, got map[string]int64) {
+ b.Helper()
+ if len(got) != len(want) {
+ b.Fatalf("row count mismatch: got %d, want %d", len(got), len(want))
+ }
+ for traceID, wantKey := range want {
+ gotKey, ok := got[traceID]
+ if !ok || gotKey != wantKey {
+ b.Fatalf("row mismatch for %q: got key %d present %t, want key %d", traceID, gotKey, ok, wantKey)
+ }
+ }
+}
+
+type benchmarkServiceMatcher struct{}
+
+func (benchmarkServiceMatcher) Match(tags []*modelv1.Tag) (bool, error) {
+ for _, tag := range tags {
+ if tag.GetKey() == benchmarkServiceTag && tag.GetValue().GetStr().GetValue() == benchmarkServiceValue {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (benchmarkServiceMatcher) GetDecoder() model.TagValueDecoder {
+ return mustDecodeTagValueAndArray
+}
diff --git a/banyand/trace/streaming_pipeline.go b/banyand/trace/streaming_pipeline.go
index 81098197e..766bd2787 100644
--- a/banyand/trace/streaming_pipeline.go
+++ b/banyand/trace/streaming_pipeline.go
@@ -304,8 +304,8 @@ type sidxStreamRunner struct {
errEvents chan sidxStreamError
heap *sidxStreamHeap
seenTraceIDs map[string]struct{}
- req sidx.QueryRequest
batch traceBatch
+ req sidx.QueryRequest
errWg sync.WaitGroup
nextSeq int
total atomic.Int64
diff --git a/pkg/query/logical/trace/index_filter_test.go b/pkg/query/logical/trace/index_filter_test.go
index 2f6843b2c..acccda4a6 100644
--- a/pkg/query/logical/trace/index_filter_test.go
+++ b/pkg/query/logical/trace/index_filter_test.go
@@ -21,6 +21,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "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"
@@ -433,3 +434,47 @@ func TestBuildFilterDeduplicatesCollectedTagNames(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []string{"service_id"}, collectedTagNamesOR, "service_id should appear only once")
}
+
+func TestTraceAnalyzerUsesSelectedIndexRuleEntityLayout(t *testing.T) {
+ trace := &databasev1.Trace{
+ Metadata: &commonv1.Metadata{Name: "trace", Group: "default"},
+ TraceIdTagName: "trace_id",
+ SpanIdTagName: "span_id",
+ TimestampTagName: "timestamp",
+ Tags: []*databasev1.TraceTagSpec{
+ {Name: "trace_id", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "span_id", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "service", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "region", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "duration", Type: databasev1.TagType_TAG_TYPE_INT},
+ },
+ }
+ firstRule := &databasev1.IndexRule{Metadata: &commonv1.Metadata{Name: "first_rule"}, Tags: []string{"service", "duration"}}
+ compositeRule := &databasev1.IndexRule{Metadata: &commonv1.Metadata{Name: "custom_composite"}, Tags: []string{"service", "region", "duration"}}
+ singleRule := &databasev1.IndexRule{Metadata: &commonv1.Metadata{Name: "custom_single"}, Tags: []string{"duration"}}
+ schema, schemaErr := BuildSchema(trace, []*databasev1.IndexRule{firstRule, compositeRule, singleRule})
+ assert.NoError(t, schemaErr)
+
+ for _, testCase := range []struct {
+ name string
+ indexRuleName string
+ wantEntityLen int
+ }{
+ {name: "non_first_composite", indexRuleName: "custom_composite", wantEntityLen: 2},
+ {name: "non_first_single", indexRuleName: "custom_single", wantEntityLen: 0},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ plan, analyzeErr := (&unresolvedTraceTagFilter{
+ traceIDTagName: "trace_id",
+ spanIDTagName: "span_id",
+ orderByTag: "duration",
+ indexRuleName: testCase.indexRuleName,
+ }).Analyze(schema)
+ require.NoError(t, analyzeErr)
+ scan, ok := plan.(*localScan)
+ require.True(t, ok)
+ require.Len(t, scan.entities, 1)
+ assert.Len(t, scan.entities[0], testCase.wantEntityLen)
+ })
+ }
+}
diff --git a/pkg/query/logical/trace/trace_analyzer.go b/pkg/query/logical/trace/trace_analyzer.go
index 74c97690a..d84312e1b 100644
--- a/pkg/query/logical/trace/trace_analyzer.go
+++ b/pkg/query/logical/trace/trace_analyzer.go
@@ -245,6 +245,7 @@ func parseTraceTags(criteria *tracev1.QueryRequest, metadata *commonv1.Metadata,
traceIDTagName: traceIDTagName,
spanIDTagName: spanIDTagName,
orderByTag: orderByTag,
+ indexRuleName: criteria.GetOrderBy().GetIndexRuleName(),
timestampTagName: timestampTagName,
groupIndex: groupIndex,
}
diff --git a/pkg/query/logical/trace/trace_plan_tag_filter.go b/pkg/query/logical/trace/trace_plan_tag_filter.go
index 25b06f3fb..f04031340 100644
--- a/pkg/query/logical/trace/trace_plan_tag_filter.go
+++ b/pkg/query/logical/trace/trace_plan_tag_filter.go
@@ -46,6 +46,7 @@ type unresolvedTraceTagFilter struct {
traceIDTagName string
spanIDTagName string
orderByTag string
+ indexRuleName string
timestampTagName string
projectionTags [][]*logical.Tag
groupIndex int
@@ -54,6 +55,9 @@ type unresolvedTraceTagFilter struct {
func (uis *unresolvedTraceTagFilter) Analyze(s logical.Schema) (logical.Plan, error) {
ctx := newTraceAnalyzerContext(s)
entityList := s.EntityList()
+ if ok, selectedRule := s.IndexRuleDefined(uis.indexRuleName); ok && len(selectedRule.GetTags()) > 0 {
+ entityList = selectedRule.GetTags()[:len(selectedRule.GetTags())-1]
+ }
entityDict := make(map[string]int)
entity := make([]*modelv1.TagValue, len(entityList))
for idx, e := range entityList {
@@ -137,7 +141,7 @@ func (uis *unresolvedTraceTagFilter) Analyze(s logical.Schema) (logical.Plan, er
var tagFilter logical.TagFilter
if uis.criteria != nil {
var orderByTags []string
- if ok, indexRule := s.IndexRuleDefined(uis.orderByTag); ok {
+ if ok, indexRule := s.IndexRuleDefined(uis.indexRuleName); ok {
orderByTags = indexRule.Tags
}
skippedTagNames := make([]string, 0, len(orderByTags)+2)
diff --git a/test/cases/trace/data/want/time_range_trace_001.yml b/test/cases/trace/data/want/time_range_trace_001.yml
new file mode 100644
index 000000000..2e84191da
--- /dev/null
+++ b/test/cases/trace/data/want/time_range_trace_001.yml
@@ -0,0 +1,30 @@
+# 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.
+
+traces:
+ - spans:
+ - span: trace_001_span_1
+ spanId: span_001_1
+ - span: trace_001_span_2
+ spanId: span_001_2
+ - span: trace_001_span_3
+ spanId: span_001_3
+ - span: trace_001_span_4
+ spanId: span_001_4
+ - span: trace_001_span_5
+ spanId: span_001_5
+ traceId: trace_001
diff --git a/test/cases/trace/trace.go b/test/cases/trace/trace.go
index 75c79ee68..7823f0f8e 100644
--- a/test/cases/trace/trace.go
+++ b/test/cases/trace/trace.go
@@ -49,6 +49,19 @@ var traceEntries = []any{
g.Entry("query by empty span ids", helpers.Args{Input: "in_empty_span_ids", Duration: 1 * time.Hour, WantEmpty: true}),
g.Entry("order by timestamp", helpers.Args{Input: "order_timestamp_desc", Duration: 1 * time.Hour}),
g.Entry("order by duration", helpers.Args{Input: "order_duration_desc", Duration: 1 * time.Hour}),
+ // The seed occupies the first seconds of each minute. This gap stays within
+ // the populated segment even when BaseTime is the last minute of the day.
+ g.Entry("timestamp order excludes same-segment time gap",
+ helpers.Args{Input: "order_timestamp_desc", Offset: 20 * time.Second, Duration: 10 * time.Second, WantEmpty: true}),
+ g.Entry("duration order excludes same-segment time gap",
+ helpers.Args{Input: "order_duration_desc", Offset: 20 * time.Second, Duration: 10 * time.Second, WantEmpty: true}),
+ g.Entry("duration order filters time before pagination",
+ helpers.Args{
+ Input: "gen_feat_order_duration_asc_limit2_offset1_proj_explicit_1",
+ Offset: 20 * time.Second, Duration: 10 * time.Second, WantEmpty: true,
+ }),
+ g.Entry("duration order selects a qualifying span and preserves its complete trace",
+ helpers.Args{Input: "order_duration_desc", Want: "time_range_trace_001", Offset: 750 * time.Millisecond, Duration: 500 * time.Millisecond}),
g.Entry("duration range 10-1000 order by timestamp",
helpers.Args{Input: "duration_range_order_timestamp", Duration: 1 * time.Hour}),
g.Entry("duration range with bound parameters",
(skywalking-banyandb) 05/15: Fix FODC proxy `/metrics` returning partial data or timing out when concurrent scrapes overlap. (#1328)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit ee098a7ce33d7cccfa465b8c8ac1d77f2d2b074a
Author: mrproliu <74...@qq.com>
AuthorDate: Mon Sep 7 06:51:11 2026 +0800
Fix FODC proxy `/metrics` returning partial data or timing out when concurrent scrapes overlap. (#1328)
(cherry picked from commit a48ab167d00e1bce17f4c1ec2b3359a19561c4ca)
---
fodc/proxy/internal/grpc/service.go | 3 +
.../proxy/internal/grpc/service_concurrent_test.go | 77 ++++++++++
fodc/proxy/internal/metrics/aggregator.go | 128 ++++++++++++-----
.../internal/metrics/aggregator_concurrent_test.go | 156 +++++++++++++++++++++
fodc/proxy/internal/metrics/aggregator_test.go | 31 ++--
5 files changed, 340 insertions(+), 55 deletions(-)
diff --git a/fodc/proxy/internal/grpc/service.go b/fodc/proxy/internal/grpc/service.go
index 09ea4c5d1..66c1efe97 100644
--- a/fodc/proxy/internal/grpc/service.go
+++ b/fodc/proxy/internal/grpc/service.go
@@ -58,6 +58,7 @@ type agentConnection struct {
agentID string
mu sync.RWMutex
fetchMu sync.Mutex
+ metricsSendMu sync.Mutex
}
// fetchWaiter delivers the chunks of one in-flight download to the HTTP handler that
@@ -117,6 +118,8 @@ func (ac *agentConnection) sendMetricsRequest(resp *fodcv1.StreamMetricsResponse
if ac.metricsStream == nil {
return fmt.Errorf("metrics stream not established for agent ID: %s", ac.agentID)
}
+ ac.metricsSendMu.Lock()
+ defer ac.metricsSendMu.Unlock()
if sendErr := ac.metricsStream.Send(resp); sendErr != nil {
return fmt.Errorf("failed to send metrics request: %w", sendErr)
}
diff --git a/fodc/proxy/internal/grpc/service_concurrent_test.go b/fodc/proxy/internal/grpc/service_concurrent_test.go
new file mode 100644
index 000000000..9c6ba5b83
--- /dev/null
+++ b/fodc/proxy/internal/grpc/service_concurrent_test.go
@@ -0,0 +1,77 @@
+// 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"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ fodcv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/fodc/v1"
+)
+
+// overlapDetectingStream reports whether two goroutines were ever inside Send at the same
+// time. It deliberately does not lock, so it observes the caller's serialization rather
+// than imposing its own.
+type overlapDetectingStream struct {
+ *mockStreamMetricsServer
+ inFlight atomic.Int32
+ overlaps atomic.Int32
+}
+
+func (s *overlapDetectingStream) Send(_ *fodcv1.StreamMetricsResponse) error {
+ if s.inFlight.Add(1) > 1 {
+ s.overlaps.Add(1)
+ }
+ // Widen the window so an unserialized caller is caught reliably.
+ time.Sleep(time.Millisecond)
+ s.inFlight.Add(-1)
+ return nil
+}
+
+// TestSendMetricsRequest_SerializesStreamSends pins the gRPC contract: a stream may have
+// one sender at a time. Overlapping /metrics scrapes each ask every agent for metrics
+// independently, so sendMetricsRequest is reached concurrently for the same connection.
+// ac.mu is held for reading there and therefore cannot serialize the senders.
+func TestSendMetricsRequest_SerializesStreamSends(t *testing.T) {
+ stream := &overlapDetectingStream{
+ mockStreamMetricsServer: newMockStreamMetricsServer(context.Background()),
+ }
+ conn := &agentConnection{agentID: "agent-1", metricsStream: stream}
+
+ const senders = 16
+ var wg sync.WaitGroup
+ start := make(chan struct{})
+ for i := 0; i < senders; i++ {
+ wg.Add(1)
+ //panicdiag:allow-rawgo test-only concurrency driver; a panic here must fail the test loudly rather than be recovered and hidden
+ go func() {
+ defer wg.Done()
+ <-start
+ require.NoError(t, conn.sendMetricsRequest(&fodcv1.StreamMetricsResponse{}))
+ }()
+ }
+ close(start)
+ wg.Wait()
+
+ require.Zero(t, stream.overlaps.Load(), "concurrent Send on one gRPC stream is not supported")
+}
diff --git a/fodc/proxy/internal/metrics/aggregator.go b/fodc/proxy/internal/metrics/aggregator.go
index 3757dec7f..3be6b863d 100644
--- a/fodc/proxy/internal/metrics/aggregator.go
+++ b/fodc/proxy/internal/metrics/aggregator.go
@@ -65,12 +65,19 @@ type Filter struct {
AgentIDs []string
}
+// agentSubscription is one collection's claim on an agent's next metrics push.
+type agentSubscription struct {
+ collectCh chan []*AggregatedMetric
+ subID uint64
+}
+
// Aggregator aggregates and enriches metrics from all agents.
type Aggregator struct {
registry *registry.AgentRegistry
logger *logger.Logger
grpcService RequestSender
- collecting map[string]chan []*AggregatedMetric
+ collecting map[string]map[uint64]chan []*AggregatedMetric
+ nextSubID uint64
mu sync.RWMutex
collectingMu sync.RWMutex
}
@@ -86,7 +93,7 @@ func NewAggregator(registry *registry.AgentRegistry, grpcService RequestSender,
registry: registry,
grpcService: grpcService,
logger: logger,
- collecting: make(map[string]chan []*AggregatedMetric),
+ collecting: make(map[string]map[uint64]chan []*AggregatedMetric),
}
}
@@ -161,24 +168,75 @@ func (ma *Aggregator) ProcessMetricsFromAgent(ctx context.Context, agentID strin
aggregatedMetrics = append(aggregatedMetrics, aggregatedMetric)
}
+ // Deliver to every collection waiting on this agent. Holding the read lock keeps
+ // unsubscribe (which closes the channel) from running between the lookup and the
+ // send, so this can never send on a closed channel.
ma.collectingMu.RLock()
defer ma.collectingMu.RUnlock()
- collectCh, exists := ma.collecting[agentID]
+ subscribers := ma.collecting[agentID]
+ if len(subscribers) == 0 {
+ // Expected whenever scrapes overlap: each one asks the agent separately, so the
+ // second reply arrives after the first has already satisfied every subscriber.
+ ma.logger.Debug().Str("agent_id", agentID).Msg("Metrics collection channel not found, dropping metrics")
+ return nil
+ }
- if exists {
+ // Every subscriber gets the same slice; consumers must treat it as read-only.
+ // No send can block - each channel is buffered and the select has a default - so the
+ // loop always runs to completion. Bailing out on a canceled context part-way through
+ // would strand the subscribers not yet visited, and each of those collections would
+ // then wait out its whole timeout for data that had already arrived.
+ for _, collectCh := range subscribers {
select {
case collectCh <- aggregatedMetrics:
- case <-ctx.Done():
- return ctx.Err()
default:
ma.logger.Warn().Str("agent_id", agentID).Msg("Metrics collection channel full, dropping metrics")
}
- } else {
- ma.logger.Warn().Str("agent_id", agentID).Msg("Metrics collection channel not found, dropping metrics")
}
- return nil
+ return ctx.Err()
+}
+
+// subscribe registers a channel to receive this agent's next metrics push and returns
+// the subscription ID needed to release it. Each collection subscribes separately, so a
+// scrape that starts while another is in flight can never take over - or, on cleanup,
+// close - the other one's channel.
+func (ma *Aggregator) subscribe(agentID string) (uint64, chan []*AggregatedMetric) {
+ collectCh := make(chan []*AggregatedMetric, 1)
+
+ ma.collectingMu.Lock()
+ defer ma.collectingMu.Unlock()
+
+ ma.nextSubID++
+ subID := ma.nextSubID
+ subscribers, exists := ma.collecting[agentID]
+ if !exists {
+ subscribers = make(map[uint64]chan []*AggregatedMetric)
+ ma.collecting[agentID] = subscribers
+ }
+ subscribers[subID] = collectCh
+
+ return subID, collectCh
+}
+
+// unsubscribe releases one subscription and closes its channel. It is a no-op if the
+// subscription is already gone.
+func (ma *Aggregator) unsubscribe(agentID string, subID uint64) {
+ ma.collectingMu.Lock()
+ defer ma.collectingMu.Unlock()
+
+ subscribers, exists := ma.collecting[agentID]
+ if !exists {
+ return
+ }
+ if collectCh, ok := subscribers[subID]; ok {
+ delete(subscribers, subID)
+ close(collectCh)
+ }
+ if len(subscribers) == 0 {
+ delete(ma.collecting, agentID)
+ }
}
// CollectMetricsFromAgents requests metrics from all agents (or filtered agents) when external client queries.
@@ -188,26 +246,18 @@ func (ma *Aggregator) CollectMetricsFromAgents(ctx context.Context, filter *Filt
return []*AggregatedMetric{}, nil
}
- collectChs := make(map[string]chan []*AggregatedMetric)
- agentIDs := make([]string, 0, len(agents))
- ma.collectingMu.Lock()
+ agents = dedupeAgents(agents)
+
+ subscriptions := make(map[string]agentSubscription, len(agents))
for _, agentInfo := range agents {
- collectCh := make(chan []*AggregatedMetric, 1)
- collectChs[agentInfo.AgentID] = collectCh
- ma.collecting[agentInfo.AgentID] = collectCh
- agentIDs = append(agentIDs, agentInfo.AgentID)
+ subID, collectCh := ma.subscribe(agentInfo.AgentID)
+ subscriptions[agentInfo.AgentID] = agentSubscription{subID: subID, collectCh: collectCh}
}
- ma.collectingMu.Unlock()
defer func() {
- ma.collectingMu.Lock()
- for _, agentID := range agentIDs {
- if collectCh, exists := ma.collecting[agentID]; exists {
- close(collectCh)
- delete(ma.collecting, agentID)
- }
+ for agentID, sub := range subscriptions {
+ ma.unsubscribe(agentID, sub.subID)
}
- ma.collectingMu.Unlock()
}()
for _, agentInfo := range agents {
@@ -222,13 +272,8 @@ func (ma *Aggregator) CollectMetricsFromAgents(ctx context.Context, filter *Filt
Err(requestErr).
Str("agent_id", agentInfo.AgentID).
Msg("Failed to request metrics from agent")
- ma.collectingMu.Lock()
- if collectCh, exists := ma.collecting[agentInfo.AgentID]; exists {
- close(collectCh)
- delete(ma.collecting, agentInfo.AgentID)
- }
- ma.collectingMu.Unlock()
- delete(collectChs, agentInfo.AgentID)
+ ma.unsubscribe(agentInfo.AgentID, subscriptions[agentInfo.AgentID].subID)
+ delete(subscriptions, agentInfo.AgentID)
}
}
@@ -246,7 +291,7 @@ func (ma *Aggregator) CollectMetricsFromAgents(ctx context.Context, filter *Filt
var metricsMu sync.Mutex
var wg sync.WaitGroup
- for agentID, collectCh := range collectChs {
+ for agentID, sub := range subscriptions {
wg.Add(1)
go func(id string, ch chan []*AggregatedMetric) {
defer wg.Done()
@@ -263,7 +308,7 @@ func (ma *Aggregator) CollectMetricsFromAgents(ctx context.Context, filter *Filt
allMetrics = append(allMetrics, metrics...)
metricsMu.Unlock()
}
- }(agentID, collectCh)
+ }(agentID, sub.collectCh)
}
wg.Wait()
@@ -295,6 +340,23 @@ func (ma *Aggregator) GetMetricsWindow(ctx context.Context, startTime, endTime t
return ma.CollectMetricsFromAgents(ctx, filter)
}
+// dedupeAgents drops repeated agents so each one is subscribed to, asked and cleaned up
+// exactly once. Filter.AgentIDs is caller-supplied and may name the same agent twice; a
+// second subscription for it would be stranded forever, because only one subscription per
+// agent is tracked for cleanup.
+func dedupeAgents(agents []*registry.AgentInfo) []*registry.AgentInfo {
+ seen := make(map[string]struct{}, len(agents))
+ deduped := make([]*registry.AgentInfo, 0, len(agents))
+ for _, agentInfo := range agents {
+ if _, ok := seen[agentInfo.AgentID]; ok {
+ continue
+ }
+ seen[agentInfo.AgentID] = struct{}{}
+ deduped = append(deduped, agentInfo)
+ }
+ return deduped
+}
+
// getFilteredAgents returns agents filtered by the provided filter.
func (ma *Aggregator) getFilteredAgents(filter *Filter) []*registry.AgentInfo {
if filter == nil {
diff --git a/fodc/proxy/internal/metrics/aggregator_concurrent_test.go b/fodc/proxy/internal/metrics/aggregator_concurrent_test.go
new file mode 100644
index 000000000..8070ed131
--- /dev/null
+++ b/fodc/proxy/internal/metrics/aggregator_concurrent_test.go
@@ -0,0 +1,156 @@
+// 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 metrics
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/skywalking-banyandb/fodc/proxy/internal/registry"
+)
+
+// replyingSender simulates a live agent: every RequestMetrics is answered
+// asynchronously, the way a real agent answers the proxy's collect request over
+// its gRPC stream.
+type replyingSender struct {
+ aggregator *Aggregator
+ reg *registry.AgentRegistry
+ delay time.Duration
+ wg sync.WaitGroup
+}
+
+func (s *replyingSender) RequestMetrics(agentID string, _ *time.Time, _ *time.Time) error {
+ s.wg.Add(1)
+ //panicdiag:allow-rawgo test-only agent stub; a panic here must fail the test loudly rather than be recovered and hidden
+ go func() {
+ defer s.wg.Done()
+ time.Sleep(s.delay)
+ agentInfo, err := s.reg.GetAgentByID(agentID)
+ if err != nil {
+ return
+ }
+ now := time.Now()
+ req := createTestStreamMetricsRequest("banyandb_system_up_time", 1, nil, &now)
+ _ = s.aggregator.ProcessMetricsFromAgent(context.Background(), agentID, agentInfo, req)
+ }()
+ return nil
+}
+
+// TestCollectMetricsFromAgents_ConcurrentScrapes reproduces the production failure:
+// the OTel collector scrapes /metrics every 10s and Prometheus every 30s, so their
+// collections regularly overlap. Every scrape must get every agent's metrics.
+func TestCollectMetricsFromAgents_ConcurrentScrapes(t *testing.T) {
+ initTestLogger(t)
+ aggregator, testRegistry, _ := newTestAggregator(t)
+
+ sender := &replyingSender{aggregator: aggregator, reg: testRegistry, delay: 200 * time.Millisecond}
+ aggregator.SetGRPCService(sender)
+
+ agentCount := 3
+ for i := 0; i < agentCount; i++ {
+ createTestAgent(t, testRegistry, "pod", "datanode-warm", nil)
+ }
+
+ const scrapers = 2
+ results := make([][]*AggregatedMetric, scrapers)
+ errs := make([]error, scrapers)
+
+ var wg sync.WaitGroup
+ for i := 0; i < scrapers; i++ {
+ wg.Add(1)
+ //panicdiag:allow-rawgo test-only scrape driver; a panic here must fail the test loudly rather than be recovered and hidden
+ go func(idx int) {
+ defer wg.Done()
+ // Stagger the second scrape so it lands while the first is still collecting.
+ time.Sleep(time.Duration(idx) * 50 * time.Millisecond)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ results[idx], errs[idx] = aggregator.GetLatestMetrics(ctx, nil)
+ }(i)
+ }
+ wg.Wait()
+ sender.wg.Wait()
+
+ for i := 0; i < scrapers; i++ {
+ require.NoError(t, errs[i])
+ require.Len(t, results[i], agentCount,
+ "scrape %d must observe all %d agents, got %d", i, agentCount, len(results[i]))
+ }
+ require.Equal(t, 0, aggregator.ActiveCollections(), "every subscription must be released")
+}
+
+// TestCollectMetricsFromAgents_DuplicateAgentID guards the per-collection subscription
+// bookkeeping: only one subscription per agent is tracked for cleanup, so an agent named
+// twice must be subscribed to only once or the extra subscription is never released.
+func TestCollectMetricsFromAgents_DuplicateAgentID(t *testing.T) {
+ initTestLogger(t)
+ aggregator, testRegistry, _ := newTestAggregator(t)
+
+ sender := &replyingSender{aggregator: aggregator, reg: testRegistry, delay: 20 * time.Millisecond}
+ aggregator.SetGRPCService(sender)
+
+ agentID := createTestAgent(t, testRegistry, "pod", "datanode-warm", nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ collected, err := aggregator.CollectMetricsFromAgents(ctx, &Filter{AgentIDs: []string{agentID, agentID}})
+ require.NoError(t, err)
+ require.Len(t, collected, 1, "a repeated agent must be collected from once, not twice")
+
+ sender.wg.Wait()
+ require.Equal(t, 0, aggregator.ActiveCollections(), "every subscription must be released")
+}
+
+// TestProcessMetricsFromAgent_CancelledContextStillBroadcasts pins the broadcast down: a
+// canceled context must not cut the fan-out short. A subscriber that cannot take the value
+// (buffer already full) must not cost the other subscribers their data - they would each go
+// on to wait out a full collection timeout for metrics that had already arrived.
+func TestProcessMetricsFromAgent_CancelledContextStillBroadcasts(t *testing.T) {
+ initTestLogger(t)
+ aggregator, testRegistry, _ := newTestAggregator(t)
+
+ agentID := createTestAgent(t, testRegistry, "pod", "datanode-warm", nil)
+ agentInfo, err := testRegistry.GetAgentByID(agentID)
+ require.NoError(t, err)
+
+ // Subscriber "blocked" has a full buffer, so its send can never proceed.
+ blockedSubID, blockedCh := aggregator.subscribe(agentID)
+ blockedCh <- nil
+ healthySubID, healthyCh := aggregator.subscribe(agentID)
+ defer aggregator.unsubscribe(agentID, blockedSubID)
+ defer aggregator.unsubscribe(agentID, healthySubID)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ now := time.Now()
+ processErr := aggregator.ProcessMetricsFromAgent(ctx, agentID, agentInfo,
+ createTestStreamMetricsRequest("banyandb_system_up_time", 1, nil, &now))
+ require.ErrorIs(t, processErr, context.Canceled, "the canceled context must still be reported")
+
+ select {
+ case metrics := <-healthyCh:
+ require.Len(t, metrics, 1)
+ default:
+ t.Fatal("healthy subscriber was starved because the broadcast stopped early")
+ }
+}
diff --git a/fodc/proxy/internal/metrics/aggregator_test.go b/fodc/proxy/internal/metrics/aggregator_test.go
index a7fb6a637..a91000b7a 100644
--- a/fodc/proxy/internal/metrics/aggregator_test.go
+++ b/fodc/proxy/internal/metrics/aggregator_test.go
@@ -186,10 +186,7 @@ func TestProcessMetricsFromAgent_WithCollectionChannel(t *testing.T) {
agentInfo, getErr := testRegistry.GetAgentByID(agentID)
require.NoError(t, getErr)
- aggregator.collectingMu.Lock()
- collectCh := make(chan []*AggregatedMetric, 1)
- aggregator.collecting[agentID] = collectCh
- aggregator.collectingMu.Unlock()
+ _, collectCh := aggregator.subscribe(agentID)
ctx := context.Background()
now := time.Now()
@@ -229,10 +226,7 @@ func TestProcessMetricsFromAgent_WithAgentLabels(t *testing.T) {
agentInfo, getErr := testRegistry.GetAgentByID(agentID)
require.NoError(t, getErr)
- aggregator.collectingMu.Lock()
- collectCh := make(chan []*AggregatedMetric, 1)
- aggregator.collecting[agentID] = collectCh
- aggregator.collectingMu.Unlock()
+ _, collectCh := aggregator.subscribe(agentID)
ctx := context.Background()
now := time.Now()
@@ -270,10 +264,7 @@ func TestProcessMetricsFromAgent_WithoutTimestamp(t *testing.T) {
agentInfo, getErr := testRegistry.GetAgentByID(agentID)
require.NoError(t, getErr)
- aggregator.collectingMu.Lock()
- collectCh := make(chan []*AggregatedMetric, 1)
- aggregator.collecting[agentID] = collectCh
- aggregator.collectingMu.Unlock()
+ _, collectCh := aggregator.subscribe(agentID)
ctx := context.Background()
req := createTestStreamMetricsRequest("cpu_usage", 75.5, nil, nil)
@@ -298,10 +289,7 @@ func TestProcessMetricsFromAgent_MultipleMetrics(t *testing.T) {
agentInfo, getErr := testRegistry.GetAgentByID(agentID)
require.NoError(t, getErr)
- aggregator.collectingMu.Lock()
- collectCh := make(chan []*AggregatedMetric, 1)
- aggregator.collecting[agentID] = collectCh
- aggregator.collectingMu.Unlock()
+ _, collectCh := aggregator.subscribe(agentID)
ctx := context.Background()
now := time.Now()
@@ -339,9 +327,10 @@ func TestProcessMetricsFromAgent_ContextCancelled(t *testing.T) {
agentInfo, getErr := testRegistry.GetAgentByID(agentID)
require.NoError(t, getErr)
+ // Unbuffered and unread, so nothing can be delivered: the call must still report the
+ // canceled context rather than swallowing it.
aggregator.collectingMu.Lock()
- collectCh := make(chan []*AggregatedMetric)
- aggregator.collecting[agentID] = collectCh
+ aggregator.collecting[agentID] = map[uint64]chan []*AggregatedMetric{1: make(chan []*AggregatedMetric)}
aggregator.collectingMu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
@@ -826,10 +815,8 @@ func collectOneMetric(t *testing.T, aggregator *Aggregator, agentID string, agen
req *fodcv1.StreamMetricsRequest,
) *AggregatedMetric {
t.Helper()
- aggregator.collectingMu.Lock()
- collectCh := make(chan []*AggregatedMetric, 1)
- aggregator.collecting[agentID] = collectCh
- aggregator.collectingMu.Unlock()
+ subID, collectCh := aggregator.subscribe(agentID)
+ t.Cleanup(func() { aggregator.unsubscribe(agentID, subID) })
require.NoError(t, aggregator.ProcessMetricsFromAgent(context.Background(), agentID, agentInfo, req))
(skywalking-banyandb) 01/15: Fix standalone TopN write ordering (#1304)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit d5959ac1d3d1375bf9e4ea7b812a1b25f3caeed6
Author: Owen Willison <41...@users.noreply.github.com>
AuthorDate: Tue Aug 25 12:15:19 2026 +0800
Fix standalone TopN write ordering (#1304)
(cherry picked from commit cf3b770b9aeb85c4ff883723bcb9f646a494be6b)
---
banyand/measure/topn.go | 190 ++++++++++++++++++++++++--------
banyand/measure/topn_benchmark_test.go | 140 +++++++++++++++++++++++
banyand/measure/topn_unregister_test.go | 172 ++++++++++++++++++++++++++++-
3 files changed, 453 insertions(+), 49 deletions(-)
diff --git a/banyand/measure/topn.go b/banyand/measure/topn.go
index bda30bf52..ad470ce76 100644
--- a/banyand/measure/topn.go
+++ b/banyand/measure/topn.go
@@ -59,6 +59,7 @@ const (
resultPersistencyTimeout = 10 * time.Second
maxFlushInterval = time.Minute
maxTopNValuesCount = 1 << 17 // sanity upper bound: 131072 elements = ~1MB per unmarshal, far above TopN CountersNumber
+ topNWriteQueueCapacity = 1024
)
var (
@@ -333,7 +334,7 @@ type topNProcessor interface {
Setup(ctx context.Context) error
Teardown(ctx context.Context) error
Close() error
- Src() chan interface{}
+ Write(flow.StreamRecord)
TopNSchema() *databasev1.TopNAggregation
}
@@ -341,16 +342,20 @@ type topNStreamingProcessor[K streaming.TopSortKey] struct {
pipeline queue.Client
streamingFlow flow.Flow
in chan flow.StreamRecord
- l *logger.Logger
- topNSchema *databasev1.TopNAggregation
- src chan interface{}
+ writeStopCh chan struct{}
m *databasev1.Measure
errCh <-chan error
+ topNSchema *databasev1.TopNAggregation
+ l *logger.Logger
stopCh chan struct{}
+ src chan interface{}
nodeID string
flow.ComponentState
interval time.Duration
+ closeOnce sync.Once
+ writeMu sync.Mutex
sortDirection modelv1.Sort
+ closed bool
}
func newTopNStreamingProcessor[K streaming.TopSortKey](
@@ -373,6 +378,7 @@ func newTopNStreamingProcessor[K streaming.TopSortKey](
src: srcCh,
in: make(chan flow.StreamRecord),
stopCh: make(chan struct{}),
+ writeStopCh: make(chan struct{}),
streamingFlow: streamingFlow,
pipeline: pipeline,
nodeID: nodeID,
@@ -418,7 +424,17 @@ func (t *topNStreamingProcessor[K]) Teardown(_ context.Context) error {
}
func (t *topNStreamingProcessor[K]) Close() error {
+ t.closeOnce.Do(func() {
+ close(t.writeStopCh)
+ })
+ t.writeMu.Lock()
+ if t.closed {
+ t.writeMu.Unlock()
+ return nil
+ }
+ t.closed = true
close(t.src)
+ t.writeMu.Unlock()
// close streaming flow
err := t.streamingFlow.Close()
// and wait for error channel close
@@ -427,8 +443,16 @@ func (t *topNStreamingProcessor[K]) Close() error {
return err
}
-func (t *topNStreamingProcessor[K]) Src() chan interface{} {
- return t.src
+func (t *topNStreamingProcessor[K]) Write(record flow.StreamRecord) {
+ t.writeMu.Lock()
+ defer t.writeMu.Unlock()
+ if t.closed {
+ return
+ }
+ select {
+ case t.src <- record:
+ case <-t.writeStopCh:
+ }
}
func (t *topNStreamingProcessor[K]) TopNSchema() *databasev1.TopNAggregation {
@@ -596,16 +620,27 @@ func (t *topNStreamingProcessor[K]) handleError() {
close(t.stopCh)
}
+type topNWrite struct {
+ request *measurev1.InternalWriteRequest
+ measure *databasev1.Measure
+ seriesID uint64
+ shardID uint32
+}
+
// topNProcessorManager manages multiple topNStreamingProcessor(s) belonging to a single measure.
type topNProcessorManager struct {
pipeline queue.Client
l *logger.Logger
m *databasev1.Measure
+ inputCh chan topNWrite
+ stopCh chan struct{}
+ dispatcher *run.Task
nodeID string
registeredTasks []*databasev1.TopNAggregation
processorList []topNProcessor
sync.RWMutex
- closed bool
+ enqueueMu sync.Mutex
+ closed bool
}
func (manager *topNProcessorManager) init(ctx context.Context, m *databasev1.Measure) {
@@ -614,6 +649,11 @@ func (manager *topNProcessorManager) init(ctx context.Context, m *databasev1.Mea
if manager.closed {
return
}
+ if manager.inputCh == nil {
+ manager.inputCh = make(chan topNWrite, topNWriteQueueCapacity)
+ manager.stopCh = make(chan struct{})
+ manager.dispatcher = run.Go(ctx, "measure.topn.dispatcher", manager.l, manager.dispatchTopNWrites)
+ }
if manager.m != nil {
return
}
@@ -627,26 +667,36 @@ func (manager *topNProcessorManager) init(ctx context.Context, m *databasev1.Mea
func (manager *topNProcessorManager) Close() error {
manager.Lock()
- defer manager.Unlock()
if manager.closed {
+ manager.Unlock()
return nil
}
manager.closed = true
+ processorList := manager.processorList
+ manager.processorList = nil
+ manager.registeredTasks = nil
+ manager.m = nil
+ stopCh := manager.stopCh
+ dispatcher := manager.dispatcher
+ manager.Unlock()
+ if stopCh != nil {
+ close(stopCh)
+ }
// Close all processors in parallel to avoid serial 5-second-per-flow timeouts.
- errCh := make(chan error, len(manager.processorList))
- for _, processor := range manager.processorList {
+ errCh := make(chan error, len(processorList))
+ for _, processor := range processorList {
p := processor
run.Go(context.Background(), "measure.topn.processor-close", manager.l, func(_ context.Context) {
errCh <- p.Close()
})
}
var err error
- for range manager.processorList {
+ for range processorList {
err = multierr.Append(err, <-errCh)
}
- manager.processorList = nil
- manager.registeredTasks = nil
- manager.m = nil
+ if dispatcher != nil {
+ dispatcher.Wait()
+ }
return err
}
@@ -657,66 +707,106 @@ func (manager *topNProcessorManager) onMeasureWrite(
request *measurev1.InternalWriteRequest,
measure *databasev1.Measure,
) {
- run.Go(ctx, "topn-write", manager.l, func(_ context.Context) {
+ manager.enqueueMu.Lock()
+ defer manager.enqueueMu.Unlock()
+ manager.RLock()
+ if manager.closed {
+ manager.RUnlock()
+ return
+ }
+ if manager.m == nil {
+ manager.RUnlock()
+ manager.init(ctx, measure)
manager.RLock()
- defer manager.RUnlock()
- if manager.closed {
+ if manager.closed || manager.m == nil || manager.inputCh == nil {
+ manager.RUnlock()
return
}
- if manager.m == nil {
- manager.RUnlock()
- manager.init(ctx, measure)
- manager.RLock()
+ }
+ inputCh := manager.inputCh
+ stopCh := manager.stopCh
+ manager.RUnlock()
+ input := topNWrite{
+ seriesID: seriesID,
+ shardID: shardID,
+ request: request,
+ measure: measure,
+ }
+ select {
+ case inputCh <- input:
+ case <-stopCh:
+ }
+}
+
+func (manager *topNProcessorManager) dispatchTopNWrites(_ context.Context) {
+ for {
+ select {
+ case <-manager.stopCh:
+ return
+ default:
}
- dp := request.GetRequest().GetDataPoint()
- spec := request.GetRequest().GetDataPointSpec()
- for _, processor := range manager.processorList {
- dpWithEntity := newDataPointWithEntityValues(
- dp,
- request.GetEntityValues(),
- seriesID,
- shardID,
- spec,
- manager.m,
- )
- processor.Src() <- flow.NewStreamRecordWithTimestampPb(dpWithEntity, dp.GetTimestamp())
+ select {
+ case input := <-manager.inputCh:
+ manager.dispatchTopNWrite(input)
+ case <-manager.stopCh:
+ return
}
- })
+ }
+}
+
+func (manager *topNProcessorManager) dispatchTopNWrite(input topNWrite) {
+ manager.RLock()
+ if manager.closed {
+ manager.RUnlock()
+ return
+ }
+ processors := slices.Clone(manager.processorList)
+ manager.RUnlock()
+ dp := input.request.GetRequest().GetDataPoint()
+ spec := input.request.GetRequest().GetDataPointSpec()
+ for _, processor := range processors {
+ dpWithEntity := newDataPointWithEntityValues(
+ dp,
+ input.request.GetEntityValues(),
+ input.seriesID,
+ input.shardID,
+ spec,
+ input.measure,
+ )
+ processor.Write(flow.NewStreamRecordWithTimestampPb(dpWithEntity, dp.GetTimestamp()))
+ }
}
func (manager *topNProcessorManager) register(ctx context.Context, topNSchema *databasev1.TopNAggregation) {
manager.Lock()
- defer manager.Unlock()
if manager.closed {
+ manager.Unlock()
return
}
- exist := false
for i := range manager.registeredTasks {
if manager.registeredTasks[i].GetMetadata().GetName() == topNSchema.GetMetadata().GetName() {
- exist = true
if manager.registeredTasks[i].GetMetadata().GetModRevision() < topNSchema.GetMetadata().GetModRevision() {
prev := manager.registeredTasks[i]
prevProcessors := manager.removeProcessors(prev)
if err := manager.start(ctx, topNSchema); err != nil {
manager.l.Err(err).Msg("fail to start the new processor")
+ manager.Unlock()
return
}
manager.registeredTasks[i] = topNSchema
- for _, processor := range prevProcessors {
- if err := processor.Close(); err != nil {
- manager.l.Err(err).Msg("fail to close the prev processor")
- }
- }
+ manager.Unlock()
+ manager.closeProcessors(prevProcessors, "fail to close the prev processor")
+ return
}
+ manager.Unlock()
+ return
}
}
- if exist {
- return
- }
manager.registeredTasks = append(manager.registeredTasks, topNSchema)
if err := manager.start(ctx, topNSchema); err != nil {
manager.l.Err(err).Msg("fail to start processor")
}
+ manager.Unlock()
}
func (manager *topNProcessorManager) start(ctx context.Context, topNSchema *databasev1.TopNAggregation) error {
@@ -784,12 +874,16 @@ func (manager *topNProcessorManager) unregister(topNSchema *databasev1.TopNAggre
manager.Unlock()
// Close outside the lock: processor.Close can block up to ~5s and would otherwise
// stall onMeasureWrite readers; the processors are already detached from the manager.
- for _, processor := range removed {
+ manager.closeProcessors(removed, "fail to close the removed top-n processor")
+ return empty
+}
+
+func (manager *topNProcessorManager) closeProcessors(processors []topNProcessor, message string) {
+ for _, processor := range processors {
if err := processor.Close(); err != nil {
- manager.l.Err(err).Msg("fail to close the removed top-n processor")
+ manager.l.Err(err).Msg(message)
}
}
- return empty
}
func (manager *topNProcessorManager) removeProcessors(topNSchema *databasev1.TopNAggregation) []topNProcessor {
diff --git a/banyand/measure/topn_benchmark_test.go b/banyand/measure/topn_benchmark_test.go
new file mode 100644
index 000000000..b09ec9284
--- /dev/null
+++ b/banyand/measure/topn_benchmark_test.go
@@ -0,0 +1,140 @@
+// 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. The 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
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ 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/flow"
+ "github.com/apache/skywalking-banyandb/pkg/run"
+)
+
+const topNWriteBenchmarkBatchSize = 256
+
+type benchmarkTopNProcessor struct {
+ completed *sync.WaitGroup
+}
+
+func (b *benchmarkTopNProcessor) In() chan<- flow.StreamRecord { return nil }
+func (b *benchmarkTopNProcessor) Setup(context.Context) error { return nil }
+func (b *benchmarkTopNProcessor) Teardown(context.Context) error { return nil }
+func (b *benchmarkTopNProcessor) Close() error { return nil }
+func (b *benchmarkTopNProcessor) TopNSchema() *databasev1.TopNAggregation { return nil }
+
+func (b *benchmarkTopNProcessor) Write(flow.StreamRecord) {
+ b.completed.Done()
+}
+
+// BenchmarkTopNWriteDispatch compares completed write throughput. Each batch waits
+// for every record to reach the processor, so the asynchronous baseline cannot
+// report only enqueue latency while leaving its goroutines outstanding.
+func BenchmarkTopNWriteDispatch(b *testing.B) {
+ b.Run("original_async", func(b *testing.B) {
+ benchmarkOriginalAsyncTopNWrites(b)
+ })
+ b.Run("bounded_dispatcher", func(b *testing.B) {
+ benchmarkBoundedTopNWrites(b)
+ })
+}
+
+func benchmarkOriginalAsyncTopNWrites(b *testing.B) {
+ completed := &sync.WaitGroup{}
+ processor := &benchmarkTopNProcessor{completed: completed}
+ measureSchema := benchmarkTopNMeasureSchema()
+ manager := &topNProcessorManager{
+ m: measureSchema,
+ processorList: []topNProcessor{processor},
+ }
+ request := benchmarkTopNWriteRequest()
+ b.ReportAllocs()
+ b.ResetTimer()
+ benchmarkTopNWrites(b, completed, func() {
+ benchmarkOriginalAsyncTopNWrite(manager, request, measureSchema)
+ })
+}
+
+func benchmarkBoundedTopNWrites(b *testing.B) {
+ completed := &sync.WaitGroup{}
+ processor := &benchmarkTopNProcessor{completed: completed}
+ measureSchema := benchmarkTopNMeasureSchema()
+ manager := &topNProcessorManager{processorList: []topNProcessor{processor}}
+ manager.init(context.Background(), measureSchema)
+ b.Cleanup(func() {
+ if closeErr := manager.Close(); closeErr != nil {
+ b.Fatal(closeErr)
+ }
+ })
+ request := benchmarkTopNWriteRequest()
+ b.ReportAllocs()
+ b.ResetTimer()
+ benchmarkTopNWrites(b, completed, func() {
+ manager.onMeasureWrite(context.Background(), 1, 1, request, measureSchema)
+ })
+}
+
+func benchmarkTopNWrites(b *testing.B, completed *sync.WaitGroup, write func()) {
+ for writesRemaining := b.N; writesRemaining > 0; {
+ batchSize := min(writesRemaining, topNWriteBenchmarkBatchSize)
+ completed.Add(batchSize)
+ for range batchSize {
+ write()
+ }
+ completed.Wait()
+ writesRemaining -= batchSize
+ }
+}
+
+func benchmarkOriginalAsyncTopNWrite(manager *topNProcessorManager, request *measurev1.InternalWriteRequest, measureSchema *databasev1.Measure) {
+ run.Go(context.Background(), "measure.topn.benchmark-original-async", nil, func(_ context.Context) {
+ manager.RLock()
+ defer manager.RUnlock()
+ dp := request.GetRequest().GetDataPoint()
+ spec := request.GetRequest().GetDataPointSpec()
+ for _, processor := range manager.processorList {
+ dpWithEntity := newDataPointWithEntityValues(
+ dp,
+ request.GetEntityValues(),
+ 1,
+ 1,
+ spec,
+ measureSchema,
+ )
+ processor.Write(flow.NewStreamRecordWithTimestampPb(dpWithEntity, dp.GetTimestamp()))
+ }
+ })
+}
+
+func benchmarkTopNMeasureSchema() *databasev1.Measure {
+ return &databasev1.Measure{Metadata: &commonv1.Metadata{Group: "benchmark", Name: "topn"}}
+}
+
+func benchmarkTopNWriteRequest() *measurev1.InternalWriteRequest {
+ return &measurev1.InternalWriteRequest{
+ Request: &measurev1.WriteRequest{
+ DataPoint: &measurev1.DataPointValue{
+ Timestamp: timestamppb.New(time.UnixMilli(1_000)),
+ },
+ },
+ }
+}
diff --git a/banyand/measure/topn_unregister_test.go b/banyand/measure/topn_unregister_test.go
index 8cbdc2a05..bbdce5a64 100644
--- a/banyand/measure/topn_unregister_test.go
+++ b/banyand/measure/topn_unregister_test.go
@@ -19,12 +19,16 @@ package measure
import (
"context"
+ "sync"
"testing"
+ "time"
"github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
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/flow"
"github.com/apache/skywalking-banyandb/pkg/logger"
)
@@ -38,9 +42,46 @@ func (f fakeTopNProcessor) In() chan<- flow.StreamRecord { return nil
func (f fakeTopNProcessor) Setup(context.Context) error { return nil }
func (f fakeTopNProcessor) Teardown(context.Context) error { return nil }
func (f fakeTopNProcessor) Close() error { *f.closed = true; return nil }
-func (f fakeTopNProcessor) Src() chan interface{} { return nil }
+func (f fakeTopNProcessor) Write(flow.StreamRecord) {}
func (f fakeTopNProcessor) TopNSchema() *databasev1.TopNAggregation { return f.schema }
+type blockingTopNProcessor struct {
+ writeStarted chan struct{}
+ closeStarted chan struct{}
+ releaseWrite chan struct{}
+ writeMu sync.Mutex
+ closed bool
+}
+
+func (b *blockingTopNProcessor) In() chan<- flow.StreamRecord { return nil }
+func (b *blockingTopNProcessor) Setup(context.Context) error { return nil }
+func (b *blockingTopNProcessor) Teardown(context.Context) error { return nil }
+func (b *blockingTopNProcessor) TopNSchema() *databasev1.TopNAggregation { return nil }
+
+func (b *blockingTopNProcessor) Write(flow.StreamRecord) {
+ b.writeMu.Lock()
+ defer b.writeMu.Unlock()
+ close(b.writeStarted)
+ <-b.releaseWrite
+}
+
+func (b *blockingTopNProcessor) Close() error {
+ close(b.closeStarted)
+ b.writeMu.Lock()
+ b.closed = true
+ b.writeMu.Unlock()
+ return nil
+}
+
+type recordingTopNProcessor struct {
+ fakeTopNProcessor
+ records chan flow.StreamRecord
+}
+
+func (r recordingTopNProcessor) Write(record flow.StreamRecord) {
+ r.records <- record
+}
+
func agg(name string) *databasev1.TopNAggregation {
return &databasev1.TopNAggregation{Metadata: &commonv1.Metadata{Group: "g", Name: name}}
}
@@ -103,3 +144,132 @@ func TestRemoveTopNAggregation_DropsManagerOnlyWhenEmpty(t *testing.T) {
require.False(t, ok, "manager dropped from the map after the last aggregation is removed")
require.True(t, closedB)
}
+
+// TestTopNManager_CloseDoesNotHoldManagerLockWhileWriterDrains ensures a blocked
+// processor write cannot prevent the manager from transitioning to closed.
+func TestTopNManager_CloseDoesNotHoldManagerLockWhileWriterDrains(t *testing.T) {
+ processor := &blockingTopNProcessor{
+ writeStarted: make(chan struct{}),
+ closeStarted: make(chan struct{}),
+ releaseWrite: make(chan struct{}),
+ }
+ measureSchema := &databasev1.Measure{}
+ manager := &topNProcessorManager{
+ processorList: []topNProcessor{processor},
+ }
+ manager.init(context.Background(), measureSchema)
+ request := &measurev1.InternalWriteRequest{
+ Request: &measurev1.WriteRequest{DataPoint: &measurev1.DataPointValue{}},
+ }
+ writeDone := make(chan struct{})
+ go func() {
+ manager.onMeasureWrite(context.Background(), 0, 0, request, measureSchema)
+ close(writeDone)
+ }()
+ require.Eventually(t, func() bool {
+ select {
+ case <-processor.writeStarted:
+ return true
+ default:
+ return false
+ }
+ }, time.Second, time.Millisecond)
+
+ closeErr := make(chan error, 1)
+ go func() {
+ closeErr <- manager.Close()
+ }()
+ require.Eventually(t, func() bool {
+ select {
+ case <-processor.closeStarted:
+ return true
+ default:
+ return false
+ }
+ }, time.Second, time.Millisecond)
+
+ managerClosed := make(chan bool, 1)
+ go func() {
+ manager.Lock()
+ closed := manager.closed
+ manager.Unlock()
+ managerClosed <- closed
+ }()
+ require.Eventually(t, func() bool {
+ select {
+ case isClosed := <-managerClosed:
+ return isClosed
+ default:
+ return false
+ }
+ }, time.Second, time.Millisecond)
+
+ close(processor.releaseWrite)
+ require.Eventually(t, func() bool {
+ select {
+ case <-writeDone:
+ return true
+ default:
+ return false
+ }
+ }, time.Second, time.Millisecond)
+ require.Eventually(t, func() bool {
+ select {
+ case closeResult := <-closeErr:
+ require.NoError(t, closeResult)
+ return true
+ default:
+ return false
+ }
+ }, time.Second, time.Millisecond)
+}
+
+func TestTopNManagerQueuesWritesInOrder(t *testing.T) {
+ const (
+ firstTimestamp = 1_000
+ secondTimestamp = 61_000
+ )
+ measureSchema := &databasev1.Measure{Metadata: &commonv1.Metadata{Group: "g", Name: "m"}}
+ var closed bool
+ records := make(chan flow.StreamRecord, 2)
+ manager := &topNProcessorManager{
+ l: logger.GetLogger("test"),
+ processorList: []topNProcessor{
+ recordingTopNProcessor{
+ fakeTopNProcessor: fakeTopNProcessor{schema: agg("a"), closed: &closed},
+ records: records,
+ },
+ },
+ }
+ manager.init(context.Background(), measureSchema)
+ t.Cleanup(func() {
+ require.NoError(t, manager.Close())
+ })
+ manager.onMeasureWrite(context.Background(), 1, 0, topNWriteRequest(firstTimestamp), measureSchema)
+ manager.onMeasureWrite(context.Background(), 2, 0, topNWriteRequest(secondTimestamp), measureSchema)
+ firstRecord := waitForTopNRecord(t, records)
+ secondRecord := waitForTopNRecord(t, records)
+ require.Equal(t, int64(firstTimestamp), firstRecord.TimestampMillis())
+ require.Equal(t, int64(secondTimestamp), secondRecord.TimestampMillis())
+}
+
+func waitForTopNRecord(t *testing.T, records <-chan flow.StreamRecord) flow.StreamRecord {
+ t.Helper()
+ select {
+ case record := <-records:
+ return record
+ case <-time.After(time.Second):
+ require.FailNow(t, "timed out waiting for a queued TopN write")
+ return flow.StreamRecord{}
+ }
+}
+
+func topNWriteRequest(timestampMillis int64) *measurev1.InternalWriteRequest {
+ return &measurev1.InternalWriteRequest{
+ Request: &measurev1.WriteRequest{
+ DataPoint: &measurev1.DataPointValue{
+ Timestamp: timestamppb.New(time.UnixMilli(timestampMillis)),
+ },
+ },
+ }
+}
(skywalking-banyandb) 08/15: fix: honor log level for native metrics (#1338)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit c98ba8d53d19d607b605226c3f3b144357151078
Author: 李宏杰 <98...@qq.com>
AuthorDate: Fri Sep 11 06:45:11 2026 +0800
fix: honor log level for native metrics (#1338)
Co-authored-by: Gao Hongtao <ha...@gmail.com>
(cherry picked from commit b4a602fce920dc012dd87eac017b892ce93f893f)
---
pkg/meter/native/collection.go | 14 +++++++-------
pkg/meter/native/provider.go | 10 ++++++----
pkg/meter/native/provider_test.go | 33 +++++++++++++++++++++++++++++++++
pkg/meter/native/vec.go | 2 +-
4 files changed, 47 insertions(+), 12 deletions(-)
diff --git a/pkg/meter/native/collection.go b/pkg/meter/native/collection.go
index 2a2336345..46d5689fc 100644
--- a/pkg/meter/native/collection.go
+++ b/pkg/meter/native/collection.go
@@ -72,23 +72,23 @@ func (m *MetricCollection) FlushMetrics(ctx context.Context) {
m.mu.RLock()
if len(m.collectors) == 0 {
m.mu.RUnlock()
- log.Debug().Msg("native metric collection skipped: no collectors registered")
+ nativeLogger().Debug().Msg("native metric collection skipped: no collectors registered")
return
}
collectorsCopy := append([]collector(nil), m.collectors...)
m.mu.RUnlock()
- log.Debug().Int("collector_count", len(collectorsCopy)).Msg("native metric collection started")
+ nativeLogger().Debug().Int("collector_count", len(collectorsCopy)).Msg("native metric collection started")
publisher := m.pipeline.NewBatchPublisher(writeTimeout)
defer publisher.Close()
var messages []bus.Message
for _, collector := range collectorsCopy {
name, metrics := collector.Collect()
if len(metrics) == 0 {
- log.Debug().Str("metric_name", name).Msg("native metric collector returned no metrics")
+ nativeLogger().Debug().Str("metric_name", name).Msg("native metric collector returned no metrics")
continue
}
- log.Debug().Str("metric_name", name).Int("metric_count", len(metrics)).Msg("native metric collector collected metrics")
+ nativeLogger().Debug().Str("metric_name", name).Int("metric_count", len(metrics)).Msg("native metric collector collected metrics")
for _, metric := range metrics {
iwr := m.buildIWR(name, metric)
nodeID := ""
@@ -97,7 +97,7 @@ func (m *MetricCollection) FlushMetrics(ctx context.Context) {
if m.nodeSelector != nil {
nodeID, err = m.nodeSelector.Locate(iwr.GetRequest().GetMetadata().GetGroup(), iwr.GetRequest().GetMetadata().GetName(), uint32(0), uint32(0))
if err != nil {
- log.Error().Err(err).Msg("Failed to locate nodeID")
+ nativeLogger().Error().Err(err).Msg("Failed to locate nodeID")
}
}
messages = append(messages, bus.NewBatchMessageWithNode(bus.MessageID(time.Now().UnixNano()), nodeID, iwr))
@@ -105,10 +105,10 @@ func (m *MetricCollection) FlushMetrics(ctx context.Context) {
}
_, err := publisher.Publish(ctx, data.TopicMeasureWrite, messages...)
if err != nil {
- log.Error().Err(err).Msg("Failed to publish messages")
+ nativeLogger().Error().Err(err).Msg("Failed to publish messages")
return
}
- log.Debug().Int("message_count", len(messages)).Msg("native metric collection published messages")
+ nativeLogger().Debug().Int("message_count", len(messages)).Msg("native metric collection published messages")
}
func (m *MetricCollection) buildIWR(metricName string, metric metricWithLabelValues) *measurev1.InternalWriteRequest {
diff --git a/pkg/meter/native/provider.go b/pkg/meter/native/provider.go
index 9ac3267ca..68d5faed3 100644
--- a/pkg/meter/native/provider.go
+++ b/pkg/meter/native/provider.go
@@ -45,7 +45,9 @@ const (
tagHTTPAddress = "http_address"
)
-var log = logger.GetLogger("observability", "metrics", "system")
+func nativeLogger() *logger.Logger {
+ return logger.GetLogger("observability", "metrics", "system")
+}
// NodeInfo is the struct that contains information used in native observability mode.
type NodeInfo struct {
@@ -88,7 +90,7 @@ func InitSchema(ctx context.Context, p meter.Provider) {
np.initialized.Store(true)
groupErr := np.createNativeObservabilityGroup(ctx)
if groupErr != nil && !errors.Is(groupErr, schema.ErrGRPCAlreadyExists) {
- log.Error().Err(groupErr).Msg("Failed to create native observability group")
+ nativeLogger().Error().Err(groupErr).Msg("Failed to create native observability group")
}
np.mu.Lock()
pending := np.pendingMeasures
@@ -97,7 +99,7 @@ func InitSchema(ctx context.Context, p meter.Provider) {
for _, pm := range pending {
_, measureErr := np.createMeasure(ctx, pm.name, pm.labels...)
if measureErr != nil && !errors.Is(measureErr, schema.ErrGRPCAlreadyExists) {
- log.Error().Err(measureErr).Msgf("Failed to create measure %s", pm.name)
+ nativeLogger().Error().Err(measureErr).Msgf("Failed to create measure %s", pm.name)
}
}
}
@@ -135,7 +137,7 @@ func (p *provider) registerOrDefer(name string, labels []string) {
defer cancel()
_, measureErr := p.createMeasure(ctx, name, labels...)
if measureErr != nil && !errors.Is(measureErr, schema.ErrGRPCAlreadyExists) {
- log.Error().Err(measureErr).Msgf("Failed to create measure %s", name)
+ nativeLogger().Error().Err(measureErr).Msgf("Failed to create measure %s", name)
}
return
}
diff --git a/pkg/meter/native/provider_test.go b/pkg/meter/native/provider_test.go
new file mode 100644
index 000000000..f9e86f8f1
--- /dev/null
+++ b/pkg/meter/native/provider_test.go
@@ -0,0 +1,33 @@
+// Licensed to the 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.
+// The 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 native
+
+import (
+ "testing"
+
+ "github.com/rs/zerolog"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+func TestNativeLoggerHonorsConfiguredLevel(t *testing.T) {
+ t.Cleanup(func() {
+ require.NoError(t, logger.Init(logger.Logging{Env: "prod", Level: "debug"}))
+ })
+ require.NoError(t, logger.Init(logger.Logging{Env: "prod", Level: "info"}))
+ require.Equal(t, zerolog.InfoLevel, nativeLogger().GetLevel())
+}
diff --git a/pkg/meter/native/vec.go b/pkg/meter/native/vec.go
index 08ee71764..147eabdd9 100644
--- a/pkg/meter/native/vec.go
+++ b/pkg/meter/native/vec.go
@@ -91,7 +91,7 @@ func (n *metricVec) Collect() (string, []metricWithLabelValues) {
func seriesHash(tagValues []*modelv1.TagValue) []byte {
entities, err := pbv1.EntityValues(tagValues).ToEntity()
if err != nil {
- log.Error().Err(err).Msg("Failed to convert tagValues to Entity")
+ nativeLogger().Error().Err(err).Msg("Failed to convert tagValues to Entity")
}
return pbv1.HashEntity(entities)
}
(skywalking-banyandb) 10/15: Update schema registry client init retry strategy (#1344)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 014b08c39ba386b59a569137ec3961b42f66cfc3
Author: mrproliu <74...@qq.com>
AuthorDate: Sat Sep 12 12:34:32 2026 +0800
Update schema registry client init retry strategy (#1344)
(cherry picked from commit fad0afa0dac3590e76666dd22996c49eee1e4d9e)
---
banyand/metadata/client.go | 32 +++++++++++++++++++++-----------
1 file changed, 21 insertions(+), 11 deletions(-)
diff --git a/banyand/metadata/client.go b/banyand/metadata/client.go
index 2a3d0b6ac..a520faef2 100644
--- a/banyand/metadata/client.go
+++ b/banyand/metadata/client.go
@@ -63,8 +63,8 @@ const defaultRecvSize = 10 << 20
// for the property based registry connect to the metadata node.
const (
- propertyRegistryInitRetryCount = 30
- propertyRegistryInitRetrySleep = time.Second * 5
+ propertyRegistryInitRetryLogInterval = 10
+ propertyRegistryInitRetrySleep = time.Second * 5
)
// NewClient returns a new metadata client.
@@ -211,18 +211,23 @@ func (s *clientService) Validate() error {
}
func (s *clientService) PreRun(ctx context.Context) error {
- stopCh := make(chan struct{})
+ // initCtx is canceled by process signals or service close so that the
+ // unbounded registry init retry below can be interrupted during shutdown.
+ initCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
sn := make(chan os.Signal, 1)
l := logger.GetLogger(s.Name())
signal.Notify(sn,
syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
+ defer signal.Stop(sn)
go func() {
select {
case si := <-sn:
- logger.GetLogger(s.Name()).Info().Msgf("signal received: %s", si)
- close(stopCh)
+ l.Info().Msgf("signal received: %s", si)
+ cancel()
case <-s.closer.CloseNotify():
- close(stopCh)
+ cancel()
+ case <-initCtx.Done():
}
}()
@@ -282,7 +287,7 @@ func (s *clientService) PreRun(ctx context.Context) error {
s.nodeDiscoveryRegistry = none.NewService(ctx)
}
- initErr := s.initPropertySchemaRegistry(ctx, l)
+ initErr := s.initPropertySchemaRegistry(initCtx, l)
if initErr != nil {
return initErr
}
@@ -326,11 +331,17 @@ func (s *clientService) initPropertySchemaRegistry(ctx context.Context, l *logge
TLSEnabled: s.propertySchemaClientTLS,
CACertPath: s.propertySchemaClientCACert,
}
- for attempt := 1; attempt <= propertyRegistryInitRetryCount; attempt++ {
+ for attempt := 1; ; attempt++ {
registry, createErr := property.NewSchemaRegistryClient(cfg) //nolint:contextcheck // healthCheck uses its own 2s timeout via context.Background()
if createErr != nil {
- l.Warn().Int("attempt", attempt).Err(createErr).Msg("failed to create property schema registry, retrying...")
- time.Sleep(propertyRegistryInitRetrySleep)
+ if attempt%propertyRegistryInitRetryLogInterval == 0 {
+ l.Error().Int("attempt", attempt).Err(createErr).Msg("failed to create property schema registry, keep retrying...")
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("context canceled while creating property schema registry after %d attempts: %w", attempt, ctx.Err())
+ case <-time.After(propertyRegistryInitRetrySleep):
+ }
continue
}
// Register as KindNode handler so future node events update ConnManager
@@ -341,7 +352,6 @@ func (s *clientService) initPropertySchemaRegistry(ctx context.Context, l *logge
l.Info().Msg("property-based schema registry initialized")
return nil
}
- return fmt.Errorf("failed to create property schema registry after %d attempts", propertyRegistryInitRetryCount)
}
func (s *clientService) Serve() run.StopNotify {
(skywalking-banyandb) 06/15: perf(stream): bound the filtered index-order vectorized Top-N merge (#1331)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 17c0e32b53360873e76bacd5eb98209b7f79c512
Author: Bibek Jyoti Charah <bi...@gmail.com>
AuthorDate: Thu Sep 10 15:34:21 2026 +0530
perf(stream): bound the filtered index-order vectorized Top-N merge (#1331)
* test: assert filtered index-order bounds the stream vec merge
Red until the tag filter moves ahead of the merge (#14056).
* perf(stream): run the criteria tag filter ahead of the vectorized merge
A filtered index-order query left its vec merge uncapped because the tag filter
ran at egress, after the merge, so a cap could starve it. Move the filter ahead
of the merge as a fusible that narrows batch.Selection: the merge then orders
only surviving rows and its maxElementSize cap bounds the top-N of the filtered
set. Filtered timestamp-order queries are unchanged and still filter at egress.
* test(stream): pin the ordered top-N of a filtered index-order query
* test(vectorized): benchmark the pre-merge tag filter at both selectivities
* fix(vectorized): reject a foreign batch schema in the pre-merge tag filter
* docs(stream): correct the merge-cap rationale the tag-filter pushdown reverses
* docs: correct the pushdown rationale in the golden case and the vec egress
The new golden case cannot regress on the tag-filter pushdown: duration
carries an inverted index rule, so the scan drops every non-matching row
before the merge. State what the case does pin, and name
sort_duration_no_index_limit as the pushdown guard.
The vec egress comment still described a merge that skipped the cap for
index order. That branch is gone.
* test(stream): pin the duplicate-ElementID winner rule
* stream: settle the duplicate-ElementID contract as filter-first
---------
Co-authored-by: Gao Hongtao <ha...@gmail.com>
(cherry picked from commit e5f77574bc660757635431ae9ae83fd00c233697)
---
banyand/query/processor.go | 12 +-
.../stream/query_vectorized_topn_filter_test.go | 245 ++++++++++++++++++
.../logical/stream/stream_plan_indexscan_local.go | 30 ++-
.../stream_plan_indexscan_local_vectorized.go | 73 +++---
.../stream_plan_indexscan_local_vectorized_test.go | 48 ++--
.../stream/stream_plan_projection_coords_test.go | 116 +++++++++
.../stream/stream_plan_topn_pushdown_test.go | 246 ++++++++++++++++++
pkg/query/vectorized/stream/config.go | 6 +-
pkg/query/vectorized/stream/distinct.go | 2 +-
.../stream/operators_test_helpers_test.go | 64 +++++
pkg/query/vectorized/stream/pipeline.go | 28 +-
.../vectorized/stream/sorted_merge_bench_test.go | 100 +++++++-
pkg/query/vectorized/stream/tag_filter.go | 171 +++++++++++++
.../vectorized/stream/tag_filter_bound_test.go | 131 ++++++++++
pkg/query/vectorized/stream/tag_filter_dup_test.go | 285 +++++++++++++++++++++
pkg/query/vectorized/stream/tag_filter_test.go | 253 ++++++++++++++++++
.../sort_duration_filter_range_limit_offset.ql | 23 ++
.../sort_duration_filter_range_limit_offset.yaml | 53 ++++
.../sort_duration_filter_range_limit_offset.yaml | 69 +++++
test/cases/stream/stream.go | 11 +
20 files changed, 1881 insertions(+), 85 deletions(-)
diff --git a/banyand/query/processor.go b/banyand/query/processor.go
index ed51c2279..7096c565e 100644
--- a/banyand/query/processor.go
+++ b/banyand/query/processor.go
@@ -223,7 +223,8 @@ func (p *streamQueryProcessor) tryStreamVecDispatch(ctx context.Context, plan lo
}
// A criteria query carries a per-element tag filter (VecTagFilter) that must be
- // applied to materialized Elements (byte-identical to the row tagFilterPlan). The
+ // applied to materialized Elements (the same Match + hidden-tag strip the row
+ // tagFilterPlan applies). The
// frame path operates on columnar batches and has no element-level filter stage,
// so a filter query forces the proto egress; the liaison merges proto Elements
// and frame bodies interchangeably (mixed-mode), so this stays correct.
@@ -256,9 +257,12 @@ func (p *streamQueryProcessor) tryStreamVecDispatch(ctx context.Context, plan lo
}
// Criteria query: apply the SAME per-element tagFilter.Match + hidden-tag strip
// as the row tagFilterPlan.Execute, BEFORE the outer offset:offset+limit slice
- // (row order: scan → merge → distinct → filter → limit). The element set handed
- // to the filter already matches row's, because the merge capped (or deliberately
- // did not cap) according to the scan's order type — see scanResumesAcrossPulls.
+ // (row order: scan → merge → distinct → filter → limit). The merge caps for every
+ // plan. For timestamp order this is the only tag filter, and it runs behind that
+ // cap, as before. For index order the scan already ran the SAME logical.TagFilter
+ // ahead of the merge (see scanResumesAcrossPulls), so this stage receives the
+ // FILTERED top-N: the Match re-check is a no-op and the hidden-tag strip is the
+ // work that remains.
if hasFilter {
filtered, filterErr := applyStreamTagFilter(elements, tagFilter, hiddenTags, filterSchema)
if filterErr != nil {
diff --git a/banyand/stream/query_vectorized_topn_filter_test.go b/banyand/stream/query_vectorized_topn_filter_test.go
new file mode 100644
index 000000000..93dbf17c9
--- /dev/null
+++ b/banyand/stream/query_vectorized_topn_filter_test.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 stream
+
+import (
+ "context"
+ "encoding/hex"
+ "strconv"
+ "testing"
+
+ "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"
+ streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ "github.com/apache/skywalking-banyandb/pkg/convert"
+ "github.com/apache/skywalking-banyandb/pkg/index"
+ "github.com/apache/skywalking-banyandb/pkg/query/logical"
+ "github.com/apache/skywalking-banyandb/pkg/query/model"
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+ vstream "github.com/apache/skywalking-banyandb/pkg/query/vectorized/stream"
+)
+
+// The criteria band and the per-node cap the pushdown case turns on.
+// buildIndexOrderStream gives element j of a series the sort term
+// value(indexOrderTSCount-j), so the band below sits in the MIDDLE of the index
+// order and rejects its head in BOTH sort directions. topnMergeCap is the
+// maxElementSize a limit+offset request produces, and it is smaller than the
+// number of rows the band rejects ahead of its first survivor — which is what
+// makes the filter's position relative to the merge observable.
+const (
+ topnBandLow = "value004"
+ topnBandHigh = "value009"
+ topnLimit = 3
+ topnOffset = 1
+ topnMergeCap = topnLimit + topnOffset
+)
+
+// topnBandFilter builds `filter-tag >= value004 AND filter-tag <= value009`, the
+// criteria the scan hands the merge as a pre-merge fusible.
+func topnBandFilter(t *testing.T) logical.TagFilter {
+ t.Helper()
+ bound := func(op modelv1.Condition_BinaryOp, value string) *modelv1.Criteria {
+ return &modelv1.Criteria{Exp: &modelv1.Criteria_Condition{Condition: &modelv1.Condition{
+ Name: "filter-tag",
+ Op: op,
+ Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: value}}},
+ }}}
+ }
+ filter, err := logical.BuildSimpleTagFilter(&modelv1.Criteria{Exp: &modelv1.Criteria_Le{Le: &modelv1.LogicalExpression{
+ Op: modelv1.LogicalExpression_LOGICAL_OP_AND,
+ Left: bound(modelv1.Condition_BINARY_OP_GE, topnBandLow),
+ Right: bound(modelv1.Condition_BINARY_OP_LE, topnBandHigh),
+ }}})
+ require.NoError(t, err)
+ return filter
+}
+
+// topnFilterRegistry numbers the criteria tag onto the (family, tag) coordinates
+// fullProjection defines, as the projected schema a *tagFilterPlan carries does.
+func topnFilterRegistry() logical.TagSpecMap {
+ registry := logical.TagSpecMap{}
+ registry.RegisterTagFamilies([]*databasev1.TagFamilySpec{{
+ Name: "benchmark-family",
+ Tags: []*databasev1.TagSpec{
+ {Name: "entity-tag", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "filter-tag", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }})
+ return registry
+}
+
+// runTopnFilteredScan drives the vec scan through the pipeline
+// localIndexScan.ExecuteVectorized composes for a CRITERIA index-order query: the
+// tag filter as a pre-merge fusible, a merge capped at maxElementSize, then
+// distinct and a defensive limit of maxElementSize at offset 0. A nil filter gives
+// the pre-pushdown shape — the same cap with the criteria left at the egress —
+// which the control arm uses.
+func runTopnFilteredScan(ctx context.Context, t *testing.T, s *stream, sqo model.StreamQueryOptions,
+ filter logical.TagFilter, desc bool,
+) []*streamv1.Element {
+ t.Helper()
+ src, err := s.queryVectorized(ctx, sqo)
+ require.NoError(t, err)
+ require.NotNil(t, src)
+ schema := src.Schema()
+ var preMerge []vectorized.FusibleOperator
+ if filter != nil {
+ preMerge = append(preMerge, vstream.NewTagFilter(schema, sqo.TagProjection, filter, topnFilterRegistry()))
+ }
+ pipeline, buildErr := vstream.BuildStreamMergePipeline(
+ &testVecSource{src: src, schema: schema}, schema, desc, 0, uint32(sqo.MaxElementSize),
+ vstream.DefaultConfig().BatchSize, sqo.MaxElementSize, preMerge...)
+ require.NoError(t, buildErr)
+ require.NoError(t, pipeline.Init(ctx))
+ var batches []*vectorized.RecordBatch
+ for {
+ batch, nextErr := pipeline.Next(ctx)
+ require.NoError(t, nextErr)
+ if batch == nil {
+ break
+ }
+ batches = append(batches, batch)
+ }
+ elems, egressErr := BuildElementsFromBatches(batches, sqo.TagProjection)
+ require.NoError(t, egressErr)
+ require.NoError(t, pipeline.Close())
+ return elems
+}
+
+// topnWantIDs maps a fixture j sequence to the element ids the egress emits for
+// series 1, computed the way buildIndexOrderStream stamps them.
+func topnWantIDs(js ...int) []string {
+ ids := make([]string, 0, len(js))
+ for _, j := range js {
+ ids = append(ids, hex.EncodeToString(convert.Uint64ToBytes(convert.HashStr("1-"+strconv.Itoa(j)))))
+ }
+ return ids
+}
+
+// topnGotIDs reads back the ordered element ids of a result.
+func topnGotIDs(elems []*streamv1.Element) []string {
+ ids := make([]string, 0, len(elems))
+ for _, elem := range elems {
+ ids = append(ids, elem.ElementId)
+ }
+ return ids
+}
+
+// topnBandValue reads the criteria tag out of a materialized element.
+// fullProjection puts filter-tag second in the single family.
+func topnBandValue(t *testing.T, elem *streamv1.Element) string {
+ t.Helper()
+ require.Len(t, elem.TagFamilies, 1)
+ require.Len(t, elem.TagFamilies[0].Tags, 2)
+ return elem.TagFamilies[0].Tags[1].Value.GetStr().GetValue()
+}
+
+// TestQueryVectorized_FilteredIndexOrder_TopN is the #14056 evidence: a criteria
+// index-order query whose per-node cap is smaller than the corpus must return the
+// top-N of the FILTERED set, in index order.
+//
+// The expectation is analytic, not captured. buildIndexOrderStream gives element j
+// of series 1 the sort term value(indexOrderTSCount-j) and the timestamp base+j
+// seconds, so over ONE series the index order is total and is the exact REVERSE of
+// timestamp order. The criteria keeps the band value004..value009 — six of the
+// twelve elements, positioned so that the four rows nearest the head of the index
+// order are rejected ascending, and two of the four are rejected descending. With
+// the cap at limit+offset=4, a cap taken BEFORE the filter therefore cannot
+// produce the asserted sequence in either direction: the control arm runs that
+// shape and shows what it returns instead.
+func TestQueryVectorized_FilteredIndexOrder_TopN(t *testing.T) {
+ indexRule := &databasev1.IndexRule{
+ Metadata: &commonv1.Metadata{Name: "filter-idx", Id: indexOrderRuleID},
+ Tags: []string{"filter-tag"},
+ }
+ s, tr := buildIndexOrderStream(t)
+ ctx := context.Background()
+ // One series, so the order key carries no cross-series ties and the fixture
+ // fixes the total order.
+ entities := parityEntities(1)
+
+ for _, tc := range []struct {
+ name string
+ wantCapped []int
+ wantSliced []int
+ wantControl []int
+ sort modelv1.Sort
+ }{
+ {
+ // Index-asc walks j down from indexOrderTSCount. The band's first four
+ // survivors are j=8,7,6,5, carrying value004..value007.
+ name: "index-order-asc",
+ sort: modelv1.Sort_SORT_ASC,
+ wantCapped: []int{8, 7, 6, 5},
+ wantSliced: []int{7, 6, 5},
+ wantControl: []int{12, 11, 10, 9},
+ },
+ {
+ // Index-desc walks j up from 1. The band's first four survivors are
+ // j=3,4,5,6, carrying value009..value006.
+ name: "index-order-desc",
+ sort: modelv1.Sort_SORT_DESC,
+ wantCapped: []int{3, 4, 5, 6},
+ wantSliced: []int{4, 5, 6},
+ wantControl: []int{1, 2, 3, 4},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ desc := tc.sort == modelv1.Sort_SORT_DESC
+ sqo := model.StreamQueryOptions{
+ Name: "benchmark",
+ TimeRange: &tr,
+ Entities: entities,
+ TagProjection: fullProjection(),
+ Order: &index.OrderBy{Index: indexRule, Sort: tc.sort},
+ MaxElementSize: topnMergeCap,
+ }
+
+ got := runTopnFilteredScan(ctx, t, s, sqo, topnBandFilter(t), desc)
+ require.Equal(t, topnWantIDs(tc.wantCapped...), topnGotIDs(got),
+ "result is not the first %d of the FILTERED index order", topnMergeCap)
+ // Index order is the reverse of ts order in this fixture, so a merge that
+ // silently keyed on the timestamp would fail here.
+ assertMonotonicTS(t, got, !desc)
+ for idx, elem := range got {
+ value := topnBandValue(t, elem)
+ require.GreaterOrEqual(t, value, topnBandLow, "row %d escaped the criteria band", idx)
+ require.LessOrEqual(t, value, topnBandHigh, "row %d escaped the criteria band", idx)
+ }
+ // The enclosing *limit node applies the client offset:offset+limit slice.
+ require.Equal(t, topnWantIDs(tc.wantSliced...), topnGotIDs(got[topnOffset:topnOffset+topnLimit]))
+
+ // Control: the same cap with the criteria left at the egress. The merge
+ // then bounds the UNFILTERED order, so the window the criteria is applied
+ // to is chosen before selectivity is known.
+ control := runTopnFilteredScan(ctx, t, s, sqo, nil, desc)
+ require.Equal(t, topnWantIDs(tc.wantControl...), topnGotIDs(control))
+ survivors := 0
+ for _, elem := range control {
+ if value := topnBandValue(t, elem); value >= topnBandLow && value <= topnBandHigh {
+ survivors++
+ }
+ }
+ require.Less(t, survivors, len(tc.wantCapped),
+ "the control arm must under-deliver, else the fixture does not exercise the pushdown")
+ })
+ }
+}
diff --git a/pkg/query/logical/stream/stream_plan_indexscan_local.go b/pkg/query/logical/stream/stream_plan_indexscan_local.go
index 83de1c4b8..2cd51a442 100644
--- a/pkg/query/logical/stream/stream_plan_indexscan_local.go
+++ b/pkg/query/logical/stream/stream_plan_indexscan_local.go
@@ -46,27 +46,31 @@ var (
)
type localIndexScan struct {
+ // preMergeFilter is the criteria filter carried by the wrapping tagFilterPlan,
+ // stashed by scanFromInput so ExecuteVectorized can run it as a PRE-MERGE
+ // fusible — which is what lets the merge cap at maxElementSize for a criteria
+ // query. Nil when the query has no criteria, when the criteria collapsed to
+ // DummyFilter, or for a timestamp-order scan, whose filter stays at the egress
+ // behind the cap (see scanResumesAcrossPulls). Field order here is
+ // fieldalignment's, not grouped by meaning.
+ preMergeFilter logical.TagFilter
+ invertedFilter index.Filter
+ skippingFilter index.Filter
+ result model.StreamQueryResult
+ ec executor.StreamExecutionContext
+ // filterRegistry resolves preMergeFilter's tag names to the (family, tag)
+ // coordinates of the PROJECTED schema, so it must be the tagFilterPlan's
+ // schema and not the scan's unprojected one.
+ filterRegistry logical.Schema
schema logical.Schema
- invertedFilter index.Filter
- skippingFilter index.Filter
- result model.StreamQueryResult
- ec executor.StreamExecutionContext
- order *logical.OrderBy
metadata *commonv1.Metadata
l *logger.Logger
+ order *logical.OrderBy
timeRange timestamp.TimeRange
projectionTagRefs [][]*logical.TagRef
projectionTags []model.TagProjection
entities [][]*modelv1.TagValue
maxElementSize int
- // deferLimitToEgress is set when this scan is wrapped by a tagFilterPlan (a
- // criteria query). A non-indexed tag filter is applied AFTER the scan at egress,
- // so a maxElementSize cap inside the vec merge would truncate the ordered set
- // BEFORE the filter runs and starve it (row-path divergence: the row scan streams
- // the whole ordered set in chunks and the filter/limit loops pull as many batches
- // as needed). When set, ExecuteVectorized runs the merge UNCAPPED and the egress
- // applies filter → then the client offset:offset+limit slice.
- deferLimitToEgress bool
}
func (i *localIndexScan) Close() {
diff --git a/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go b/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go
index 4820784b4..0ff7ca6df 100644
--- a/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go
+++ b/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go
@@ -19,7 +19,6 @@ package stream
import (
"context"
- "math"
modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
"github.com/apache/skywalking-banyandb/pkg/index"
@@ -41,8 +40,9 @@ var _ executor.StreamVecExecutable = (*localIndexScan)(nil)
// case the inner scan already projects the criteria + hidden tags and pushes the
// INDEXED criteria into its sqo (invertedFilter/skippingFilter); the caller then
// applies the SAME per-element tagFilter.Match + hidden-tag strip that
-// tagFilterPlan.Execute does (via VecTagFilter) so the result is byte-identical to
-// the row path. A multi-group merger or any other shape does not resolve to a
+// tagFilterPlan.Execute does (via VecTagFilter). Results match the row path except
+// for duplicate ElementIDs, whose contract is filter-first — see
+// BuildStreamMergePipeline. A multi-group merger or any other shape does not resolve to a
// *localIndexScan, so we decline and the caller runs the row path.
func VecExecutable(plan logical.Plan) executor.StreamVecExecutable {
l, ok := plan.(*limit)
@@ -75,11 +75,15 @@ func scanFromInput(input logical.Plan) *localIndexScan {
return in
case *tagFilterPlan:
if scan, ok := in.parent.(*localIndexScan); ok {
- // A criteria query applies the tag filter at egress, AFTER the scan, so the
- // vec merge must reproduce exactly the element set the row scan would hand
- // its tagFilterPlan. Where the row scan caps depends on the order type —
- // see scanResumesAcrossPulls.
- scan.deferLimitToEgress = scanResumesAcrossPulls(scan)
+ // The vec merge reproduces the element set the row scan would hand its
+ // tagFilterPlan, except for duplicate ElementIDs, whose contract is
+ // filter-first — see BuildStreamMergePipeline. Whether the criteria filter
+ // may run AHEAD of the merge depends on the order type — see
+ // scanResumesAcrossPulls. Only an index-order scan takes the filter; a
+ // timestamp-order scan leaves it at the egress, behind the cap.
+ if scanResumesAcrossPulls(scan) && in.tagFilter != nil && in.tagFilter != logical.DummyFilter {
+ scan.preMergeFilter, scan.filterRegistry = in.tagFilter, in.s
+ }
return scan
}
}
@@ -87,8 +91,8 @@ func scanFromInput(input logical.Plan) *localIndexScan {
}
// scanResumesAcrossPulls reports whether the row scan backing this plan keeps
-// yielding new elements on successive Pulls, which decides where the vec merge may
-// cap for a criteria (filtered) query.
+// yielding new elements on successive Pulls, which decides whether a criteria
+// (filtered) query may run its tag filter AHEAD of the vec merge.
//
// The row path nests three loops: *limit.Execute pulls tagFilterPlan.Execute until
// it has accumulated limit+offset elements, tagFilterPlan.Execute pulls the scan
@@ -98,14 +102,18 @@ func scanFromInput(input logical.Plan) *localIndexScan {
//
// - index-order (idxResult): the sorted iterator persists across Pulls and each
// Pull drains the next maxElementSize entries (query_by_idx.go:262), so row keeps
-// pulling and DOES fill the limit. The vec merge must stay uncapped, letting the
-// egress filter the whole ordered set and then apply the limit.
+// pulling and DOES fill the limit out of the whole FILTERED ordered set. Vec
+// bounds the same set by running the tag filter before the merge, so the
+// maxElementSize cap bounds the top-N of the filtered set rather than truncating
+// the input the filter has yet to see (duplicate ElementIDs excepted — see
+// BuildStreamMergePipeline).
// - timestamp order (tsResult): one Pull consumes a whole segment and caps the
// result at maxElementSize (query_by_ts.go:136,159); the next Pull only advances
// to a further segment. For data inside a single segment the scan is then
// exhausted, so row returns only the matches from that first capped batch and
// legitimately UNDER-fills the limit (e.g. 30 scanned, 2 rejected ⇒ 28 returned).
-// The vec merge must cap at maxElementSize to reproduce that same input set.
+// Vec reproduces that under-fill only by capping BEFORE filtering, so the filter
+// is NOT pushed down here and stays at the egress.
//
// Caveat (documented, not emulated): for timestamp order spanning MULTIPLE segments
// the row scan does resume per segment, so row could accumulate past the first
@@ -253,9 +261,10 @@ func VecOffsetLimit(plan logical.Plan) (offsetNum, limitNum uint32, ok bool) {
// offset:offset+limit slice at egress. For a criteria-less query the vec pipeline
// caps at maxElementSize (offset 0, limit=maxElementSize) exactly like the
// scan-level cap, and the outer limit node trims — no double offset. For a criteria
-// query (deferLimitToEgress) the merge runs UNCAPPED and the egress applies the tag
-// filter before the outer offset:offset+limit slice, so the filter is never starved
-// by a premature cap (row-path parity — the row scan streams the whole ordered set).
+// query the merge caps at maxElementSize like any other, because the criteria tag
+// filter runs as a pre-merge fusible: the cap therefore bounds the top-N of the
+// FILTERED set. The egress re-applies the same filter (it is also the hidden-tag
+// strip) before the outer offset:offset+limit slice.
func (i *localIndexScan) ExecuteVectorized(ctx context.Context) ([]*vectorized.RecordBatch, *vectorized.BatchSchema, error) {
select {
case <-ctx.Done():
@@ -310,32 +319,24 @@ func (i *localIndexScan) ExecuteVectorized(ctx context.Context) ([]*vectorized.R
// applies the SAME limit with offset 0 as a defensive client slice; the final
// client offset:offset+limit slice is the enclosing *limit node's job (row path
// parity).
- // A criteria query applies a tag filter at egress AFTER this merge. Capping the
- // merge at maxElementSize here would keep only the top-N BEFORE the filter runs
- // and starve it (the row path streams the whole ordered set and filters lazily).
- // So defer the cap to the egress: run the merge UNCAPPED (mergeCap 0) and make the
- // trailing pipeline Limit a pass-through (MaxUint32, since a 0 limit emits
- // NOTHING) — the egress then applies the tag filter and the true
- // offset:offset+limit slice.
- //
- // This path is therefore NOT bounded by limit+offset, and cannot be until the
- // tag filter moves ahead of the merge. Any cap here is unsound in general: the
- // filter's selectivity is unknown, so the top-(limit+offset) rows BEFORE it can
- // contain arbitrarily few surviving rows — including zero — while the row path
- // keeps pulling until it has enough. Bounding it needs columnar tag-filter
- // pushdown (out of scope here), not a bigger cap.
+ // A criteria query runs its tag filter as a PRE-MERGE fusible, so the merge sees
+ // only surviving rows and the cap keeps the top-N of the FILTERED set — not a
+ // top-N taken before the filter, which the filter's unknown selectivity could
+ // leave empty. The egress still applies the same filter; on rows that already
+ // passed here that re-check is a no-op, and it remains the hidden-tag strip.
+ var preMerge []vectorized.FusibleOperator
+ if i.preMergeFilter != nil {
+ preMerge = append(preMerge,
+ vstream.NewTagFilter(schema, i.projectionTags, i.preMergeFilter, i.filterRegistry))
+ }
limitRows := uint32(0)
- mergeCap := i.maxElementSize
- if i.deferLimitToEgress {
- mergeCap = 0
- limitRows = math.MaxUint32
- } else if i.maxElementSize > 0 {
+ if i.maxElementSize > 0 {
limitRows = uint32(i.maxElementSize)
}
pipeline, buildErr := vstream.BuildStreamMergePipeline(
&vecSourceOperator{source: source, schema: schema},
- schema, desc, 0, limitRows, batchSize, mergeCap)
+ schema, desc, 0, limitRows, batchSize, i.maxElementSize, preMerge...)
if buildErr != nil {
source.Release()
return nil, nil, buildErr
diff --git a/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized_test.go b/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized_test.go
index f407f0619..7e66a9654 100644
--- a/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized_test.go
+++ b/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized_test.go
@@ -89,10 +89,20 @@ func TestVecExecutable_TimeOrder_AcceptsVec(t *testing.T) {
}
// newVecFilteredPlan builds the criteria shape *limit → *tagFilterPlan →
-// *localIndexScan so scanFromInput's cap decision can be exercised directly.
-func newVecFilteredPlan(order *logical.OrderBy, projection []model.TagProjection) (logical.Plan, *localIndexScan) {
+// *localIndexScan so scanFromInput's pushdown decision can be exercised directly.
+// The tag filter is a real one, not DummyFilter: scanFromInput only pushes a
+// filter down when the criteria survived BuildTagFilter, so a dummy would make
+// both order arms report "no pushdown" for the wrong reason.
+func newVecFilteredPlan(t *testing.T, order *logical.OrderBy, projection []model.TagProjection) (logical.Plan, *localIndexScan) {
+ t.Helper()
+ tagFilter, err := logical.BuildSimpleTagFilter(&modelv1.Criteria{Exp: &modelv1.Criteria_Condition{Condition: &modelv1.Condition{
+ Name: "state",
+ Op: modelv1.Condition_BINARY_OP_EQ,
+ Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "open"}}},
+ }}})
+ require.NoError(t, err)
scan := &localIndexScan{order: order, projectionTags: projection}
- filter := &tagFilterPlan{parent: scan, tagFilter: logical.DummyFilter, hiddenTags: logical.HiddenTagSet{}}
+ filter := &tagFilterPlan{parent: scan, tagFilter: tagFilter, hiddenTags: logical.HiddenTagSet{}}
return &limit{Parent: &Parent{Input: filter}}, scan
}
@@ -101,22 +111,28 @@ func newVecFilteredPlan(order *logical.OrderBy, projection []model.TagProjection
// maxElementSize, and the following Pull only advances to a further segment — so
// within a segment the row scan is exhausted and row returns just that first capped
// batch's matches (30 scanned, 2 rejected ⇒ 28, NOT a filled limit of 30). The vec
-// merge must therefore CAP, feeding the egress filter the same element set.
+// merge must therefore cap and leave the filter AT THE EGRESS, behind that cap,
+// feeding it the same element set. Pushing the filter ahead of the merge here would
+// let the merge fill the limit from rows row never sees, and over-return.
func TestScanCap_FilteredTimeOrder_CapsMerge(t *testing.T) {
projection := []model.TagProjection{{Family: "searchable", Names: []string{"service", "state"}}}
- plan, scan := newVecFilteredPlan(nil, projection)
+ plan, scan := newVecFilteredPlan(t, nil, projection)
require.NotNil(t, VecExecutable(plan), "a filtered time-order query is vec-eligible")
- require.False(t, scan.deferLimitToEgress,
- "time-order scans do not resume within a segment, so the vec merge must cap at maxElementSize to match row")
+ require.Nil(t, scan.preMergeFilter,
+ "time-order scans do not resume within a segment, so the filter must stay at the egress behind the cap")
}
-// TestScanCap_FilteredIndexOrder_DefersLimit is the other arm: for INDEX order the
-// sorted iterator persists across Pulls (each drains the next maxElementSize
-// entries), so row keeps pulling and DOES fill the limit. Capping the vec merge
-// there would starve the filter and under-return; the merge must stay uncapped and
-// let the egress filter the whole ordered set before the limit slice.
-func TestScanCap_FilteredIndexOrder_DefersLimit(t *testing.T) {
+// TestScanCap_FilteredIndexOrder_PushesFilterDown is the other arm, and the shape
+// #14056 is about. For INDEX order the sorted iterator persists across Pulls (each
+// drains the next maxElementSize entries), so row keeps pulling and DOES fill the
+// limit. A cap taken BEFORE the filter would starve it, so the filter moves ahead
+// of the merge instead: the merge then orders only surviving rows, and its cap
+// bounds the top-N of the filtered set. That set matches the one row fills its
+// limit from, except for duplicate ElementIDs, whose contract is filter-first —
+// see BuildStreamMergePipeline. scanFromInput must therefore stash the filter on
+// the scan.
+func TestScanCap_FilteredIndexOrder_PushesFilterDown(t *testing.T) {
order := &logical.OrderBy{
Index: &databasev1.IndexRule{
Metadata: &commonv1.Metadata{Name: "duration"},
@@ -125,9 +141,9 @@ func TestScanCap_FilteredIndexOrder_DefersLimit(t *testing.T) {
Sort: modelv1.Sort_SORT_DESC,
}
projection := []model.TagProjection{{Family: "searchable", Names: []string{"duration", "span_id"}}}
- plan, scan := newVecFilteredPlan(order, projection)
+ plan, scan := newVecFilteredPlan(t, order, projection)
require.NotNil(t, VecExecutable(plan), "a filtered index-order query with the sort tag projected is vec-eligible")
- require.True(t, scan.deferLimitToEgress,
- "index-order scans resume across Pulls, so the vec merge must stay uncapped to match row filling the limit")
+ require.NotNil(t, scan.preMergeFilter,
+ "index-order scans resume across Pulls, so the filter must run pre-merge to make the merge cap sound")
}
diff --git a/pkg/query/logical/stream/stream_plan_projection_coords_test.go b/pkg/query/logical/stream/stream_plan_projection_coords_test.go
new file mode 100644
index 000000000..e9b1cf4d0
--- /dev/null
+++ b/pkg/query/logical/stream/stream_plan_projection_coords_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 stream
+
+import (
+ "testing"
+
+ "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"
+ "github.com/apache/skywalking-banyandb/pkg/query/logical"
+ vstream "github.com/apache/skywalking-banyandb/pkg/query/vectorized/stream"
+)
+
+// coordsStreamSchema declares TWO tag families so a family-ordering drift is
+// observable at all: with one family every TagFamilyIdx is 0 and the invariant
+// below holds vacuously.
+func coordsStreamSchema() *databasev1.Stream {
+ return &databasev1.Stream{
+ Metadata: &commonv1.Metadata{Name: "coords", Group: "test"},
+ Entity: &databasev1.Entity{TagNames: []string{"entity_tag"}},
+ TagFamilies: []*databasev1.TagFamilySpec{
+ {
+ Name: "first",
+ Tags: []*databasev1.TagSpec{
+ {Name: "entity_tag", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "duration", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ },
+ {
+ Name: "second",
+ Tags: []*databasev1.TagSpec{
+ {Name: "span_id", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "state", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ },
+ },
+ }
+}
+
+// TestProjectionCoordinatesAddressTheRightColumn pins the one invariant the
+// pre-merge tag filter rests on and nothing else enforces.
+//
+// projectionBuilder.Model() becomes the scan's projectionTags, which the filter's
+// row accessor walks to turn a (tagFamilyIdx, tagIdx) pair into a batch column.
+// projectionBuilder.LogicalTags() becomes projTagsRefs, which CommonSchema.ProjTags
+// numbers BY POSITION into the TagSpecs the filter indexes with. The two agree
+// today only because they are the same loop written out twice.
+//
+// If they ever drift the filter silently reads the WRONG tag — no error, no panic,
+// just wrong rows. The criteria here sits on the SECOND tag of the SECOND family,
+// so a drift in either dimension moves it.
+func TestProjectionCoordinatesAddressTheRightColumn(t *testing.T) {
+ sch, err := BuildSchema(coordsStreamSchema(), nil)
+ require.NoError(t, err)
+
+ // Build the projection the way Analyze does: the client tags first, then the
+ // criteria tag force-added on top.
+ projBuilder := newProjectionBuilder([][]*logical.Tag{
+ {logical.NewTag("first", "duration")},
+ {logical.NewTag("second", "span_id")},
+ })
+ added, addErr := projBuilder.AddTagFromSchema(sch, "state")
+ require.NoError(t, addErr)
+ require.True(t, added, "the criteria tag must be force-added, or this test proves nothing")
+
+ projection := projBuilder.Model()
+ refs, refErr := sch.CreateTagRef(projBuilder.LogicalTags()...)
+ require.NoError(t, refErr)
+ projSchema := sch.ProjTags(refs...)
+ require.NotNil(t, projSchema)
+
+ batchSchema := vstream.BuildStreamBatchSchema(projection, "first", "duration")
+
+ // The column each projected tag actually occupies, resolved independently of
+ // the TagSpec coordinates under test.
+ wantColIdx := make(map[string]int, 3)
+ for _, proj := range projection {
+ for _, tagName := range proj.Names {
+ colIdx, ok := batchSchema.TagIndex(proj.Family, tagName)
+ require.True(t, ok, "projected tag %q has no column", tagName)
+ wantColIdx[tagName] = colIdx
+ }
+ }
+ require.Len(t, wantColIdx, 3)
+
+ for _, proj := range projection {
+ for _, tagName := range proj.Names {
+ spec := projSchema.FindTagSpecByName(tagName)
+ require.NotNil(t, spec, "projected tag %q is missing from the projected schema", tagName)
+ require.Less(t, spec.TagFamilyIdx, len(projection), "family index out of the projection's range")
+ family := projection[spec.TagFamilyIdx]
+ require.Less(t, spec.TagIdx, len(family.Names), "tag index out of family %q's range", family.Family)
+ colIdx, ok := batchSchema.TagIndex(family.Family, family.Names[spec.TagIdx])
+ require.True(t, ok)
+ require.Equal(t, wantColIdx[tagName], colIdx,
+ "the filter's (family,tag) coordinates must address the column that actually holds %q", tagName)
+ }
+ }
+}
diff --git a/pkg/query/logical/stream/stream_plan_topn_pushdown_test.go b/pkg/query/logical/stream/stream_plan_topn_pushdown_test.go
new file mode 100644
index 000000000..d9eb48e68
--- /dev/null
+++ b/pkg/query/logical/stream/stream_plan_topn_pushdown_test.go
@@ -0,0 +1,246 @@
+// 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 stream
+
+import (
+ "context"
+ "testing"
+
+ "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/query/executor"
+ "github.com/apache/skywalking-banyandb/pkg/query/logical"
+ "github.com/apache/skywalking-banyandb/pkg/query/model"
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+ vstream "github.com/apache/skywalking-banyandb/pkg/query/vectorized/stream"
+)
+
+const (
+ pushdownFamily = "searchable"
+ pushdownOrderTag = "duration"
+ pushdownFilterTag = "state"
+ pushdownFilterWant = "open"
+ pushdownFilterFail = "closed"
+ // pushdownCorpus is far larger than pushdownMaxElements so an unbounded merge is
+ // distinguishable from a bounded one by the returned element count alone.
+ pushdownCorpus = 200
+ pushdownMaxElements = 25
+ // pushdownFailHead is a run of NON-matching rows at the head of the ascending
+ // corpus, longer than the cap. Without it every row matches and the cap alone
+ // decides the answer, so the test passes even with the pushdown disconnected.
+ pushdownFailHead = 50
+)
+
+// pushdownVecSource replays pre-built batches as an executor.StreamVecScanSource.
+type pushdownVecSource struct {
+ schema *vectorized.BatchSchema
+ batches []*vectorized.RecordBatch
+ pos int
+}
+
+func (s *pushdownVecSource) Schema() *vectorized.BatchSchema { return s.schema }
+
+func (s *pushdownVecSource) NextBatch(context.Context) (*vectorized.RecordBatch, error) {
+ if s.pos >= len(s.batches) {
+ return nil, nil
+ }
+ batch := s.batches[s.pos]
+ s.pos++
+ return batch, nil
+}
+
+func (s *pushdownVecSource) Release() {}
+
+// pushdownExecContext is a StreamExecutionContext whose vec source replays a
+// fixed in-order corpus, so ExecuteVectorized can be driven without a data node.
+type pushdownExecContext struct {
+ src *pushdownVecSource
+}
+
+func (e *pushdownExecContext) Query(context.Context, model.StreamQueryOptions) (model.StreamQueryResult, error) {
+ return nil, nil
+}
+
+func (e *pushdownExecContext) QueryVectorized(context.Context, model.StreamQueryOptions) (executor.StreamVecScanSource, error) {
+ return e.src, nil
+}
+
+func (e *pushdownExecContext) VectorizedConfig() vstream.VectorizedConfig {
+ return vstream.DefaultConfig()
+}
+
+// pushdownProjection is the two-tag projection the corpus populates: the ordered
+// tag (so the plan is vec-eligible) plus the criteria tag.
+func pushdownProjection() []model.TagProjection {
+ return []model.TagProjection{{Family: pushdownFamily, Names: []string{pushdownOrderTag, pushdownFilterTag}}}
+}
+
+// pushdownStreamSchema is the stream definition both tags are declared in.
+func pushdownStreamSchema() *databasev1.Stream {
+ return &databasev1.Stream{
+ Metadata: &commonv1.Metadata{Name: "pushdown", Group: "test"},
+ Entity: &databasev1.Entity{TagNames: []string{pushdownOrderTag}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: pushdownFamily,
+ Tags: []*databasev1.TagSpec{
+ {Name: pushdownOrderTag, Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: pushdownFilterTag, Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }},
+ }
+}
+
+// pushdownCorpusBatch builds one batch of pushdownCorpus rows, each a distinct
+// ElementID, in ascending order-key order. The first pushdownFailHead rows FAIL the
+// criteria, which is what makes the corpus discriminating: a merge that caps before
+// the filter runs keeps only rows from that head, so the elements it returns are
+// disjoint from the ones the pushdown returns.
+func pushdownCorpusBatch(schema *vectorized.BatchSchema) *vectorized.RecordBatch {
+ batch := vectorized.NewRecordBatch(schema, pushdownCorpus)
+ tsCol := batch.Columns[schema.TimestampIndex()].(*vectorized.TypedColumn[int64])
+ elemCol := batch.Columns[schema.ElementIDIndex()].(*vectorized.TypedColumn[int64])
+ seriesCol := batch.Columns[schema.SeriesIDIndex()].(*vectorized.TypedColumn[int64])
+ orderIdx, _ := schema.TagIndex(pushdownFamily, pushdownOrderTag)
+ orderTagCol := batch.Columns[orderIdx].(*vectorized.TypedColumn[*modelv1.TagValue])
+ stateIdx, _ := schema.TagIndex(pushdownFamily, pushdownFilterTag)
+ stateCol := batch.Columns[stateIdx].(*vectorized.TypedColumn[*modelv1.TagValue])
+ keyCol := batch.Columns[schema.OrderKeyIndex()].(*vectorized.TypedColumn[[]byte])
+ for rowIdx := 0; rowIdx < pushdownCorpus; rowIdx++ {
+ // Zero-padded so lexicographic key order equals numeric order.
+ key := []byte(pushdownOrderTag + "-" + pad3(rowIdx))
+ tsCol.Append(int64(rowIdx + 1))
+ elemCol.Append(vstream.ElementIDToColumn(uint64(rowIdx + 1)))
+ seriesCol.Append(vstream.SeriesIDToColumn(1))
+ orderTagCol.Append(pushdownStr(string(key)))
+ state := pushdownFilterWant
+ if rowIdx < pushdownFailHead {
+ state = pushdownFilterFail
+ }
+ stateCol.Append(pushdownStr(state))
+ keyCol.Append(key)
+ batch.Len++
+ }
+ return batch
+}
+
+func pad3(n int) string {
+ digits := []byte{byte('0' + n/100%10), byte('0' + n/10%10), byte('0' + n%10)}
+ return string(digits)
+}
+
+func pushdownStr(s string) *modelv1.TagValue {
+ return &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: s}}}
+}
+
+// newPushdownFilteredPlan builds the criteria index-order shape
+// *limit → *tagFilterPlan → *localIndexScan over a replayable vec corpus, and
+// returns the scan the dispatch resolves plus that scan.
+func newPushdownFilteredPlan(t *testing.T) *localIndexScan {
+ t.Helper()
+ sch, err := BuildSchema(pushdownStreamSchema(), nil)
+ require.NoError(t, err)
+
+ criteria := &modelv1.Criteria{Exp: &modelv1.Criteria_Condition{Condition: &modelv1.Condition{
+ Name: pushdownFilterTag,
+ Op: modelv1.Condition_BINARY_OP_EQ,
+ Value: pushdownStr(pushdownFilterWant),
+ }}}
+ tagFilter, err := logical.BuildSimpleTagFilter(criteria)
+ require.NoError(t, err)
+
+ order := &logical.OrderBy{
+ Index: &databasev1.IndexRule{
+ Metadata: &commonv1.Metadata{Name: "by-duration"},
+ Tags: []string{pushdownOrderTag},
+ },
+ Sort: modelv1.Sort_SORT_ASC,
+ }
+ projection := pushdownProjection()
+ batchSchema := vstream.BuildStreamBatchSchema(projection, pushdownFamily, pushdownOrderTag)
+ scan := &localIndexScan{
+ schema: sch,
+ order: order,
+ projectionTags: projection,
+ maxElementSize: pushdownMaxElements,
+ metadata: pushdownStreamSchema().GetMetadata(),
+ ec: &pushdownExecContext{src: &pushdownVecSource{
+ schema: batchSchema,
+ batches: []*vectorized.RecordBatch{pushdownCorpusBatch(batchSchema)},
+ }},
+ }
+ filter := &tagFilterPlan{s: sch, parent: scan, tagFilter: tagFilter, hiddenTags: logical.NewHiddenTagSet()}
+ require.NotNil(t, scanFromInput(filter), "the filtered index-order shape must resolve to a vec scan")
+ return scan
+}
+
+// TestPushdown_FilteredIndexOrder_BoundsMerge is the #14056 acceptance assertion:
+// a filtered index-order stream query must bound its vec merge at limit+offset AND
+// bound it over the FILTERED set. It drives the production wiring — scanFromInput
+// stashes the filter, ExecuteVectorized hands it to the pipeline — so removing
+// either fails here.
+//
+// It asserts the exact ordered element ids rather than a count, because a cap taken
+// before the filter returns the same NUMBER of elements; the two differ only in
+// WHICH elements those are, and the pre-change shape returns rows that the egress
+// then discards.
+func TestPushdown_FilteredIndexOrder_BoundsMerge(t *testing.T) {
+ scan := newPushdownFilteredPlan(t)
+ batches, schema, err := scan.ExecuteVectorized(context.Background())
+ require.NoError(t, err)
+
+ stateIdx, ok := schema.TagIndex(pushdownFamily, pushdownFilterTag)
+ require.True(t, ok)
+ var ids []uint64
+ for _, batch := range batches {
+ idData := batch.Columns[schema.ElementIDIndex()].(*vectorized.TypedColumn[int64]).Data()
+ stateData := batch.Columns[stateIdx].(*vectorized.TypedColumn[*modelv1.TagValue]).Data()
+ for _, rowIdx := range pushdownActiveRows(batch) {
+ id := vstream.ColumnToElementID(idData[rowIdx])
+ ids = append(ids, id)
+ require.Equal(t, pushdownFilterWant, stateData[rowIdx].GetStr().GetValue(),
+ "the merge was capped before the filter ran: element %d does not match the criteria", id)
+ }
+ }
+ // Element ids are 1-based and the first pushdownFailHead rows fail, so the
+ // filtered top-N in ascending order starts at pushdownFailHead+1.
+ want := make([]uint64, 0, pushdownMaxElements)
+ for offset := 0; offset < pushdownMaxElements; offset++ {
+ want = append(want, uint64(pushdownFailHead+1+offset))
+ }
+ require.Equal(t, want, ids,
+ "filtered index-order merge must return the top %d of the FILTERED set", pushdownMaxElements)
+}
+
+// pushdownActiveRows lists the row indices a batch's selection leaves active.
+func pushdownActiveRows(batch *vectorized.RecordBatch) []int {
+ if batch.Selection == nil {
+ rows := make([]int, 0, batch.Len)
+ for rowIdx := 0; rowIdx < batch.Len; rowIdx++ {
+ rows = append(rows, rowIdx)
+ }
+ return rows
+ }
+ rows := make([]int, 0, len(batch.Selection))
+ for _, rowIdx := range batch.Selection {
+ rows = append(rows, int(rowIdx))
+ }
+ return rows
+}
diff --git a/pkg/query/vectorized/stream/config.go b/pkg/query/vectorized/stream/config.go
index 70d9ca01f..a4516bb8e 100644
--- a/pkg/query/vectorized/stream/config.go
+++ b/pkg/query/vectorized/stream/config.go
@@ -48,8 +48,10 @@ type VectorizedConfig struct {
// The row path uses the same key but allocates its seen-set per merge round
// (blockCursorHeap.merge / model.MergeStreamResults, one per runTabScanner call), so
// it only collapses duplicates that land in the same round. That is a weaker
-// guarantee than this path gives, not a different semantic; a fixture that reuses an
-// element_id across two writes is malformed either way.
+// guarantee than this path gives, not a different semantic. A reused element_id is
+// supported rather than malformed: for a filtered index-order query the criteria is
+// evaluated first and the element is represented by its first MATCHING row in the
+// requested sort order — see BuildStreamMergePipeline.
//
// Enabled also selects the liaison<->data wire format: a flag-on distributed data
// node emits the native columnar frame instead of protobuf. A liaison decodes both
diff --git a/pkg/query/vectorized/stream/distinct.go b/pkg/query/vectorized/stream/distinct.go
index b730ae407..cc3df8d3c 100644
--- a/pkg/query/vectorized/stream/distinct.go
+++ b/pkg/query/vectorized/stream/distinct.go
@@ -24,7 +24,7 @@ import (
)
// Distinct deduplicates stream rows by ElementID, keeping the first occurrence
-// in the (already merged) input order. It is stateful across batches via a seen
+// in the (already merged, and for a criteria query already FILTERED) input order. It is stateful across batches via a seen
// set keyed by the raw uint64 element id, mirroring the row path's first-seen
// dedup (stream_plan_distributed.go seen map keyed by ElementId). It MUST run
// downstream of SortedMerge so "first occurrence" reflects the global order.
diff --git a/pkg/query/vectorized/stream/operators_test_helpers_test.go b/pkg/query/vectorized/stream/operators_test_helpers_test.go
index 80600b9c4..a7ccf66d8 100644
--- a/pkg/query/vectorized/stream/operators_test_helpers_test.go
+++ b/pkg/query/vectorized/stream/operators_test_helpers_test.go
@@ -138,3 +138,67 @@ func drainRows(t testingT, p *vectorized.Pipeline) (tss []int64, ids []uint64, t
type testingT interface {
Fatalf(format string, args ...any)
}
+
+const testFilterTagName = "state"
+
+// filterSchema is the index-order schema carrying a SECOND tag column, so a
+// criteria over one tag can be evaluated while another tag is ordered on.
+func filterSchema() *vectorized.BatchSchema {
+ return BuildStreamBatchSchema(
+ []model.TagProjection{{Family: testTagFamily, Names: []string{testTagName, testFilterTagName}}},
+ testTagFamily, testTagName,
+ )
+}
+
+// filterRow describes one row of a filterSchema batch. state and stateNull are
+// independent on purpose: a real column carries nullness in its validity bitmap,
+// so a null row can still hold a stale non-nil pointer, and a producer can leave
+// a cell empty without marking it. Both shapes must reach the accessor.
+type filterRow struct {
+ state *modelv1.TagValue
+ // service is the non-criteria tag value, which identifies WHICH version of a
+ // duplicate ElementID a query selected. Empty falls back to a shared constant.
+ service string
+ // orderKey is the explicit index-order key. Left nil, the key falls back to
+ // the row's index WITHIN its batch, which restarts at 0 in every batch and so
+ // cannot express a corpus that is globally ordered across several batches.
+ orderKey []byte
+ elemID uint64
+ stateNull bool
+}
+
+// buildFilterBatch materializes a filterSchema batch from filterRows.
+func buildFilterBatch(schema *vectorized.BatchSchema, rows []filterRow) *vectorized.RecordBatch {
+ batch := vectorized.NewRecordBatch(schema, len(rows))
+ tsCol := batch.Columns[schema.TimestampIndex()].(*vectorized.TypedColumn[int64])
+ elemCol := batch.Columns[schema.ElementIDIndex()].(*vectorized.TypedColumn[int64])
+ seriesCol := batch.Columns[schema.SeriesIDIndex()].(*vectorized.TypedColumn[int64])
+ serviceIdx, _ := schema.TagIndex(testTagFamily, testTagName)
+ serviceCol := batch.Columns[serviceIdx].(*vectorized.TypedColumn[*modelv1.TagValue])
+ stateIdx, _ := schema.TagIndex(testTagFamily, testFilterTagName)
+ stateCol := batch.Columns[stateIdx].(*vectorized.TypedColumn[*modelv1.TagValue])
+ orderCol := streamOrderKeys(batch)
+ for rowIdx, row := range rows {
+ tsCol.Append(int64(rowIdx))
+ elemCol.Append(ElementIDToColumn(row.elemID))
+ seriesCol.Append(0)
+ service := row.service
+ if service == "" {
+ service = "svc"
+ }
+ serviceCol.Append(strTagValue(service))
+ stateCol.Append(row.state)
+ if row.stateNull {
+ stateCol.MarkNullAt(rowIdx)
+ }
+ if orderCol != nil {
+ key := row.orderKey
+ if key == nil {
+ key = []byte{byte(rowIdx)}
+ }
+ orderCol.Append(key)
+ }
+ batch.Len++
+ }
+ return batch
+}
diff --git a/pkg/query/vectorized/stream/pipeline.go b/pkg/query/vectorized/stream/pipeline.go
index 56b97b0ab..ebf869b9e 100644
--- a/pkg/query/vectorized/stream/pipeline.go
+++ b/pkg/query/vectorized/stream/pipeline.go
@@ -22,22 +22,40 @@ import "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
// BuildStreamMergePipeline composes the liaison-side merge → distinct → limit
// pipeline over a source of stream RecordBatches. SortedMerge is the breaker
// (global ordering), Distinct and Limit are fusibles applied on the ordered
-// output, in that strict order. There is no pre-merge short-circuit.
+// output, in that strict order.
+//
+// preMerge fusibles run on the RAW source batches, before the merge consumes
+// them. A row-level filter belongs here rather than at the egress: once the merge
+// sees only surviving rows, maxRows bounds the top-N of the FILTERED set, which is
+// what makes a cap sound for a criteria query at all. A pre-merge fusible must be
+// row-level and must never signal ErrLimitExhausted, which would truncate the scan.
+//
+// Filtering first also settles duplicate ElementIDs. The criteria is evaluated
+// before Distinct picks a winner, so an element is represented by its first
+// MATCHING row in the requested sort order, and one matching version makes the
+// element eligible even when another version fails. Two rows of one element
+// commonly carry the same ordered-tag value and therefore the same sort key; among
+// equal keys the first row the scan yields wins, in both directions.
//
// maxRows bounds the merge to the in-order top-N (0 = unbounded). This is the
// per-node scan cap (maxElementSize = limit+offset), applied AFTER the merge
// sorts — the correct top-N in sort order — matching the row path, which caps
-// after its in-order heap merge (blockHeap.merge / MergeStreamResults). It is
-// distinct from the client offset/limit slice the trailing Limit applies.
+// after its own in-order merge: blockHeap.merge / MergeStreamResults for
+// timestamp order, idxResult.loadSortingData + mergeByTagValue for index order.
+// It is distinct from the client offset/limit slice the trailing Limit applies.
func BuildStreamMergePipeline(
source vectorized.PullOperator,
schema *vectorized.BatchSchema,
desc bool,
offset, limit uint32,
batchSize, maxRows int,
+ preMerge ...vectorized.FusibleOperator,
) (*vectorized.Pipeline, error) {
- return vectorized.NewPipelineBuilder().
- From(source).
+ builder := vectorized.NewPipelineBuilder().From(source)
+ for _, op := range preMerge {
+ builder = builder.Apply(op)
+ }
+ return builder.
Break(NewSortedMergeWithCap(schema, desc, batchSize, maxRows)).
Apply(NewDistinct(schema)).
Apply(NewLimit(schema, offset, limit)).
diff --git a/pkg/query/vectorized/stream/sorted_merge_bench_test.go b/pkg/query/vectorized/stream/sorted_merge_bench_test.go
index 4192c1575..08bd466a0 100644
--- a/pkg/query/vectorized/stream/sorted_merge_bench_test.go
+++ b/pkg/query/vectorized/stream/sorted_merge_bench_test.go
@@ -21,6 +21,10 @@ import (
"math/rand"
"testing"
+ 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/query/logical"
+ "github.com/apache/skywalking-banyandb/pkg/query/model"
"github.com/apache/skywalking-banyandb/pkg/query/vectorized"
)
@@ -28,6 +32,11 @@ const (
benchRows = 100_000
benchBatchSize = 1024
benchTopN = 20
+ // benchKeepNone leaves the criteria tag empty, which is what the merge-only
+ // benchmarks carried before the filtered cases were added.
+ benchKeepNone = 0
+ benchKeepTag = "keep"
+ benchRejectTag = "drop"
)
// benchCorpus builds a fixed benchRows-row corpus split into benchBatchSize
@@ -36,7 +45,12 @@ const (
// already-ordered input. The corpus is built once per benchmark and replayed on
// every iteration, so the reported allocations are the merge pipeline's rather
// than the fixture's.
-func benchCorpus(schema *vectorized.BatchSchema, cardinality int, shuffle bool) []*vectorized.RecordBatch {
+//
+// keepPercent stamps benchKeepTag on that percentage of the rows and
+// benchRejectTag on the rest, so a criteria of `tag == keep` has exactly that
+// selectivity. Tagging happens BEFORE the shuffle, so the survivors scatter across
+// every batch rather than clustering. Pass benchKeepNone for an unfiltered corpus.
+func benchCorpus(schema *vectorized.BatchSchema, cardinality int, shuffle bool, keepPercent int) []*vectorized.RecordBatch {
rows := make([]testRow, benchRows)
for rowIdx := range rows {
elemID := uint64(rowIdx)
@@ -44,6 +58,12 @@ func benchCorpus(schema *vectorized.BatchSchema, cardinality int, shuffle bool)
elemID = uint64(rowIdx % cardinality)
}
rows[rowIdx] = testRow{ts: int64(rowIdx), elemID: elemID}
+ if keepPercent > benchKeepNone {
+ rows[rowIdx].tag = benchRejectTag
+ if rowIdx%100 < keepPercent {
+ rows[rowIdx].tag = benchKeepTag
+ }
+ }
}
if shuffle {
rng := rand.New(rand.NewSource(1)) //nolint:gosec // fixed seed: the corpus must be identical across runs to compare benchmarks
@@ -61,13 +81,23 @@ func benchCorpus(schema *vectorized.BatchSchema, cardinality int, shuffle bool)
// runMergeBenchmark drives the merge pipeline over a prebuilt corpus at the given
// merge cap, failing if the drained result is not wantRows rows. Sharing it keeps
// the capped, duplicate-heavy and uncapped cases from drifting apart.
-func runMergeBenchmark(b *testing.B, schema *vectorized.BatchSchema, batches []*vectorized.RecordBatch, mergeCap, wantRows int) {
+func runMergeBenchmark(b *testing.B, schema *vectorized.BatchSchema, batches []*vectorized.RecordBatch, mergeCap, wantRows int,
+ preMerge ...vectorized.FusibleOperator,
+) {
b.Helper()
b.ReportAllocs()
b.ResetTimer()
for range b.N {
+ // A pre-merge fusible narrows the SOURCE batches in place, and the corpus is
+ // replayed on every iteration, so a leftover selection would make the second
+ // iteration scan only the first one's survivors. Reset before each run.
+ if len(preMerge) > 0 {
+ for _, batch := range batches {
+ batch.Selection = nil
+ }
+ }
pipe, err := BuildStreamMergePipeline(
- newStaticBatchSource(schema, batches...), schema, true, 0, benchTopN, benchBatchSize, mergeCap)
+ newStaticBatchSource(schema, batches...), schema, true, 0, benchTopN, benchBatchSize, mergeCap, preMerge...)
if err != nil {
b.Fatalf("build pipeline: %v", err)
}
@@ -86,7 +116,7 @@ func runMergeBenchmark(b *testing.B, schema *vectorized.BatchSchema, batches []*
// return benchTopN elements out of benchRows scanned rows.
func BenchmarkSortedMergeTopN(b *testing.B) {
schema := tsSchema()
- runMergeBenchmark(b, schema, benchCorpus(schema, 0, true), benchTopN, benchTopN)
+ runMergeBenchmark(b, schema, benchCorpus(schema, 0, true, benchKeepNone), benchTopN, benchTopN)
}
// BenchmarkSortedMergeTopNDuplicateHeavy is the adversarial corpus for the
@@ -94,13 +124,67 @@ func BenchmarkSortedMergeTopN(b *testing.B) {
// rows, so the distinct-ID cap can never truncate on distinct count alone.
func BenchmarkSortedMergeTopNDuplicateHeavy(b *testing.B) {
schema := tsSchema()
- runMergeBenchmark(b, schema, benchCorpus(schema, benchTopN/2, false), benchTopN, benchTopN/2)
+ runMergeBenchmark(b, schema, benchCorpus(schema, benchTopN/2, false, benchKeepNone), benchTopN, benchTopN/2)
}
// BenchmarkSortedMergeUncapped is the uncapped control: the same corpus with
-// mergeCap 0 (the filtered index-order path), which must keep buffering every
-// row. It bounds how much of the capped case's cost is inherent to the scan.
+// mergeCap 0, which must keep buffering every row. It bounds how much of the
+// capped case's cost is inherent to the scan.
func BenchmarkSortedMergeUncapped(b *testing.B) {
schema := tsSchema()
- runMergeBenchmark(b, schema, benchCorpus(schema, 0, true), 0, benchTopN)
+ runMergeBenchmark(b, schema, benchCorpus(schema, 0, true, benchKeepNone), 0, benchTopN)
+}
+
+// benchTagFilter builds the pre-merge `service == keep` criteria over the
+// tsSchema projection, wired exactly as localIndexScan.ExecuteVectorized wires it.
+func benchTagFilter(b *testing.B, schema *vectorized.BatchSchema) *TagFilter {
+ b.Helper()
+ filter, err := logical.BuildSimpleTagFilter(&modelv1.Criteria{Exp: &modelv1.Criteria_Condition{Condition: &modelv1.Condition{
+ Name: testTagName,
+ Op: modelv1.Condition_BINARY_OP_EQ,
+ Value: strTagValue(benchKeepTag),
+ }}})
+ if err != nil {
+ b.Fatalf("build tag filter: %v", err)
+ }
+ registry := logical.TagSpecMap{}
+ registry.RegisterTagFamilies([]*databasev1.TagFamilySpec{{
+ Name: testTagFamily,
+ Tags: []*databasev1.TagSpec{{Name: testTagName, Type: databasev1.TagType_TAG_TYPE_STRING}},
+ }})
+ return NewTagFilter(schema, []model.TagProjection{{Family: testTagFamily, Names: []string{testTagName}}}, filter, registry)
+}
+
+// BenchmarkSortedMergeFilteredTopN measures the criteria pipeline at the two
+// selectivities the cost model splits on. The pushdown moves the tag filter off
+// the egress, where it saw at most limit+offset materialized elements, and onto
+// every scanned row — so it buys a bounded merge at the price of per-row filter
+// work whose size depends on how little the criteria rejects.
+//
+// Each regime reports two arms over an IDENTICAL corpus:
+// - uncapped-no-premerge-filter: the shape this change replaced. The merge
+// buffers every one of benchRows rows before anything is discarded.
+// - capped-with-premerge-filter: the shape it ships. The filter runs first and
+// the merge caps at benchTopN.
+//
+// Neither arm materializes Elements, so the egress filter's own cost sits outside
+// both numbers. What the pair isolates is the merge's buffering against the
+// pre-merge filter's per-row work.
+func BenchmarkSortedMergeFilteredTopN(b *testing.B) {
+ for _, tc := range []struct {
+ name string
+ keepPercent int
+ }{
+ {name: "high-selectivity-keep1pct", keepPercent: 1},
+ {name: "low-selectivity-keep95pct", keepPercent: 95},
+ } {
+ schema := tsSchema()
+ batches := benchCorpus(schema, 0, true, tc.keepPercent)
+ b.Run(tc.name+"/uncapped-no-premerge-filter", func(sub *testing.B) {
+ runMergeBenchmark(sub, schema, batches, 0, benchTopN)
+ })
+ b.Run(tc.name+"/capped-with-premerge-filter", func(sub *testing.B) {
+ runMergeBenchmark(sub, schema, batches, benchTopN, benchTopN, benchTagFilter(sub, schema))
+ })
+ }
}
diff --git a/pkg/query/vectorized/stream/tag_filter.go b/pkg/query/vectorized/stream/tag_filter.go
new file mode 100644
index 000000000..20948b990
--- /dev/null
+++ b/pkg/query/vectorized/stream/tag_filter.go
@@ -0,0 +1,171 @@
+// 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 stream
+
+import (
+ "context"
+ "fmt"
+
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+ "github.com/apache/skywalking-banyandb/pkg/query/logical"
+ "github.com/apache/skywalking-banyandb/pkg/query/model"
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+// TagFilter narrows a batch's selection to the rows a criteria tag filter keeps.
+// Applied BEFORE SortedMerge, it is what makes a merge cap sound for a criteria
+// query: the merge then bounds the top-N of the FILTERED set instead of filtering
+// a set that was already truncated.
+//
+// It evaluates the same logical.TagFilter the egress evaluates, over the same
+// cells. Hidden-tag stripping is NOT done here — it operates on materialized
+// Elements and stays at the egress.
+type TagFilter struct {
+ filter logical.TagFilter
+ registry logical.TagSpecRegistry
+ schema *vectorized.BatchSchema
+ cols [][]int
+ closed bool
+}
+
+// NewTagFilter builds the pre-merge criteria filter. projection MUST be the same
+// []model.TagProjection the egress materializes from and registry MUST be the
+// projected schema the *tagFilterPlan carries: together they define the
+// (tagFamilyIdx, tagIdx) space the filter indexes with.
+//
+// Columns are resolved by NAME, exactly as BuildElementsFromBatch resolves them.
+// Positional resolution would be wrong wherever the executed schema carries a
+// column the client projection does not (an ordered tag added for the OrderKey).
+func NewTagFilter(schema *vectorized.BatchSchema, projection []model.TagProjection,
+ filter logical.TagFilter, registry logical.TagSpecRegistry,
+) *TagFilter {
+ cols := make([][]int, 0, len(projection))
+ for _, proj := range projection {
+ family := make([]int, 0, len(proj.Names))
+ for _, tagName := range proj.Names {
+ colIdx, ok := schema.TagIndex(proj.Family, tagName)
+ if !ok {
+ // The egress substitutes NullTagValue for a projected tag with no
+ // column rather than failing; mirror that instead of erroring.
+ colIdx = -1
+ }
+ family = append(family, colIdx)
+ }
+ cols = append(cols, family)
+ }
+ return &TagFilter{schema: schema, filter: filter, registry: registry, cols: cols}
+}
+
+// Init is a no-op: the filter carries no cross-batch state.
+func (t *TagFilter) Init(context.Context) error { return nil }
+
+// OutputSchema returns the unchanged input schema.
+func (t *TagFilter) OutputSchema() *vectorized.BatchSchema { return t.schema }
+
+// Process rewrites the selection to the matching rows. A Match error aborts the
+// query, matching the egress filter.
+func (t *TagFilter) Process(_ context.Context, batch *vectorized.RecordBatch) error {
+ // Same guard SortedMerge.Consume applies. It matters here because this operator
+ // is the first to touch a raw batch's columns, so without it a foreign schema
+ // would panic on the column type assertion instead of erroring in the merge.
+ if batch.Schema != t.schema {
+ return fmt.Errorf("TagFilter: foreign batch schema")
+ }
+ acc := &tagRowAccessor{batch: batch, cols: t.cols}
+ // activeIndices is deliberately not used: it materializes a []uint16 for a nil
+ // selection, which every raw scan batch has, so it would allocate per batch.
+ if batch.Selection == nil {
+ out := make([]uint16, 0, batch.Len)
+ for row := 0; row < batch.Len; row++ {
+ acc.row = row
+ matched, matchErr := t.filter.Match(acc, t.registry)
+ if matchErr != nil {
+ return matchErr
+ }
+ if matched {
+ // Safe: VectorizedConfig.Validate caps BatchSize at MaxUint16.
+ out = append(out, uint16(row))
+ }
+ }
+ batch.Selection = out
+ return nil
+ }
+ out := make([]uint16, 0, len(batch.Selection))
+ for _, sel := range batch.Selection {
+ acc.row = int(sel)
+ matched, matchErr := t.filter.Match(acc, t.registry)
+ if matchErr != nil {
+ return matchErr
+ }
+ if matched {
+ out = append(out, sel)
+ }
+ }
+ batch.Selection = out
+ return nil
+}
+
+// Close is idempotent and a no-op.
+func (t *TagFilter) Close() error {
+ if t.closed {
+ return nil
+ }
+ t.closed = true
+ return nil
+}
+
+// tagRowAccessor presents one batch row to a logical.TagFilter. It is the
+// egress' per-cell resolution (BuildElementsFromBatch) hoisted to the columnar
+// side, so the filter sees the identical *modelv1.TagValue the egress would put
+// in the Element.
+type tagRowAccessor struct {
+ batch *vectorized.RecordBatch
+ cols [][]int
+ row int
+}
+
+// GetTagValue implements logical.TagValueIndexAccessor.
+func (a *tagRowAccessor) GetTagValue(tagFamilyIdx, tagIdx int) *modelv1.TagValue {
+ // Out of range returns nil, exactly as logical.TagFamilies does, so tagExpr
+ // raises the same ErrTagNotDefined rather than silently failing to match.
+ if tagFamilyIdx < 0 || tagFamilyIdx >= len(a.cols) {
+ return nil
+ }
+ family := a.cols[tagFamilyIdx]
+ if tagIdx < 0 || tagIdx >= len(family) {
+ return nil
+ }
+ colIdx := family[tagIdx]
+ if colIdx < 0 {
+ return pbv1.NullTagValue
+ }
+ col := a.batch.Columns[colIdx].(*vectorized.TypedColumn[*modelv1.TagValue])
+ // The validity bitmap is the source of truth: AppendColumnRange appends the
+ // source pointer and only then marks the row null, so a null row can retain a
+ // stale non-nil value. The nil check guards producers that leave a cell empty
+ // without marking it. Both collapse to the singleton the egress substitutes —
+ // returning nil here would make tagExpr fail the whole query instead.
+ if col.IsNull(a.row) {
+ return pbv1.NullTagValue
+ }
+ if value := col.Data()[a.row]; value != nil {
+ return value
+ }
+ return pbv1.NullTagValue
+}
diff --git a/pkg/query/vectorized/stream/tag_filter_bound_test.go b/pkg/query/vectorized/stream/tag_filter_bound_test.go
new file mode 100644
index 000000000..2c44383b1
--- /dev/null
+++ b/pkg/query/vectorized/stream/tag_filter_bound_test.go
@@ -0,0 +1,131 @@
+// 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 stream
+
+import (
+ "context"
+ "math/rand"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+const (
+ boundMergeCap = 25
+ boundBatchRow = 64
+ boundBatchCnt = 100
+ // boundSelectivity is the 1-in-N match rate of the criteria. It is low on
+ // purpose: the whole point of #14056 is that a selective predicate must not
+ // force the merge to buffer every scanned row before it can be applied.
+ boundSelectivity = 64
+ boundHotTag = "hot"
+ boundColdTag = "cold"
+)
+
+// filteredMergeState replays a shuffled corpus through the merge exactly as the
+// filtered index-order stream pipeline runs it, and returns the merge plus the
+// peak retained-row count observed mid-stream.
+//
+// This helper is the ONE place the pipeline's shape is encoded. The tag filter
+// runs as a pre-merge fusible that narrows batch.Selection, so the merge consumes
+// only surviving rows and can safely cap at boundMergeCap. Before the pushdown the
+// filter ran at the egress, AFTER the merge, which forced a cap of 0 and made the
+// merge buffer every scanned row.
+func filteredMergeState(t *testing.T, schema *vectorized.BatchSchema, corpus []testRow) (*SortedMerge, int) {
+ t.Helper()
+ ctx := context.Background()
+ merge := NewSortedMergeWithCap(schema, false, boundBatchRow, boundMergeCap)
+ require.NoError(t, merge.Init(ctx))
+ tagIdx, ok := schema.TagIndex(testTagFamily, testTagName)
+ require.True(t, ok)
+ peak := 0
+ for batchIdx := 0; batchIdx < boundBatchCnt; batchIdx++ {
+ batch := buildBatch(schema, corpus[batchIdx*boundBatchRow:(batchIdx+1)*boundBatchRow])
+ tagCol := batch.Columns[tagIdx].(*vectorized.TypedColumn[*modelv1.TagValue])
+ selection := make([]uint16, 0, batch.Len)
+ for rowIdx := 0; rowIdx < batch.Len; rowIdx++ {
+ if tagCol.Data()[rowIdx].GetStr().GetValue() == boundHotTag {
+ selection = append(selection, uint16(rowIdx))
+ }
+ }
+ batch.Selection = selection
+ require.NoError(t, merge.Consume(ctx, batch))
+ if len(merge.rows) > peak {
+ peak = len(merge.rows)
+ }
+ }
+ require.NoError(t, merge.Finalize(ctx))
+ return merge, peak
+}
+
+// TestTagFilterBoundsRetainedMergeState is the #14056 R2 assertion: a filtered
+// index-order query must keep merge state O(limit+offset), not O(scanned rows).
+//
+// The corpus is shuffled rather than pre-sorted for the reason
+// TestSortedMergePruneBoundsRetainedState records: with input already in sort
+// order every survivor comes from the newest batch and the pinned-batch bound
+// holds at 1 however badly the retained state leaks.
+func TestTagFilterBoundsRetainedMergeState(t *testing.T) {
+ // One schema instance for every batch: SortedMerge validates batch.Schema by
+ // pointer, so a per-batch idxSchema() call fails as a foreign schema rather
+ // than as a merge-bound violation.
+ schema := idxSchema()
+ corpus := make([]testRow, boundBatchCnt*boundBatchRow)
+ for rowIdx := range corpus {
+ tag := boundColdTag
+ if rowIdx%boundSelectivity == 0 {
+ tag = boundHotTag
+ }
+ corpus[rowIdx] = testRow{
+ ts: int64(rowIdx),
+ elemID: uint64(rowIdx),
+ tag: tag,
+ orderKey: []byte(orderKeyFor(rowIdx)),
+ }
+ }
+ rng := rand.New(rand.NewSource(1)) //nolint:gosec // fixed seed: a failing case must reproduce exactly
+ rng.Shuffle(len(corpus), func(leftIdx, rightIdx int) { corpus[leftIdx], corpus[rightIdx] = corpus[rightIdx], corpus[leftIdx] })
+
+ merge, peak := filteredMergeState(t, schema, corpus)
+
+ require.LessOrEqual(t, peak, boundMergeCap+boundBatchRow,
+ "filtered merge buffered %d rows while scanning %d; state must stay O(limit+offset)", peak, len(corpus))
+ pinned := make(map[*vectorized.RecordBatch]struct{})
+ for _, ref := range merge.rows {
+ pinned[ref.batch] = struct{}{}
+ }
+ require.LessOrEqual(t, len(pinned), boundMergeCap, "retained refs must not pin every consumed batch")
+ for _, ref := range merge.rows[len(merge.rows):cap(merge.rows)] {
+ require.Nil(t, ref.batch, "dropped tail must be cleared so its batches are collectable")
+ }
+ require.NoError(t, merge.Close())
+}
+
+// orderKeyFor renders a zero-padded index-order key so lexicographic byte order
+// equals numeric order.
+func orderKeyFor(n int) string {
+ digits := make([]byte, 6)
+ for pos := 5; pos >= 0; pos-- {
+ digits[pos] = byte('0' + n%10)
+ n /= 10
+ }
+ return string(digits)
+}
diff --git a/pkg/query/vectorized/stream/tag_filter_dup_test.go b/pkg/query/vectorized/stream/tag_filter_dup_test.go
new file mode 100644
index 000000000..b79f887c1
--- /dev/null
+++ b/pkg/query/vectorized/stream/tag_filter_dup_test.go
@@ -0,0 +1,285 @@
+// 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 stream
+
+import (
+ "context"
+ "math"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+const dupClosedState = "closed"
+
+// dupCorpus is the duplicate-`ElementID` corpus in ascending order-key order.
+// Every element exercises one clause of the contract:
+//
+// element 1 — its first row in sort order FAILS the criteria and its second
+// passes, so a matching version alone makes the element eligible.
+// element 2 — BOTH rows match, at different order keys, so the selected version
+// is decided by the requested sort direction and flips between ASC and DESC.
+// element 3 — a matching row at a low key and a FAILING row at the highest key,
+// so descending meets its failing version first and must still admit it.
+// element 4 — no matching row at all, so it must never appear.
+//
+// The service tag is the version marker: it is not part of the criteria, so
+// asserting it names WHICH row of a duplicate the query selected.
+func dupCorpus() []filterRow {
+ return []filterRow{
+ {orderKey: dupKey(0), elemID: 1, state: strTagValue(dupClosedState), service: "e1-first"},
+ {orderKey: dupKey(1), elemID: 1, state: strTagValue(filterWantState), service: "e1-second"},
+ {orderKey: dupKey(2), elemID: 2, state: strTagValue(filterWantState), service: "e2-first"},
+ {orderKey: dupKey(3), elemID: 2, state: strTagValue(filterWantState), service: "e2-second"},
+ {orderKey: dupKey(4), elemID: 3, state: strTagValue(filterWantState), service: "e3-only"},
+ {orderKey: dupKey(5), elemID: 4, state: strTagValue(dupClosedState), service: "e4-only"},
+ {orderKey: dupKey(6), elemID: 3, state: strTagValue(dupClosedState), service: "e3-late-fail"},
+ }
+}
+
+func dupKey(n int) []byte { return []byte(orderKeyFor(n)) }
+
+// dupResult is one selected element: its id, the version marker and criteria
+// value of the row the query kept, and that row's sort key.
+type dupResult struct {
+ service string
+ state string
+ orderKey string
+ id uint64
+}
+
+// runDupQuery drives the filtered index-order pipeline over the split corpus and
+// returns what the client receives, in order: the pipeline, then the egress
+// criteria re-check, then the enclosing limit node's offset:offset+limit slice.
+//
+// capped is the production shape, bounding the merge at limit+offset. Uncapped is
+// the control: it must select the same versions, which is what pins the bound as
+// an optimization rather than part of the semantics.
+func runDupQuery(t *testing.T, split [][]filterRow, desc, capped bool, offset, limit uint32) []dupResult {
+ t.Helper()
+ // One schema instance for every batch: SortedMerge validates batch.Schema by
+ // pointer identity, so a per-batch filterSchema() call fails as a foreign
+ // schema rather than exercising the merge.
+ schema := filterSchema()
+ batches := make([]*vectorized.RecordBatch, 0, len(split))
+ for _, rows := range split {
+ batches = append(batches, buildFilterBatch(schema, rows))
+ }
+ limitRows := uint32(math.MaxUint32)
+ mergeCap := 0
+ if capped {
+ limitRows = offset + limit
+ mergeCap = int(offset + limit)
+ }
+ pipeline, err := BuildStreamMergePipeline(
+ newStaticBatchSource(schema, batches...),
+ schema, desc, 0, limitRows, len(dupCorpus()), mergeCap,
+ NewTagFilter(schema, filterProjection(), eqStateFilter(t), filterRegistry()))
+ require.NoError(t, err)
+ defer func() { require.NoError(t, pipeline.Close()) }()
+
+ // The egress criteria re-check, which production still runs. Under the
+ // filter-first contract every emitted row already passed, so it is a no-op
+ // here; keeping it pins that it never removes a row the pushdown selected.
+ kept := make([]dupResult, 0, len(dupCorpus()))
+ for _, row := range drainDupRows(t, pipeline) {
+ if row.state == filterWantState {
+ kept = append(kept, row)
+ }
+ }
+ if int(offset) >= len(kept) {
+ return []dupResult{}
+ }
+ return kept[offset:min(int(offset+limit), len(kept))]
+}
+
+// drainDupRows drains a pipeline into the selected elements, honoring each output
+// batch's active selection.
+func drainDupRows(t *testing.T, p *vectorized.Pipeline) []dupResult {
+ t.Helper()
+ ctx := context.Background()
+ require.NoError(t, p.Init(ctx))
+ schema := filterSchema()
+ serviceIdx, ok := schema.TagIndex(testTagFamily, testTagName)
+ require.True(t, ok)
+ stateIdx, ok := schema.TagIndex(testTagFamily, testFilterTagName)
+ require.True(t, ok)
+ var rows []dupResult
+ for {
+ batch, err := p.Next(ctx)
+ require.NoError(t, err)
+ if batch == nil {
+ return rows
+ }
+ idData := streamElementIDs(batch).Data()
+ serviceData := batch.Columns[serviceIdx].(*vectorized.TypedColumn[*modelv1.TagValue]).Data()
+ stateData := batch.Columns[stateIdx].(*vectorized.TypedColumn[*modelv1.TagValue]).Data()
+ keyData := streamOrderKeys(batch).Data()
+ for _, rowIdx := range activeIndices(batch) {
+ rows = append(rows, dupResult{
+ id: ColumnToElementID(idData[rowIdx]),
+ service: serviceData[rowIdx].GetStr().GetValue(),
+ state: stateData[rowIdx].GetStr().GetValue(),
+ orderKey: string(keyData[rowIdx]),
+ })
+ }
+ }
+}
+
+// dupWant names an expected selection. Every selected row carries the criteria
+// value by construction, so state is not a per-case parameter.
+func dupWant(id uint64, service string, key int) dupResult {
+ return dupResult{id: id, service: service, state: filterWantState, orderKey: orderKeyFor(key)}
+}
+
+// TestTagFilterDuplicateWinnerSemantics pins the duplicate-`ElementID` contract
+// for a filtered index-order query: evaluate the criteria first, then among the
+// MATCHING rows sharing an ElementID keep the first in the requested sort order.
+// A matching version makes the element eligible even when another version fails.
+//
+// It asserts the selected version — its non-criteria tag value and its sort key —
+// alongside the ordered ids, because an element can be right and its representative
+// row wrong. Element 2 carries two matching versions, so the selected one flips
+// between ASC and DESC; that is the clause a membership-only assertion misses.
+//
+// No other fixture in the tree combines repeated element ids WITH the criteria
+// filter, so nothing else can catch a regression in this rule.
+func TestTagFilterDuplicateWinnerSemantics(t *testing.T) {
+ splits := []struct {
+ name string
+ split [][]filterRow
+ }{
+ {
+ name: "single batch",
+ split: [][]filterRow{dupCorpus()},
+ },
+ {
+ // Each duplicate pair straddles the boundary and the batches arrive out
+ // of order-key order, so the merge — not the batch layout — decides which
+ // version is first in sort order.
+ name: "duplicates straddle a batch boundary",
+ split: [][]filterRow{
+ {dupCorpus()[0], dupCorpus()[2], dupCorpus()[5], dupCorpus()[6]},
+ {dupCorpus()[1], dupCorpus()[3], dupCorpus()[4]},
+ },
+ },
+ }
+ cases := []struct {
+ name string
+ want []dupResult
+ offset uint32
+ limit uint32
+ desc bool
+ capped bool
+ }{
+ {
+ // Element 1 enters on its second row, element 2 on its earlier matching
+ // row, and element 4 never matches.
+ name: "ascending keeps the earliest matching version",
+ limit: 4,
+ capped: true,
+ want: []dupResult{
+ dupWant(1, "e1-second", 1),
+ dupWant(2, "e2-first", 2),
+ dupWant(3, "e3-only", 4),
+ },
+ },
+ {
+ // Descending reverses the requested sort order, so element 2 is now
+ // represented by its LATER matching row. Element 1 has only one matching
+ // row, so its version does not move.
+ name: "descending keeps the latest matching version",
+ limit: 4,
+ desc: true,
+ capped: true,
+ want: []dupResult{
+ dupWant(3, "e3-only", 4),
+ dupWant(2, "e2-second", 3),
+ dupWant(1, "e1-second", 1),
+ },
+ },
+ {
+ name: "the merge cap does not change the selected versions",
+ limit: 4,
+ want: []dupResult{
+ dupWant(1, "e1-second", 1),
+ dupWant(2, "e2-first", 2),
+ dupWant(3, "e3-only", 4),
+ },
+ },
+ {
+ // A cap of 3 binds against 4 matching rows, so a cap counting ROWS rather
+ // than distinct ElementIDs spends a slot on element 2's second version and
+ // returns two elements instead of three.
+ name: "the cap counts distinct elements, not rows",
+ limit: 3,
+ capped: true,
+ want: []dupResult{
+ dupWant(1, "e1-second", 1),
+ dupWant(2, "e2-first", 2),
+ dupWant(3, "e3-only", 4),
+ },
+ },
+ {
+ // The cap is limit+offset while the client slice is offset:offset+limit,
+ // so an off-by-one in either shows up as the wrong single element.
+ name: "offset and limit select one element from the middle",
+ offset: 1,
+ limit: 1,
+ capped: true,
+ want: []dupResult{dupWant(2, "e2-first", 2)},
+ },
+ {
+ name: "offset and limit descending",
+ offset: 1,
+ limit: 1,
+ desc: true,
+ capped: true,
+ want: []dupResult{dupWant(2, "e2-second", 3)},
+ },
+ }
+ for _, split := range splits {
+ for _, tt := range cases {
+ t.Run(split.name+"/"+tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, runDupQuery(t, split.split, tt.desc, tt.capped, tt.offset, tt.limit))
+ })
+ }
+ }
+}
+
+// TestTagFilterDuplicateTieOnSortKey pins the clause the main matrix cannot reach.
+// Two versions of one element normally carry the SAME ordered-tag value, so their
+// sort keys tie and "first in the requested sort order" does not discriminate. The
+// merge breaks such ties on arrival order, which is direction-independent — so the
+// same version is selected ascending and descending.
+func TestTagFilterDuplicateTieOnSortKey(t *testing.T) {
+ corpus := []filterRow{
+ {orderKey: dupKey(0), elemID: 1, state: strTagValue(dupClosedState), service: "e1-fail"},
+ {orderKey: dupKey(1), elemID: 1, state: strTagValue(filterWantState), service: "e1-tie-a"},
+ {orderKey: dupKey(1), elemID: 1, state: strTagValue(filterWantState), service: "e1-tie-b"},
+ {orderKey: dupKey(2), elemID: 2, state: strTagValue(filterWantState), service: "e2-only"},
+ }
+ split := [][]filterRow{corpus}
+ require.Equal(t, []dupResult{dupWant(1, "e1-tie-a", 1), dupWant(2, "e2-only", 2)},
+ runDupQuery(t, split, false, true, 0, 2), "ascending")
+ require.Equal(t, []dupResult{dupWant(2, "e2-only", 2), dupWant(1, "e1-tie-a", 1)},
+ runDupQuery(t, split, true, true, 0, 2), "descending")
+}
diff --git a/pkg/query/vectorized/stream/tag_filter_test.go b/pkg/query/vectorized/stream/tag_filter_test.go
new file mode 100644
index 000000000..c684a0f68
--- /dev/null
+++ b/pkg/query/vectorized/stream/tag_filter_test.go
@@ -0,0 +1,253 @@
+// 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 stream
+
+import (
+ "context"
+ "testing"
+
+ "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/index"
+ "github.com/apache/skywalking-banyandb/pkg/query/logical"
+ "github.com/apache/skywalking-banyandb/pkg/query/model"
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+const filterWantState = "open"
+
+// filterProjection is the client projection the accessor's (family, tag)
+// coordinate space is defined by. It matches filterSchema's columns.
+func filterProjection() []model.TagProjection {
+ return []model.TagProjection{{Family: testTagFamily, Names: []string{testTagName, testFilterTagName}}}
+}
+
+// filterRegistry maps the criteria tag onto the coordinates the projection
+// defines, exactly as CommonSchema.ProjTags numbers a projected schema.
+func filterRegistry() logical.TagSpecMap {
+ registry := logical.TagSpecMap{}
+ registry.RegisterTagFamilies([]*databasev1.TagFamilySpec{{
+ Name: testTagFamily,
+ Tags: []*databasev1.TagSpec{
+ {Name: testTagName, Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: testFilterTagName, Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }})
+ return registry
+}
+
+// eqStateFilter builds `state == open`, the criteria every case below shares
+// unless it needs a different operator.
+func eqStateFilter(t *testing.T) logical.TagFilter {
+ t.Helper()
+ filter, err := logical.BuildSimpleTagFilter(&modelv1.Criteria{Exp: &modelv1.Criteria_Condition{Condition: &modelv1.Condition{
+ Name: testFilterTagName,
+ Op: modelv1.Condition_BINARY_OP_EQ,
+ Value: strTagValue(filterWantState),
+ }}})
+ require.NoError(t, err)
+ return filter
+}
+
+func TestTagFilter_Process(t *testing.T) {
+ openRow := func(id uint64) filterRow {
+ return filterRow{elemID: id, state: strTagValue(filterWantState)}
+ }
+ closedRow := func(id uint64) filterRow {
+ return filterRow{elemID: id, state: strTagValue("closed")}
+ }
+ tests := []struct {
+ name string
+ rows []filterRow
+ selIn []uint16
+ want []uint16
+ nilSel bool
+ }{
+ {
+ name: "nil selection keeps only survivors",
+ rows: []filterRow{openRow(1), closedRow(2), openRow(3), closedRow(4)},
+ nilSel: true,
+ want: []uint16{0, 2},
+ },
+ {
+ name: "non-nil selection narrows within itself",
+ rows: []filterRow{openRow(1), closedRow(2), openRow(3), openRow(4)},
+ selIn: []uint16{1, 2},
+ want: []uint16{2},
+ },
+ {
+ name: "all rows rejected yields an empty non-nil selection",
+ rows: []filterRow{closedRow(1), closedRow(2)},
+ nilSel: true,
+ want: []uint16{},
+ },
+ {
+ name: "all rows kept",
+ rows: []filterRow{openRow(1), openRow(2)},
+ nilSel: true,
+ want: []uint16{0, 1},
+ },
+ {
+ // A null validity bit must win over the cell, which AppendColumnRange can
+ // leave holding a stale pointer to a value that WOULD have matched.
+ name: "null bit beats a stale matching pointer",
+ rows: []filterRow{{elemID: 1, state: strTagValue(filterWantState), stateNull: true}, openRow(2)},
+ nilSel: true,
+ want: []uint16{1},
+ },
+ {
+ // A producer that leaves a cell empty without marking it null must not
+ // crash the filter, and must not match a string criteria either.
+ name: "unmarked nil cell is treated as null",
+ rows: []filterRow{{elemID: 1}, openRow(2)},
+ nilSel: true,
+ want: []uint16{1},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ schema := filterSchema()
+ batch := buildFilterBatch(schema, tt.rows)
+ if !tt.nilSel {
+ batch.Selection = tt.selIn
+ }
+ op := NewTagFilter(schema, filterProjection(), eqStateFilter(t), filterRegistry())
+ require.NoError(t, op.Init(context.Background()))
+ require.Equal(t, schema, op.OutputSchema())
+ require.NoError(t, op.Process(context.Background(), batch))
+ // Equal against a non-nil empty slice also pins that the filter always
+ // WRITES a selection: a nil here would leave every row active.
+ require.Equal(t, tt.want, batch.Selection)
+ require.Equal(t, len(tt.want), batch.ActiveLen())
+ require.NoError(t, op.Close())
+ require.NoError(t, op.Close(), "Close is idempotent")
+ })
+ }
+}
+
+// TestTagFilter_ProjectedTagWithoutColumn covers the egress case people miss: a
+// tag is in the projection but the executed batch carries no column for it. The
+// egress substitutes NullTagValue rather than failing, so the filter must too —
+// erroring here would abort a query the row path answers.
+func TestTagFilter_ProjectedTagWithoutColumn(t *testing.T) {
+ // The batch schema omits the criteria tag entirely; the projection keeps it.
+ schema := BuildStreamBatchSchema(
+ []model.TagProjection{{Family: testTagFamily, Names: []string{testTagName}}},
+ testTagFamily, testTagName,
+ )
+ batch := vectorized.NewRecordBatch(schema, 1)
+ batch.Columns[schema.TimestampIndex()].(*vectorized.TypedColumn[int64]).Append(0)
+ batch.Columns[schema.ElementIDIndex()].(*vectorized.TypedColumn[int64]).Append(ElementIDToColumn(1))
+ batch.Columns[schema.SeriesIDIndex()].(*vectorized.TypedColumn[int64]).Append(0)
+ serviceIdx, _ := schema.TagIndex(testTagFamily, testTagName)
+ batch.Columns[serviceIdx].(*vectorized.TypedColumn[*modelv1.TagValue]).Append(strTagValue("svc"))
+ streamOrderKeys(batch).Append([]byte{0})
+ batch.Len = 1
+
+ op := NewTagFilter(schema, filterProjection(), eqStateFilter(t), filterRegistry())
+ require.NoError(t, op.Process(context.Background(), batch))
+ require.Equal(t, []uint16{}, batch.Selection, "a null-substituted cell cannot equal a string criteria")
+}
+
+// TestTagFilter_ForeignSchema pins the guard SortedMerge.Consume used to be the
+// first to apply. The filter now touches a raw batch's columns ahead of the merge,
+// so without this check a foreign schema panics on the column type assertion
+// instead of returning the error the merge would have returned.
+func TestTagFilter_ForeignSchema(t *testing.T) {
+ schema := filterSchema()
+ batch := buildFilterBatch(filterSchema(), []filterRow{{elemID: 1, state: strTagValue(filterWantState)}})
+
+ op := NewTagFilter(schema, filterProjection(), eqStateFilter(t), filterRegistry())
+ require.ErrorContains(t, op.Process(context.Background(), batch), "foreign batch schema")
+}
+
+// TestTagFilter_CoordinateOutOfRange pins the accessor's out-of-range contract:
+// it returns a bare nil, so tagExpr raises ErrTagNotDefined and the query errors,
+// exactly as the row path does. Silently failing to match would return a wrong
+// result set instead of an error.
+func TestTagFilter_CoordinateOutOfRange(t *testing.T) {
+ registry := logical.TagSpecMap{}
+ registry.RegisterTag(7, 0, &databasev1.TagSpec{Name: testFilterTagName, Type: databasev1.TagType_TAG_TYPE_STRING})
+ schema := filterSchema()
+ batch := buildFilterBatch(schema, []filterRow{{elemID: 1, state: strTagValue(filterWantState)}})
+
+ op := NewTagFilter(schema, filterProjection(), eqStateFilter(t), registry)
+ err := op.Process(context.Background(), batch)
+ require.ErrorIs(t, err, logical.ErrTagNotDefined)
+}
+
+// analyzedIndexChecker reports the criteria tag as indexed with a keyword
+// analyzer, which is the shape a `match` criteria normally has in production.
+type analyzedIndexChecker struct{}
+
+func (analyzedIndexChecker) IndexDefined(tagName string) (bool, *databasev1.IndexRule) {
+ return analyzedIndexChecker{}.IndexRuleDefined(tagName)
+}
+
+func (analyzedIndexChecker) IndexRuleDefined(ruleName string) (bool, *databasev1.IndexRule) {
+ if ruleName != testFilterTagName {
+ return false, nil
+ }
+ return true, &databasev1.IndexRule{
+ Metadata: &commonv1.Metadata{Name: testFilterTagName},
+ Tags: []string{testFilterTagName},
+ Analyzer: index.AnalyzerKeyword,
+ }
+}
+
+// TestTagFilter_MatchOverNullCell records what a `match` criteria does to a null
+// cell, and asserts it rather than working around it. The behavior splits on
+// whether the tag carries an analyzer, and BOTH arms are reachable, so the filter
+// must not short-circuit nulls: a short-circuit would make the vec path answer a
+// query the row path rejects, and reject a row the row path also rejects but for
+// a reason the client can see.
+func TestTagFilter_MatchOverNullCell(t *testing.T) {
+ criteria := &modelv1.Criteria{Exp: &modelv1.Criteria_Condition{Condition: &modelv1.Condition{
+ Name: testFilterTagName,
+ Op: modelv1.Condition_BINARY_OP_MATCH,
+ Value: strTagValue(filterWantState),
+ }}}
+
+ t.Run("analyzed tag errors the query", func(t *testing.T) {
+ // parseExpr takes its analyzer branch, which accepts only TagValue_Str, so
+ // the null raises ErrUnsupportedConditionValue and the whole query fails.
+ filter, err := logical.BuildTagFilter(criteria, nil, nil, analyzedIndexChecker{}, false)
+ require.NoError(t, err)
+ schema := filterSchema()
+ batch := buildFilterBatch(schema, []filterRow{{elemID: 1, stateNull: true}})
+
+ op := NewTagFilter(schema, filterProjection(), filter, filterRegistry())
+ require.ErrorIs(t, op.Process(context.Background(), batch), logical.ErrUnsupportedConditionValue)
+ })
+
+ t.Run("unanalyzed tag rejects the row", func(t *testing.T) {
+ // With no index rule the analyzer is nil, so parseExpr returns the null
+ // literal, whose Contains is always false — the row drops, without an error.
+ filter, err := logical.BuildSimpleTagFilter(criteria)
+ require.NoError(t, err)
+ schema := filterSchema()
+ batch := buildFilterBatch(schema, []filterRow{{elemID: 1, stateNull: true}})
+
+ op := NewTagFilter(schema, filterProjection(), filter, filterRegistry())
+ require.NoError(t, op.Process(context.Background(), batch))
+ require.Equal(t, []uint16{}, batch.Selection)
+ })
+}
diff --git a/test/cases/stream/data/input/sort_duration_filter_range_limit_offset.ql b/test/cases/stream/data/input/sort_duration_filter_range_limit_offset.ql
new file mode 100644
index 000000000..191515bc9
--- /dev/null
+++ b/test/cases/stream/data/input/sort_duration_filter_range_limit_offset.ql
@@ -0,0 +1,23 @@
+# 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.
+
+SELECT trace_id, service_id, state, duration, data_binary FROM STREAM sw IN default
+TIME > '-15m'
+WHERE (duration > 30 AND duration < 1000)
+ORDER BY duration ASC
+LIMIT 2
+OFFSET 1
diff --git a/test/cases/stream/data/input/sort_duration_filter_range_limit_offset.yaml b/test/cases/stream/data/input/sort_duration_filter_range_limit_offset.yaml
new file mode 100644
index 000000000..a7841b4ef
--- /dev/null
+++ b/test/cases/stream/data/input/sort_duration_filter_range_limit_offset.yaml
@@ -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.
+
+criteria:
+ le:
+ left:
+ condition:
+ name: duration
+ op: BINARY_OP_GT
+ value:
+ int:
+ value: "30"
+ op: LOGICAL_OP_AND
+ right:
+ condition:
+ name: duration
+ op: BINARY_OP_LT
+ value:
+ int:
+ value: "1000"
+groups:
+- default
+limit: 2
+name: sw
+offset: 1
+orderBy:
+ indexRuleName: duration
+ sort: SORT_ASC
+projection:
+ tagFamilies:
+ - name: searchable
+ tags:
+ - trace_id
+ - service_id
+ - state
+ - duration
+ - name: data
+ tags:
+ - data_binary
diff --git a/test/cases/stream/data/want/sort_duration_filter_range_limit_offset.yaml b/test/cases/stream/data/want/sort_duration_filter_range_limit_offset.yaml
new file mode 100644
index 000000000..8d45975df
--- /dev/null
+++ b/test/cases/stream/data/want/sort_duration_filter_range_limit_offset.yaml
@@ -0,0 +1,69 @@
+# 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.
+
+elements:
+- elementId: aaede9362761569a
+ tagFamilies:
+ - name: searchable
+ tags:
+ - key: trace_id
+ value:
+ str:
+ value: "5"
+ - key: service_id
+ value:
+ str:
+ value: webapp_id
+ - key: state
+ value:
+ int: {}
+ - key: duration
+ value:
+ int:
+ value: "300"
+ - name: data
+ tags:
+ - key: data_binary
+ value:
+ binaryData: YWJjMTIzIT8kKiYoKSctPUB+
+ timestamp: "2026-07-04T06:18:02Z"
+- elementId: 4dd999e3502d728d
+ tagFamilies:
+ - name: searchable
+ tags:
+ - key: trace_id
+ value:
+ str:
+ value: "2"
+ - key: service_id
+ value:
+ str:
+ value: webapp_id
+ - key: state
+ value:
+ int:
+ value: "1"
+ - key: duration
+ value:
+ int:
+ value: "500"
+ - name: data
+ tags:
+ - key: data_binary
+ value:
+ binaryData: YWJjMTIzIT8kKiYoKSctPUB+
+ timestamp: "2026-07-04T06:18:00.500Z"
diff --git a/test/cases/stream/stream.go b/test/cases/stream/stream.go
index ea56f0741..e1fec1391 100644
--- a/test/cases/stream/stream.go
+++ b/test/cases/stream/stream.go
@@ -73,6 +73,17 @@ var streamEntries = []any{
g.Entry("filter with bound parameters", helpers.Args{Input: "params_bind", Want: "filter_tag", Duration: 1 * time.Hour}),
g.Entry("filter hidden tag projection", helpers.Args{Input: "filter_hidden_tag", Duration: 1 * time.Hour}),
g.Entry("filter by non-indexed tag order by duration desc with limit 3", helpers.Args{Input: "sort_duration_no_index_limit", Duration: 1 * time.Hour}),
+ // Pins the ORDERED element sequence of a filtered index-order query with a
+ // non-zero offset, on both engines, against a committed want file: the criteria
+ // keeps durations 60, 300 and 500 of the five seeded, so offset 1 + limit 2 must
+ // yield 300 then 500. The offset earns its place because the merge cap is
+ // limit+offset while the egress slice is offset:offset+limit, so a double-offset
+ // error shows here and not in a limit-only case. This is NOT a tag-filter pushdown
+ // guard: duration carries an inverted index rule, so the scan drops every
+ // non-matching row before the merge sees it. sort_duration_no_index_limit is that
+ // guard, because its criteria tag span_id carries no index rule.
+ g.Entry("filter by indexed range order by duration asc with limit and offset",
+ helpers.Args{Input: "sort_duration_filter_range_limit_offset", Duration: 1 * time.Hour}),
g.Entry("get empty result by non-indexed tag", helpers.Args{Input: "filter_tag_empty", Duration: 1 * time.Hour, WantEmpty: true}),
g.Entry("get results by no non-index tag", helpers.Args{Input: "filter_no_indexed", Duration: 1 * time.Hour}),
g.Entry("numeric local index: less", helpers.Args{Input: "less", Duration: 1 * time.Hour}),
(skywalking-banyandb) 03/15: Bound the vectorized stream Top-N merge by limit+offset (#1322)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit c9335edb1d3773930ec8ffe6bab81cdaef7b39c0
Author: Bibek Jyoti Charah <bi...@gmail.com>
AuthorDate: Wed Sep 2 18:19:48 2026 +0530
Bound the vectorized stream Top-N merge by limit+offset (#1322)
* Bound the vectorized stream Top-N merge by limit+offset
SortedMerge buffered a row reference for every scanned row, sorted the
whole set in Finalize, and only then applied the distinct-ElementID cap.
A query with LIMIT 20 over a large time range therefore paid memory and
sort cost proportional to the number of rows scanned rather than to the
limit.
Prune the buffer during Consume instead: once it grows past twice the
cap, sort and re-apply the cap in place. Each prune keeps only the first
row of every retained ElementID. Later duplicates cannot reach the client
because the downstream Distinct drops them, and carrying them breaks the
bound - the cap can only fire when a new distinct ElementID appears, so
on a corpus whose cardinality sits just above the cap the boundary row is
dropped by one prune and not re-established for thousands of rows, during
which every duplicate accumulates and pins its source batch.
Query results are unchanged. A row dropped by a prune already sits behind
maxRows distinct ElementIDs, and rows consumed later can only sort ahead
of it, never behind it; ties still break on the globally monotonic arrival
sequence, so the emitted order is the same as sorting once and capping.
Filtered index-order queries still merge uncapped. Their tag filter runs
at egress, after the merge, so any cap here would starve it. Bounding that
path needs columnar tag-filter pushdown; the constraint is now stated in
the planner where the decision is made.
* Apply review feedback on the bounded Top-N merge
Use descriptive range-loop variable names in the tests and benchmarks, as
AGENTS.md requires, and rename the two single-letter indices in
sorted_merge.go that this change already touched.
Extract a shared benchmark helper parameterized by merge cap and expected
row count, so the capped, duplicate-heavy and uncapped cases cannot drift
apart. The corpus builder is now parameterized by ElementID cardinality
and shuffling instead of being duplicated per benchmark.
Correct the Consume comment. The buffer is checked only after a whole
batch has been appended, so it peaks just under pruneAt (2*maxRows) plus
one batch, not maxRows plus one batch. Measured at cap 1000 with batch
size 64, where the peak is 1984 against a 2*maxRows+batchSize bound of
2064.
Reword the prune-equivalence argument. Rows consumed later may sort to
either side of a dropped row; the property that matters is that rows
sorting ahead only add to the distinct ElementIDs preceding it and rows
sorting behind cannot remove any, so its distinct rank never improves.
Record in TestSortedMergePruneBoundsRetainedState why mergeCap+batchRows
is the exact bound for that configuration rather than the general one:
batchRows exceeds mergeCap there, so every Consume prunes.
---------
Co-authored-by: Gao Hongtao <ha...@gmail.com>
(cherry picked from commit 9f1ea35278b37ba3a5248d44e1ed02dc80398e34)
---
.../stream_plan_indexscan_local_vectorized.go | 7 ++
pkg/query/vectorized/stream/sorted_merge.go | 123 ++++++++++++++-----
.../vectorized/stream/sorted_merge_bench_test.go | 106 +++++++++++++++++
pkg/query/vectorized/stream/sorted_merge_test.go | 132 +++++++++++++++++++++
4 files changed, 337 insertions(+), 31 deletions(-)
diff --git a/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go b/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go
index 11b5ec3e0..4820784b4 100644
--- a/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go
+++ b/pkg/query/logical/stream/stream_plan_indexscan_local_vectorized.go
@@ -317,6 +317,13 @@ func (i *localIndexScan) ExecuteVectorized(ctx context.Context) ([]*vectorized.R
// trailing pipeline Limit a pass-through (MaxUint32, since a 0 limit emits
// NOTHING) — the egress then applies the tag filter and the true
// offset:offset+limit slice.
+ //
+ // This path is therefore NOT bounded by limit+offset, and cannot be until the
+ // tag filter moves ahead of the merge. Any cap here is unsound in general: the
+ // filter's selectivity is unknown, so the top-(limit+offset) rows BEFORE it can
+ // contain arbitrarily few surviving rows — including zero — while the row path
+ // keeps pulling until it has enough. Bounding it needs columnar tag-filter
+ // pushdown (out of scope here), not a bigger cap.
limitRows := uint32(0)
mergeCap := i.maxElementSize
if i.deferLimitToEgress {
diff --git a/pkg/query/vectorized/stream/sorted_merge.go b/pkg/query/vectorized/stream/sorted_merge.go
index 9cfa5f18e..815368482 100644
--- a/pkg/query/vectorized/stream/sorted_merge.go
+++ b/pkg/query/vectorized/stream/sorted_merge.go
@@ -52,6 +52,7 @@ type SortedMerge struct {
rows []mergeRowRef
batchSize int
maxRows int
+ pruneAt int
pos int
arriveSeq int
orderKey int
@@ -74,15 +75,39 @@ func NewSortedMerge(schema *vectorized.BatchSchema, desc bool, batchSize int) *S
}
}
+// maxPrunableCap is the largest merge cap worth pruning incrementally. A client
+// "max limit" query passes maxRows ≈ MaxUint32; a prune buffer of twice that
+// exceeds any real result set, so pruning could never fire and the merge falls
+// back to the plain sort-then-cap path.
+const maxPrunableCap = 1 << 20
+
// NewSortedMergeWithCap constructs a stream global-merge breaker that keeps only
-// the first maxRows rows in sort order after the stable sort — the correct
-// in-order top-N. A maxRows of 0 means unbounded (equivalent to NewSortedMerge).
-// The cap is applied AFTER the merge sorts, so it matches the row path's
-// cap-after-in-order-merge semantics (blockHeap.merge / MergeStreamResults).
+// the first maxRows rows in sort order — the correct in-order top-N. A maxRows
+// of 0 means unbounded (equivalent to NewSortedMerge).
+//
+// The cap is enforced INCREMENTALLY: Consume prunes the buffer back to the
+// in-order top-N whenever it grows past 2*maxRows, so merge state stays O(maxRows)
+// rows — peaking just under 2*maxRows plus the batch being consumed — and sort
+// work is O(N log maxRows) rather than O(N log N).
+//
+// This yields the same QUERY result as capping after one full sort (the emitted
+// rows are the deduped top-N rather than a duplicate-carrying prefix of it, which
+// the downstream Distinct makes indistinguishable). A row dropped by a prune
+// already sits behind maxRows distinct ElementIDs. Rows consumed later may sort
+// to either side of it, but neither side can rescue it: rows sorting ahead only
+// add to the distinct ElementIDs preceding it, and rows sorting behind cannot
+// remove any. Its distinct rank therefore never improves, so a row a prune drops
+// is one the full sort would have dropped too. Equal sort keys cannot reorder
+// across a prune either, because ties break on the globally monotonic arrive
+// sequence. The result still matches the row path's cap-after-in-order-merge
+// semantics (blockHeap.merge / MergeStreamResults).
func NewSortedMergeWithCap(schema *vectorized.BatchSchema, desc bool, batchSize, maxRows int) *SortedMerge {
s := NewSortedMerge(schema, desc, batchSize)
if maxRows > 0 {
s.maxRows = maxRows
+ if maxRows <= maxPrunableCap {
+ s.pruneAt = 2 * maxRows
+ }
}
return s
}
@@ -101,9 +126,10 @@ func (s *SortedMerge) OutputSchema() *vectorized.BatchSchema {
return s.schema
}
-// Consume buffers references to every active row in the batch. The batch is
-// retained (not copied); callers must not recycle a consumed batch until this
-// operator is closed.
+// Consume buffers references to every active row in the batch, then prunes the
+// buffer back to the in-order top-N once it outgrows the prune threshold. The
+// batch is retained (not copied); callers must not recycle a consumed batch
+// until this operator is closed.
func (s *SortedMerge) Consume(_ context.Context, batch *vectorized.RecordBatch) error {
if batch == nil || batch.ActiveLen() == 0 {
return nil
@@ -116,11 +142,20 @@ func (s *SortedMerge) Consume(_ context.Context, batch *vectorized.RecordBatch)
s.rows = append(s.rows, mergeRowRef{batch: batch, row: row, arrive: s.arriveSeq})
s.arriveSeq++
}
- return nil
+ } else {
+ for _, sel := range batch.Selection {
+ s.rows = append(s.rows, mergeRowRef{batch: batch, row: int(sel), arrive: s.arriveSeq})
+ s.arriveSeq++
+ }
}
- for _, sel := range batch.Selection {
- s.rows = append(s.rows, mergeRowRef{batch: batch, row: int(sel), arrive: s.arriveSeq})
- s.arriveSeq++
+ // Every prune leaves at most maxRows rows (capByDistinctElementID compacts to
+ // one row per retained ElementID), so the threshold needs no backoff. The
+ // buffer is checked only after a whole batch has been appended, so it peaks
+ // just under pruneAt (2*maxRows) plus one batch; each prune is therefore
+ // O(maxRows + batchSize) work, amortized O(N log(maxRows + batchSize)).
+ if s.pruneAt > 0 && len(s.rows) >= s.pruneAt {
+ s.sortRows()
+ s.capByDistinctElementID()
}
return nil
}
@@ -134,36 +169,50 @@ func (s *SortedMerge) Consume(_ context.Context, batch *vectorized.RecordBatch)
// the DEDUPED count reaches the limit (mergedResult.Len() < topN). Capping on
// the raw pre-dedup row count would drop unique rows the oracle keeps whenever a
// duplicate ElementID falls inside the retained window (an ElementID can span
-// parts). Rows are truncated at the sort position where the maxRows-th distinct
-// ElementID first appears, so the retained prefix carries every row (including
-// still-undeduped duplicates) up to that boundary; the downstream Distinct then
-// removes the duplicates from that already-correct top-N prefix.
+// parts). What survives is the FIRST row of each of the maxRows lowest-sorting
+// distinct ElementIDs — an element's first row in sort order is the only one the
+// downstream Distinct can ever emit, so later duplicates are dropped here rather
+// than carried.
func (s *SortedMerge) Finalize(context.Context) error {
- sort.SliceStable(s.rows, func(i, j int) bool {
- cmp := s.compare(s.rows[i], s.rows[j])
+ s.sortRows()
+ if s.maxRows > 0 {
+ s.capByDistinctElementID()
+ }
+ return nil
+}
+
+// sortRows stably orders the buffered rows by the schema order key, breaking
+// ties on arrival order so downstream first-seen dedup stays deterministic.
+func (s *SortedMerge) sortRows() {
+ sort.SliceStable(s.rows, func(leftIdx, rightIdx int) bool {
+ cmp := s.compare(s.rows[leftIdx], s.rows[rightIdx])
if cmp == 0 {
- return s.rows[i].arrive < s.rows[j].arrive
+ return s.rows[leftIdx].arrive < s.rows[rightIdx].arrive
}
if s.desc {
return cmp > 0
}
return cmp < 0
})
- if s.maxRows > 0 {
- s.capByDistinctElementID()
- }
- return nil
}
-// capByDistinctElementID truncates the sorted rows at the first position past
-// which maxRows distinct ElementIDs have been seen, so the retained prefix
-// contains exactly the top-N unique elements in sort order (plus any duplicate
-// rows interleaved before that boundary, which the downstream Distinct drops).
+// capByDistinctElementID compacts the sorted rows down to the FIRST row of each
+// of the first maxRows distinct ElementIDs, in sort order — the in-order top-N.
// When the schema carries no ElementID column it falls back to a raw row cap.
+//
+// Later duplicates of a retained ElementID are dropped here rather than carried
+// to the downstream Distinct, which would drop them anyway (an ElementID's first
+// row in sort order is the only one that can ever reach the client). Carrying
+// them is not merely wasteful, it breaks the bound: the cap can only fire when a
+// NEW distinct ID appears, so on a corpus whose cardinality sits just above
+// maxRows the boundary row is dropped by one prune and not re-established for
+// thousands of rows, during which every duplicate accumulates and pins its source
+// batch. Compacting keeps the buffer at exactly min(maxRows, distinct IDs seen)
+// after every prune, in every cardinality regime.
func (s *SortedMerge) capByDistinctElementID() {
if s.elemIdx < 0 {
if len(s.rows) > s.maxRows {
- s.rows = s.rows[:s.maxRows]
+ s.truncate(s.maxRows)
}
return
}
@@ -176,18 +225,30 @@ func (s *SortedMerge) capByDistinctElementID() {
capHint = len(s.rows)
}
seen := make(map[int64]struct{}, capHint)
- for i := range s.rows {
- ref := s.rows[i]
+ kept := 0
+ for rowIdx := range s.rows {
+ ref := s.rows[rowIdx]
id := ref.batch.Columns[s.elemIdx].(*vectorized.TypedColumn[int64]).Data()[ref.row]
if _, ok := seen[id]; ok {
continue
}
if len(seen) == s.maxRows {
- s.rows = s.rows[:i]
- return
+ break
}
seen[id] = struct{}{}
+ s.rows[kept] = ref
+ kept++
}
+ s.truncate(kept)
+}
+
+// truncate cuts the buffer to n rows and clears the dropped tail. Zeroing the
+// tail matters: the backing array survives the reslice, so a stale batch pointer
+// left there would keep every source batch the merge has ever consumed reachable
+// and defeat the bound the prune is there to provide.
+func (s *SortedMerge) truncate(n int) {
+ clear(s.rows[n:])
+ s.rows = s.rows[:n]
}
// compare returns -1/0/1 comparing the sort keys of two row refs. Index-order
diff --git a/pkg/query/vectorized/stream/sorted_merge_bench_test.go b/pkg/query/vectorized/stream/sorted_merge_bench_test.go
new file mode 100644
index 000000000..4192c1575
--- /dev/null
+++ b/pkg/query/vectorized/stream/sorted_merge_bench_test.go
@@ -0,0 +1,106 @@
+// 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 stream
+
+import (
+ "math/rand"
+ "testing"
+
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
+)
+
+const (
+ benchRows = 100_000
+ benchBatchSize = 1024
+ benchTopN = 20
+)
+
+// benchCorpus builds a fixed benchRows-row corpus split into benchBatchSize
+// batches. A cardinality of 0 gives every row its own ElementID; a positive one
+// caps the number of distinct ElementIDs. Shuffling denies the merge an
+// already-ordered input. The corpus is built once per benchmark and replayed on
+// every iteration, so the reported allocations are the merge pipeline's rather
+// than the fixture's.
+func benchCorpus(schema *vectorized.BatchSchema, cardinality int, shuffle bool) []*vectorized.RecordBatch {
+ rows := make([]testRow, benchRows)
+ for rowIdx := range rows {
+ elemID := uint64(rowIdx)
+ if cardinality > 0 {
+ elemID = uint64(rowIdx % cardinality)
+ }
+ rows[rowIdx] = testRow{ts: int64(rowIdx), elemID: elemID}
+ }
+ if shuffle {
+ rng := rand.New(rand.NewSource(1)) //nolint:gosec // fixed seed: the corpus must be identical across runs to compare benchmarks
+ rng.Shuffle(len(rows), func(leftIdx, rightIdx int) {
+ rows[leftIdx], rows[rightIdx] = rows[rightIdx], rows[leftIdx]
+ })
+ }
+ var batches []*vectorized.RecordBatch
+ for start := 0; start < len(rows); start += benchBatchSize {
+ batches = append(batches, buildBatch(schema, rows[start:min(start+benchBatchSize, len(rows))]))
+ }
+ return batches
+}
+
+// runMergeBenchmark drives the merge pipeline over a prebuilt corpus at the given
+// merge cap, failing if the drained result is not wantRows rows. Sharing it keeps
+// the capped, duplicate-heavy and uncapped cases from drifting apart.
+func runMergeBenchmark(b *testing.B, schema *vectorized.BatchSchema, batches []*vectorized.RecordBatch, mergeCap, wantRows int) {
+ b.Helper()
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ pipe, err := BuildStreamMergePipeline(
+ newStaticBatchSource(schema, batches...), schema, true, 0, benchTopN, benchBatchSize, mergeCap)
+ if err != nil {
+ b.Fatalf("build pipeline: %v", err)
+ }
+ tss, _, _ := drainRows(b, pipe)
+ if len(tss) != wantRows {
+ b.Fatalf("expected %d rows, got %d", wantRows, len(tss))
+ }
+ if closeErr := pipe.Close(); closeErr != nil {
+ b.Fatalf("close pipeline: %v", closeErr)
+ }
+ }
+}
+
+// BenchmarkSortedMergeTopN is the LIMIT-20-over-100k-rows case from the
+// bounded-merge issue: it reports the auxiliary memory the merge spends to
+// return benchTopN elements out of benchRows scanned rows.
+func BenchmarkSortedMergeTopN(b *testing.B) {
+ schema := tsSchema()
+ runMergeBenchmark(b, schema, benchCorpus(schema, 0, true), benchTopN, benchTopN)
+}
+
+// BenchmarkSortedMergeTopNDuplicateHeavy is the adversarial corpus for the
+// incremental prune: only benchTopN/2 distinct ElementIDs across all benchRows
+// rows, so the distinct-ID cap can never truncate on distinct count alone.
+func BenchmarkSortedMergeTopNDuplicateHeavy(b *testing.B) {
+ schema := tsSchema()
+ runMergeBenchmark(b, schema, benchCorpus(schema, benchTopN/2, false), benchTopN, benchTopN/2)
+}
+
+// BenchmarkSortedMergeUncapped is the uncapped control: the same corpus with
+// mergeCap 0 (the filtered index-order path), which must keep buffering every
+// row. It bounds how much of the capped case's cost is inherent to the scan.
+func BenchmarkSortedMergeUncapped(b *testing.B) {
+ schema := tsSchema()
+ runMergeBenchmark(b, schema, benchCorpus(schema, 0, true), 0, benchTopN)
+}
diff --git a/pkg/query/vectorized/stream/sorted_merge_test.go b/pkg/query/vectorized/stream/sorted_merge_test.go
index 621a5fe3b..67c323445 100644
--- a/pkg/query/vectorized/stream/sorted_merge_test.go
+++ b/pkg/query/vectorized/stream/sorted_merge_test.go
@@ -19,11 +19,14 @@ package stream
import (
"context"
+ "fmt"
+ "math/rand"
"testing"
"github.com/stretchr/testify/require"
"github.com/apache/skywalking-banyandb/pkg/convert"
+ "github.com/apache/skywalking-banyandb/pkg/query/vectorized"
)
func mergePipeline(t *testing.T, source *staticBatchSource, desc bool, batchSize int) []int64 {
@@ -208,6 +211,135 @@ func TestSortedMergeHugeCapDoesNotOverAllocate(t *testing.T) {
require.Equal(t, []uint64{1, 2, 3}, ids)
}
+// TestSortedMergePruneMatchesFullSort is the differential oracle for the
+// incremental prune: over a corpus large enough that Consume prunes many times,
+// the capped merge must return exactly what an UNCAPPED merge returns truncated
+// to the first mergeCap distinct ElementIDs. It deliberately seeds colliding
+// timestamps (ties, resolved by arrival order) and repeated ElementIDs (one
+// element spanning parts), since those are the two cases a naive prune gets
+// wrong, and sweeps an ID cardinality BELOW the cap, which is the case that
+// backs the prune threshold off instead of truncating.
+func TestSortedMergePruneMatchesFullSort(t *testing.T) {
+ for _, seed := range []int64{1, 7, 42} {
+ for _, mergeCap := range []int{1, 3, 20, 137} {
+ for _, idCardinality := range []int{pruneParityRows / 2, 8} {
+ for _, desc := range []bool{false, true} {
+ runPruneParityCase(t, seed, mergeCap, idCardinality, desc)
+ }
+ }
+ }
+ }
+}
+
+const (
+ pruneParityRows = 2000
+ pruneParityBatch = 64
+)
+
+// runPruneParityCase asserts one (seed, cap, cardinality, direction) of the
+// prune differential oracle.
+func runPruneParityCase(t *testing.T, seed int64, mergeCap, idCardinality int, desc bool) {
+ t.Helper()
+ name := fmt.Sprintf("seed%d/cap%d/ids%d/desc%v", seed, mergeCap, idCardinality, desc)
+ rng := rand.New(rand.NewSource(seed)) //nolint:gosec // fixed seed: a failing case must reproduce exactly
+ rows := make([]testRow, pruneParityRows)
+ for rowIdx := range rows {
+ rows[rowIdx] = testRow{ts: int64(rng.Intn(pruneParityRows / 4)), elemID: uint64(rng.Intn(idCardinality))}
+ }
+ // SortedMerge validates batch schemas by POINTER identity, so every batch in
+ // one case must share a single instance.
+ schema := tsSchema()
+ var batches []*vectorized.RecordBatch
+ for begin := 0; begin < len(rows); begin += pruneParityBatch {
+ batches = append(batches, buildBatch(schema, rows[begin:min(begin+pruneParityBatch, len(rows))]))
+ }
+
+ uncapped, err := BuildStreamMergePipeline(newStaticBatchSource(schema, batches...), schema, desc, 0, 1<<20, pruneParityBatch, 0)
+ require.NoError(t, err, name)
+ wantTS, wantIDs, _ := drainRows(t, uncapped)
+ require.NoError(t, uncapped.Close(), name)
+ if len(wantIDs) > mergeCap {
+ wantTS, wantIDs = wantTS[:mergeCap], wantIDs[:mergeCap]
+ }
+
+ capped, err := BuildStreamMergePipeline(newStaticBatchSource(schema, batches...), schema, desc, 0, uint32(mergeCap), pruneParityBatch, mergeCap)
+ require.NoError(t, err, name)
+ gotTS, gotIDs, _ := drainRows(t, capped)
+ require.NoError(t, capped.Close(), name)
+
+ require.Equal(t, wantIDs, gotIDs, name)
+ require.Equal(t, wantTS, gotTS, name)
+}
+
+// TestSortedMergePruneBoundsRetainedState asserts the bound the prune exists to
+// provide, white-box: after consuming far more rows than the cap, the merge holds
+// O(cap) row refs pinning O(cap) source batches — not one ref per scanned row
+// pinning every batch. It also checks the dropped tail of the backing array is
+// zeroed, since a stale batch pointer there is invisible to len() but still keeps
+// its whole batch reachable for the GC.
+func TestSortedMergePruneBoundsRetainedState(t *testing.T) {
+ const (
+ mergeCap = 20
+ batchRows = 64
+ batchCnt = 100
+ )
+ // Distinct-ElementID cardinality is swept, not fixed. An all-distinct corpus
+ // is the EASY case: the cap boundary is re-established by every batch. The
+ // adversarial case is a cardinality just above the cap — the cap can only fire
+ // when a new distinct id appears, so a buffer that carried duplicates would
+ // grow for thousands of rows between boundaries. mergeCap+1 is that worst case;
+ // a cardinality below the cap (the cap can never fire at all) is also covered.
+ for _, cardinality := range []int{mergeCap + 1, mergeCap + 5, mergeCap / 2, batchCnt * batchRows} {
+ t.Run(fmt.Sprintf("cardinality%d", cardinality), func(t *testing.T) {
+ runPruneBoundsCase(t, mergeCap, batchRows, batchCnt, cardinality)
+ })
+ }
+}
+
+// runPruneBoundsCase asserts the retained-state bound for one ElementID cardinality.
+func runPruneBoundsCase(t *testing.T, mergeCap, batchRows, batchCnt, cardinality int) {
+ t.Helper()
+ schema := tsSchema()
+ // Shuffled, NOT ascending: with the scan already in sort order every survivor
+ // comes from the newest batch, so the pinned-batch assertion below would hold
+ // at 1 no matter how badly the prune leaked. Uncorrelated scan order is the
+ // real worst case (a seriesID-major scan interleaves series, so global sort
+ // order is not the arrival order) and is what makes the bound load-bearing.
+ all := make([]testRow, batchCnt*batchRows)
+ for rowIdx := range all {
+ all[rowIdx] = testRow{ts: int64(rowIdx), elemID: uint64(rowIdx % cardinality)}
+ }
+ rng := rand.New(rand.NewSource(1)) //nolint:gosec // fixed seed: a failing case must reproduce exactly
+ rng.Shuffle(len(all), func(leftIdx, rightIdx int) { all[leftIdx], all[rightIdx] = all[rightIdx], all[leftIdx] })
+ merge := NewSortedMergeWithCap(schema, true, batchRows, mergeCap)
+ require.NoError(t, merge.Init(context.Background()))
+ for batchIdx := 0; batchIdx < batchCnt; batchIdx++ {
+ require.NoError(t, merge.Consume(context.Background(), buildBatch(schema, all[batchIdx*batchRows:(batchIdx+1)*batchRows])))
+ // Checked DURING consume, not after Finalize: the final sort-then-cap
+ // bounds the buffer either way, so only the mid-stream size proves the
+ // prune is doing the work.
+ //
+ // mergeCap+batchRows is the exact bound for THIS configuration, not the
+ // general one. batchRows exceeds mergeCap here, so every Consume crosses
+ // the 2*mergeCap threshold and prunes; a config with batchRows < mergeCap
+ // would take several batches to reach it and peak just under
+ // 2*mergeCap+batchRows instead.
+ require.LessOrEqual(t, len(merge.rows), mergeCap+batchRows,
+ "merge state must stay O(cap) while scanning, not O(scanned rows)")
+ }
+ require.NoError(t, merge.Finalize(context.Background()))
+
+ pinned := make(map[*vectorized.RecordBatch]struct{})
+ for _, ref := range merge.rows {
+ pinned[ref.batch] = struct{}{}
+ }
+ require.LessOrEqual(t, len(pinned), mergeCap, "retained refs must not pin every consumed batch")
+ for _, ref := range merge.rows[len(merge.rows):cap(merge.rows)] {
+ require.Nil(t, ref.batch, "dropped tail must be cleared so its batches are collectable")
+ }
+ require.NoError(t, merge.Close())
+}
+
func bytesCompare(a, b []byte) int {
switch {
case string(a) < string(b):
(skywalking-banyandb) 07/15: Pre setting logging level before flags parse (#1337)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 438b215f5585d357b77032ead177d5359e0360e4
Author: mrproliu <74...@qq.com>
AuthorDate: Thu Sep 10 20:47:18 2026 +0800
Pre setting logging level before flags parse (#1337)
* Pre setting logging level before flags parse
---------
Co-authored-by: Gao Hongtao <ha...@gmail.com>
(cherry picked from commit c664390cd6b8023c783ea4df7b0d28d0b6558078)
---
banyand/metadata/discovery/dns/dns.go | 24 ++++-
banyand/metadata/discovery/dns/level_test.go | 65 ++++++++++++
docs/operation/observability/logging.md | 9 ++
pkg/logger/setting.go | 85 +++++++++++++++-
pkg/logger/setting_test.go | 143 +++++++++++++++++++++++++++
5 files changed, 320 insertions(+), 6 deletions(-)
diff --git a/banyand/metadata/discovery/dns/dns.go b/banyand/metadata/discovery/dns/dns.go
index 2b2340870..4bb9413ec 100644
--- a/banyand/metadata/discovery/dns/dns.go
+++ b/banyand/metadata/discovery/dns/dns.go
@@ -26,6 +26,7 @@ import (
"sync"
"time"
+ "github.com/rs/zerolog"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
@@ -315,13 +316,34 @@ func (s *Service) retryScheduler(ctx context.Context) {
}
}
+// consecutiveDNSFailuresBeforeError is how many queries have to fail in a row, once the init
+// phase is over, before the discovery loop reports the failure at error level.
+const consecutiveDNSFailuresBeforeError = 3
+
+// dnsFailureLevel picks the level a failed DNS query is reported at. Nodes come up in no
+// particular order, so misses while the cluster is still forming are expected and stay at warn
+// however many there are; afterwards the query is retried every interval and recovers on its
+// own, so a single miss is still only a warning and error is kept for a run of them.
+func dnsFailureLevel(inInitPhase bool, consecutiveFailures int) zerolog.Level {
+ if inInitPhase || consecutiveFailures < consecutiveDNSFailuresBeforeError {
+ return zerolog.WarnLevel
+ }
+ return zerolog.ErrorLevel
+}
+
func (s *Service) discoveryLoop(ctx context.Context) {
// add the init phase finish time
initPhaseEnd := time.Now().Add(s.initDuration)
+ var consecutiveFailures int
for {
if err := s.queryDNSAndUpdateNodes(ctx); err != nil {
- s.GetLogger().Err(err).Msg("failed to query DNS and update nodes")
+ consecutiveFailures++
+ level := dnsFailureLevel(time.Now().Before(initPhaseEnd), consecutiveFailures)
+ s.GetLogger().WithLevel(level).Err(err).Int("consecutive_failures", consecutiveFailures).
+ Msg("failed to query DNS and update nodes")
+ } else {
+ consecutiveFailures = 0
}
// wait for next interval
diff --git a/banyand/metadata/discovery/dns/level_test.go b/banyand/metadata/discovery/dns/level_test.go
new file mode 100644
index 000000000..3be154a23
--- /dev/null
+++ b/banyand/metadata/discovery/dns/level_test.go
@@ -0,0 +1,65 @@
+// 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 dns
+
+import (
+ "testing"
+
+ "github.com/rs/zerolog"
+)
+
+func TestDNSFailureLevel(t *testing.T) {
+ tests := []struct {
+ name string
+ failures int
+ want zerolog.Level
+ inInitPhase bool
+ }{
+ {name: "first failure in the init phase", inInitPhase: true, failures: 1, want: zerolog.WarnLevel},
+ {
+ name: "a run of failures in the init phase is still expected",
+ inInitPhase: true,
+ failures: consecutiveDNSFailuresBeforeError,
+ want: zerolog.WarnLevel,
+ },
+ {name: "the init phase never escalates", inInitPhase: true, failures: 100, want: zerolog.WarnLevel},
+ {name: "first failure after the init phase", failures: 1, want: zerolog.WarnLevel},
+ {
+ name: "one short of the threshold",
+ failures: consecutiveDNSFailuresBeforeError - 1,
+ want: zerolog.WarnLevel,
+ },
+ {
+ name: "the threshold escalates",
+ failures: consecutiveDNSFailuresBeforeError,
+ want: zerolog.ErrorLevel,
+ },
+ {
+ name: "past the threshold stays escalated",
+ failures: consecutiveDNSFailuresBeforeError + 1,
+ want: zerolog.ErrorLevel,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := dnsFailureLevel(tt.inInitPhase, tt.failures); got != tt.want {
+ t.Errorf("dnsFailureLevel(%t, %d) = %v, want %v", tt.inInitPhase, tt.failures, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/docs/operation/observability/logging.md b/docs/operation/observability/logging.md
index 0c5e225fb..77ce750eb 100644
--- a/docs/operation/observability/logging.md
+++ b/docs/operation/observability/logging.md
@@ -2,6 +2,15 @@
BanyanDB uses the [zerolog](https://github.com/rs/zerolog) library for logging. The log level can be set using the `log-level` flag. The supported log levels are `debug`, `info`, `warn`, `error`, and `fatal`. The default log level is `info`.
+A process logs a little before the configured logger exists: the command tree registers its
+flags, and `GOMAXPROCS` is resolved. Those early lines honor `--logging-level` / `--logging-env`
+and their environment variables all the same -- the level is read straight from the command line
+and the environment at that point, with the flag winning over the environment, as everywhere else.
+Only `--logging-modules` and `--logging-levels` are not applied that early; they take effect once
+the logger is initialized. Without any of them the early lines stay at `debug`, which is the
+historical behavior. The ASCII banner is printed directly to the console and is not a log line,
+so no level silences it.
+
`logging-env` is used to set the logging environment. The default value is `prod`. The logging environment can be set to `dev` for development or `prod` for production. The logging environment affects the log format and output. In the `dev` environment, logs are output in a human-readable format, while in the `prod` environment, logs are output in JSON format.
`logging-modules` and `logging-levels` are used to set the log level for specific modules. The `logging-modules` flag is a comma-separated list of module names, and the `logging-levels` flag is a comma-separated list of log levels corresponding to the module names. The log level for a specific module can be set using these flags. Available modules are `storage`, `distributed-query`, `liaison-grpc`, `liaison-http`, `measure`, `stream`, `trace`, `metadata`, `property-schema-registry`, `met [...]
diff --git a/pkg/logger/setting.go b/pkg/logger/setting.go
index 32d0e1534..bfa8429d1 100644
--- a/pkg/logger/setting.go
+++ b/pkg/logger/setting.go
@@ -30,7 +30,15 @@ import (
"github.com/rs/zerolog"
)
-const rootName = "ROOT"
+const (
+ rootName = "ROOT"
+
+ // Environment variables bound to the --logging-env and --logging-level flags by
+ // pkg/config. They are read directly here because the root logger has to produce
+ // output before the command tree that owns those flags exists.
+ envLoggingEnv = "BYDB_LOGGING_ENV"
+ envLoggingLevel = "BYDB_LOGGING_LEVEL"
+)
var root = rootLogger{}
@@ -52,16 +60,83 @@ func (rl *rootLogger) setDefault() {
if rl.done == 0 {
defer atomic.StoreUint32(&rl.done, 1)
var err error
- rl.l, err = getLogger(Logging{
- Env: "prod",
- Level: "debug",
- })
+ rl.l, err = getLogger(defaultLogging())
if err != nil {
panic(err)
}
}
}
+// defaultLogging returns the configuration the root logger falls back to until Init runs.
+func defaultLogging() Logging {
+ return earlyLogging(os.Args[1:], os.Getenv)
+}
+
+// earlyLogging resolves the logging configuration from the sources available before the
+// command tree exists, so that the lines emitted while it is built follow the level the
+// operator asked for. Flags win over the environment, the order config.Load applies later.
+// An unusable level is ignored rather than fatal here, because Init reports it with a
+// proper error once it runs.
+func earlyLogging(args []string, getenv func(string) string) Logging {
+ cfg := Logging{Env: "prod", Level: "debug"}
+ // An unset variable and one set to an empty string are the same thing to viper, which
+ // ignores both, so only a non-empty value counts here.
+ if env := getenv(envLoggingEnv); env != "" {
+ cfg.Env = env
+ }
+ if level := getenv(envLoggingLevel); level != "" {
+ cfg.Level = acceptLevel(cfg.Level, level)
+ }
+ // A flag, on the other hand, is applied whenever it appears, empty value included: that is
+ // what pflag reports as changed and what config.Load hands to Init later on.
+ flags := loggingFlagsFromArgs(args)
+ if flags.envSet {
+ cfg.Env = flags.env
+ }
+ if flags.levelSet {
+ cfg.Level = acceptLevel(cfg.Level, flags.level)
+ }
+ return cfg
+}
+
+// acceptLevel returns candidate when zerolog can parse it, and current otherwise. An empty
+// candidate parses to NoLevel, the same value Init would end up with.
+func acceptLevel(current, candidate string) string {
+ if _, err := zerolog.ParseLevel(candidate); err != nil {
+ return current
+ }
+ return candidate
+}
+
+// earlyFlags carries the two logging flags scanned out of a raw command line. Each value
+// comes with whether the flag was given at all, because an explicit empty value is a value:
+// pflag reports it as changed and config.Load then keeps the environment out of the way.
+type earlyFlags struct {
+ env string
+ level string
+ envSet bool
+ levelSet bool
+}
+
+// loggingFlagsFromArgs reads the two logging flags out of a raw argument list. It reuses
+// pflag so the values match what cobra resolves later, and tolerates everything else in
+// the list: the flags of the real command are not declared here, and the subcommand name
+// is just a positional argument. A malformed command line is the real parser's business to
+// report, so whatever was read before the error is kept.
+func loggingFlagsFromArgs(args []string) earlyFlags {
+ var flags earlyFlags
+ fs := pflag.NewFlagSet("early-logging", pflag.ContinueOnError)
+ fs.ParseErrorsAllowlist.UnknownFlags = true
+ fs.SetOutput(io.Discard)
+ fs.Usage = func() {}
+ fs.StringVar(&flags.env, "logging-env", "", "")
+ fs.StringVar(&flags.level, "logging-level", "", "")
+ _ = fs.Parse(args)
+ flags.envSet = fs.Changed("logging-env")
+ flags.levelSet = fs.Changed("logging-level")
+ return flags
+}
+
func (rl *rootLogger) set(cfg Logging) error {
rl.m.Lock()
defer rl.m.Unlock()
diff --git a/pkg/logger/setting_test.go b/pkg/logger/setting_test.go
new file mode 100644
index 000000000..6405a0b7c
--- /dev/null
+++ b/pkg/logger/setting_test.go
@@ -0,0 +1,143 @@
+// 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 logger
+
+import "testing"
+
+func TestEarlyLogging(t *testing.T) {
+ tests := []struct {
+ env map[string]string
+ name string
+ wantEnv string
+ wantLevel string
+ args []string
+ }{
+ {
+ name: "nothing set keeps the built-in defaults",
+ wantEnv: "prod",
+ wantLevel: "debug",
+ },
+ {
+ name: "level from the environment",
+ env: map[string]string{envLoggingLevel: "error"},
+ wantEnv: "prod",
+ wantLevel: "error",
+ },
+ {
+ name: "env from the environment",
+ env: map[string]string{envLoggingEnv: "dev"},
+ wantEnv: "dev",
+ wantLevel: "debug",
+ },
+ {
+ name: "level from a flag joined by an equals sign",
+ args: []string{"liaison", "--logging-level=error"},
+ wantEnv: "prod",
+ wantLevel: "error",
+ },
+ {
+ name: "level from a flag separated by a space",
+ args: []string{"liaison", "--logging-level", "error"},
+ wantEnv: "prod",
+ wantLevel: "error",
+ },
+ {
+ name: "both flags at once",
+ args: []string{"data", "--logging-env", "dev", "--logging-level", "warn"},
+ wantEnv: "dev",
+ wantLevel: "warn",
+ },
+ {
+ name: "a flag wins over the environment",
+ env: map[string]string{envLoggingLevel: "info", envLoggingEnv: "prod"},
+ args: []string{"standalone", "--logging-level=error", "--logging-env=dev"},
+ wantEnv: "dev",
+ wantLevel: "error",
+ },
+ {
+ name: "the environment still applies to what the flags leave out",
+ env: map[string]string{envLoggingEnv: "dev"},
+ args: []string{"standalone", "--logging-level=error"},
+ wantEnv: "dev",
+ wantLevel: "error",
+ },
+ {
+ name: "flags of the real command do not disturb the scan",
+ args: []string{"data", "--grpc-port", "17912", "--logging-level=error", "--node-labels", "type=hot"},
+ wantEnv: "prod",
+ wantLevel: "error",
+ },
+ {
+ name: "an unparsable level from a flag falls back",
+ args: []string{"liaison", "--logging-level=bogus"},
+ wantEnv: "prod",
+ wantLevel: "debug",
+ },
+ {
+ name: "an unparsable level from the environment falls back",
+ env: map[string]string{envLoggingLevel: "bogus"},
+ wantEnv: "prod",
+ wantLevel: "debug",
+ },
+ {
+ name: "an unparsable flag does not discard a usable environment value",
+ env: map[string]string{envLoggingLevel: "error"},
+ args: []string{"liaison", "--logging-level=bogus"},
+ wantEnv: "prod",
+ wantLevel: "error",
+ },
+ {
+ name: "a trailing flag with no value is tolerated",
+ args: []string{"liaison", "--logging-level"},
+ wantEnv: "prod",
+ wantLevel: "debug",
+ },
+ {
+ name: "an explicitly empty env flag wins over the environment",
+ env: map[string]string{envLoggingEnv: "dev"},
+ args: []string{"liaison", "--logging-env="},
+ wantEnv: "",
+ wantLevel: "debug",
+ },
+ {
+ name: "an explicitly empty level flag wins over the environment",
+ env: map[string]string{envLoggingLevel: "error"},
+ args: []string{"liaison", "--logging-level="},
+ wantEnv: "prod",
+ wantLevel: "",
+ },
+ {
+ name: "an empty environment value does not override the default",
+ env: map[string]string{envLoggingEnv: "", envLoggingLevel: ""},
+ wantEnv: "prod",
+ wantLevel: "debug",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ getenv := func(key string) string { return tt.env[key] }
+ cfg := earlyLogging(tt.args, getenv)
+ if cfg.Env != tt.wantEnv {
+ t.Errorf("Env = %q, want %q", cfg.Env, tt.wantEnv)
+ }
+ if cfg.Level != tt.wantLevel {
+ t.Errorf("Level = %q, want %q", cfg.Level, tt.wantLevel)
+ }
+ })
+ }
+}
(skywalking-banyandb) 04/15: fix(canopy): enforce readonly role on /monitoring/* proxy (#1330)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 0c433fa67b24cf24cc399d7757a1af12ebc104af
Author: Owen Willison <41...@users.noreply.github.com>
AuthorDate: Sat Sep 5 21:53:56 2026 +0800
fix(canopy): enforce readonly role on /monitoring/* proxy (#1330)
(cherry picked from commit 2f39fb53d896ad3f1e685f77bba4cf862b9954fe)
---
canopy/server/src/plugins/proxy.ts | 5 +++--
canopy/server/test/bff.test.ts | 25 +++++++++++++++++++++++++
2 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/canopy/server/src/plugins/proxy.ts b/canopy/server/src/plugins/proxy.ts
index aff10dc57..26483d7b4 100644
--- a/canopy/server/src/plugins/proxy.ts
+++ b/canopy/server/src/plugins/proxy.ts
@@ -166,8 +166,9 @@ export async function registerProxy(app: FastifyInstance, config: Config): Promi
await forwardRequest(upstreamBase, upstreamPath, request, reply, config, upstreamAuth);
});
- // /monitoring/* — forwarded to MONITOR_TARGET with /monitoring prefix STRIPPED
- app.all('/monitoring/*', { preHandler: [requireAuth] }, async (request, reply) => {
+ // /monitoring/* — forwarded to MONITOR_TARGET with /monitoring prefix STRIPPED.
+ // Same role gate as /api/*: readonly sessions may only use GET/HEAD/OPTIONS.
+ app.all('/monitoring/*', { preHandler: [requireAuth, enforceRole] }, async (request, reply) => {
const scopedPath = stripBasePath(request.url, config.basePath);
const stripped = scopedPath.replace(/^\/monitoring/, '');
await forwardRequest(config.monitorTarget, stripped, request, reply, config, undefined);
diff --git a/canopy/server/test/bff.test.ts b/canopy/server/test/bff.test.ts
index 7d1e27213..3aaba3d6c 100644
--- a/canopy/server/test/bff.test.ts
+++ b/canopy/server/test/bff.test.ts
@@ -505,4 +505,29 @@ describe('/monitoring proxy', () => {
// Strip /monitoring prefix → sent to monitor.test:2121/metrics → 200
expect(res.statusCode).toBe(200);
});
+
+ it('readonly user: POST /monitoring/* returns 403', async () => {
+ const cookie = await loginAs(app, 'reader', 'readpass');
+ const res = await app.inject({
+ method: 'POST',
+ url: '/monitoring/debug/panic',
+ headers: { cookie },
+ payload: {},
+ });
+ expect(res.statusCode).toBe(403);
+ expect(res.json()).toMatchObject({ error: 'forbidden' });
+ });
+
+ it('admin user: POST /monitoring/* is allowed through', async () => {
+ const pool = mockAgent.get('http://monitor.test:2121');
+ pool.intercept({ path: '/debug/panic', method: 'POST' }).reply(200, 'ok');
+ const cookie = await loginAs(app, 'admin', 'adminpass');
+ const res = await app.inject({
+ method: 'POST',
+ url: '/monitoring/debug/panic',
+ headers: { cookie },
+ payload: {},
+ });
+ expect(res.statusCode).toBe(200);
+ });
});
(skywalking-banyandb) 12/15: fix(bydbql): reject deeply nested WHERE parentheses to prevent stack overflow (#1343)
Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch release-0.11.1
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 067d5e817e50566731127cf65ae6e1bb464e6cad
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Mon Sep 14 14:49:27 2026 +0800
fix(bydbql): reject deeply nested WHERE parentheses to prevent stack overflow (#1343)
(cherry picked from commit 9241fb2f3b4e81018421f2ded4504711ac3795bd)
---
pkg/bydbql/bydbql_test.go | 16 ++++++++++++++++
pkg/bydbql/parser.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 63 insertions(+)
diff --git a/pkg/bydbql/bydbql_test.go b/pkg/bydbql/bydbql_test.go
index e29b5559c..28947e2dd 100644
--- a/pkg/bydbql/bydbql_test.go
+++ b/pkg/bydbql/bydbql_test.go
@@ -790,6 +790,22 @@ var _ = Describe("Parser", func() {
Expect(err).ToNot(BeNil())
Expect(grammar).To(BeNil())
})
+
+ It("rejects deeply nested WHERE parentheses instead of crashing", func() {
+ nested := "SELECT * FROM STREAM sw IN default WHERE " +
+ strings.Repeat("(", 500000) + "x=1" + strings.Repeat(")", 500000)
+ grammar, err := ParseQuery(nested)
+ Expect(err).ToNot(BeNil())
+ Expect(grammar).To(BeNil())
+ })
+
+ It("still parses WHERE parentheses nested within the accepted limit", func() {
+ nested := "SELECT * FROM STREAM sw IN default WHERE " +
+ strings.Repeat("(", 10) + "x=1" + strings.Repeat(")", 10)
+ grammar, err := ParseQuery(nested)
+ Expect(err).To(BeNil())
+ Expect(grammar).NotTo(BeNil())
+ })
})
})
diff --git a/pkg/bydbql/parser.go b/pkg/bydbql/parser.go
index 66892e088..9596e41d8 100644
--- a/pkg/bydbql/parser.go
+++ b/pkg/bydbql/parser.go
@@ -76,8 +76,20 @@ func init() {
}
}
+// maxParenDepth bounds the parenthesis nesting ParseQuery accepts. Participle's
+// recursive-descent parser calls back into GrammarOrExpr once per nesting level
+// with no built-in recursion limit, so a query with enough nested parentheses
+// can exhaust the goroutine stack and crash the process via runtime.throw — a
+// fatal error recover() cannot catch. Rejecting excess nesting with a flat,
+// non-recursive scan before the query ever reaches the parser keeps the fix
+// itself immune to the same failure mode.
+const maxParenDepth = 64
+
// ParseQuery parses a BydbQL query string into a Grammar struct.
func ParseQuery(query string) (*Grammar, error) {
+ if err := checkParenDepth(query); err != nil {
+ return nil, err
+ }
// Parse using Participle
grammar, err := particpleParser.ParseString("", query)
if err != nil {
@@ -86,3 +98,38 @@ func ParseQuery(query string) (*Grammar, error) {
return grammar, nil
}
+
+// checkParenDepth rejects queries whose parenthesis nesting exceeds maxParenDepth.
+// It skips over quoted string content — which may contain unbalanced or
+// backslash-escaped quote characters, per the String lexer rule — so literal
+// values never distort the depth count.
+func checkParenDepth(query string) error {
+ depth := 0
+ var quote byte
+ for i := 0; i < len(query); i++ {
+ c := query[i]
+ if quote != 0 {
+ switch c {
+ case '\\':
+ i++
+ case quote:
+ quote = 0
+ }
+ continue
+ }
+ switch c {
+ case '\'', '"':
+ quote = c
+ case '(':
+ depth++
+ if depth > maxParenDepth {
+ return fmt.Errorf("syntax error: parenthesis nesting exceeds maximum depth of %d", maxParenDepth)
+ }
+ case ')':
+ if depth > 0 {
+ depth--
+ }
+ }
+ }
+ return nil
+}