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

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


The following commit(s) were added to refs/heads/main by this push:
     new 3c8659ba5 perf(trace): vectorized trace query — limit pushdown, 
bounded sidx scan, native columnar distributed wire frame (#1190)
3c8659ba5 is described below

commit 3c8659ba5aed8538f153540e12751f58c436e02a
Author: Gao Hongtao <[email protected]>
AuthorDate: Wed Jun 24 20:33:07 2026 +0800

    perf(trace): vectorized trace query — limit pushdown, bounded sidx scan, 
native columnar distributed wire frame (#1190)
    
    * perf(trace): push offset+limit into the vectorized SIDX scan
    
    The ordered SIDX (tag-filter) vectorized path built Phase-1 with no trace 
cap, so it carried forward and materialized span data for every matching trace 
before the outer traceLimit truncated to the limit -- O(matches) work vs the 
row path's O(limit). Pass maxRows (= offset+limit) to BuildMergePhase1 so 
Phase-1 stops after the top-N distinct trace IDs in sorted-merge order (via the 
existing ErrLimitExhausted), matching the row path. Results are identical (the 
merge is globally sorted); [...]
---
 api/data/codec.go                                  |  17 ++
 api/data/codec_test.go                             |   5 +-
 api/data/data.go                                   |   9 +-
 api/data/trace_frame.go                            |  50 ++++
 api/data/trace_frame_test.go                       | 109 +++++++
 banyand/internal/migration/fsutil.go               |  22 +-
 banyand/internal/migration/fsutil_test.go          | 100 +++++++
 banyand/internal/sidx/query.go                     |  38 ++-
 banyand/internal/sidx/query_test.go                |  55 ++++
 banyand/internal/sidx/sidx.go                      |  37 +++
 banyand/measure/measure_suite_test.go              |   2 +-
 banyand/measure/migration_e2e_test.go              |   2 +-
 banyand/query/processor.go                         | 119 +++++++-
 banyand/query/query.go                             |   3 +-
 banyand/queue/sub/sub.go                           |   4 +-
 banyand/stream/stream_suite_test.go                |   2 +-
 banyand/trace/query_vectorized.go                  |  10 +-
 banyand/trace/svc_liaison.go                       |   2 +
 banyand/trace/svc_standalone.go                    |   4 +
 banyand/trace/trace_suite_test.go                  |   2 +-
 banyand/trace/vectorized_parity_test.go            |  57 ++--
 pkg/cmdsetup/data.go                               |   2 +-
 pkg/cmdsetup/standalone.go                         |   2 +-
 pkg/query/logical/trace/trace_frame.go             | 325 +++++++++++++++++++++
 pkg/query/logical/trace/trace_frame_test.go        | 130 +++++++++
 pkg/query/logical/trace/trace_plan_distributed.go  | 263 +++++++++--------
 .../logical/trace/trace_plan_distributed_test.go   | 140 +++++----
 27 files changed, 1272 insertions(+), 239 deletions(-)

diff --git a/api/data/codec.go b/api/data/codec.go
index eea56df35..9e8bf4a19 100644
--- a/api/data/codec.go
+++ b/api/data/codec.go
@@ -149,6 +149,23 @@ func MeasureWireModeRaw() bool {
        return measureWireModeRaw.Load()
 }
 
+// traceWireModeRaw is the per-process wire mode for TopicTraceQuery. Set once 
at
+// trace-service startup; raw==true selects the native columnar trace frame 
body,
+// raw==false keeps the proto body. Default false so any process that never 
sets
+// it keeps the pre-existing proto behavior.
+var traceWireModeRaw atomic.Bool
+
+// SetTraceWireModeRaw publishes the per-process wire mode for TopicTraceQuery.
+func SetTraceWireModeRaw(raw bool) {
+       traceWireModeRaw.Store(raw)
+}
+
+// TraceWireModeRaw reports whether this process emits/decodes native trace 
frame
+// bodies for TopicTraceQuery.
+func TraceWireModeRaw() bool {
+       return traceWireModeRaw.Load()
+}
+
 // measureQueryResponseCodec dispatches TopicInternalMeasureQuery encode/decode
 // by the per-process wire mode: flag-on → RawFrameCodec, flag-off → 
ProtoCodec.
 // One static supplier serves both flag-on raw bodies and flag-off proto bodies
diff --git a/api/data/codec_test.go b/api/data/codec_test.go
index da49f99fa..b9de83f1d 100644
--- a/api/data/codec_test.go
+++ b/api/data/codec_test.go
@@ -105,8 +105,9 @@ func TestRawFrameCodec_WrongMagic_FailsLoud(t *testing.T) {
 func TestTopicResponseMap_ProtoCodec_RoundTripsByteIdentical(t *testing.T) {
        var protoTopics int
        for topic, codec := range TopicResponseMap {
-               if topic == TopicInternalMeasureQuery {
-                       // Wire-mode-dispatching codec; covered separately 
below.
+               if topic == TopicInternalMeasureQuery || topic == 
TopicTraceQuery {
+                       // Wire-mode-dispatching codecs; covered separately 
(measure below,
+                       // trace in trace_frame_test.go including flag-off 
byte-identity).
                        continue
                }
                pc, ok := codec.(*ProtoCodec)
diff --git a/api/data/data.go b/api/data/data.go
index b11baea5c..ad9ca6420 100644
--- a/api/data/data.go
+++ b/api/data/data.go
@@ -208,9 +208,12 @@ var (
                TopicPropertyRepair: NewProtoCodec(func() proto.Message {
                        return &propertyv1.InternalRepairResponse{}
                }),
-               TopicTraceQuery: NewProtoCodec(func() proto.Message {
-                       return &tracev1.InternalQueryResponse{}
-               }),
+               TopicTraceQuery: &traceQueryResponseCodec{
+                       proto: NewProtoCodec(func() proto.Message {
+                               return &tracev1.InternalQueryResponse{}
+                       }),
+                       raw: NewRawFrameCodec(),
+               },
                TopicMeasureCollectDataInfo: NewProtoCodec(func() proto.Message 
{
                        return &databasev1.DataInfo{}
                }),
diff --git a/api/data/trace_frame.go b/api/data/trace_frame.go
new file mode 100644
index 000000000..71b716b86
--- /dev/null
+++ b/api/data/trace_frame.go
@@ -0,0 +1,50 @@
+// 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 data
+
+// traceQueryResponseCodec dispatches TopicTraceQuery encode/decode by the
+// per-process wire mode, mirroring measureQueryResponseCodec: flag-on passes 
the
+// caller-supplied columnar frame ([]byte) through RawFrameCodec; flag-off (and
+// any proto.Message the caller still emits, e.g. the tracing path) keeps the
+// byte-identical proto body via ProtoCodec.
+type traceQueryResponseCodec struct {
+       proto *ProtoCodec
+       raw   *RawFrameCodec
+}
+
+// Marshal encodes the response. In raw wire mode a []byte body (the columnar
+// trace frame) is passed through unchanged; everything else (proto.Message) 
goes
+// through the proto path, covering both flag-off and the proto fallback.
+func (c *traceQueryResponseCodec) Marshal(v any) ([]byte, error) {
+       if TraceWireModeRaw() {
+               if b, ok := v.([]byte); ok {
+                       return c.raw.Marshal(b)
+               }
+       }
+       return c.proto.Marshal(v)
+}
+
+// Unmarshal decodes the response. In raw wire mode a magic-prefixed body is
+// returned as []byte (the columnar frame, decoded downstream by the liaison);
+// otherwise it is proto-unmarshaled.
+func (c *traceQueryResponseCodec) Unmarshal(body []byte) (any, error) {
+       if TraceWireModeRaw() && len(body) > 0 && body[0] == 
RawFrameMagicLeadingByte {
+               return c.raw.Unmarshal(body)
+       }
+       return c.proto.Unmarshal(body)
+}
diff --git a/api/data/trace_frame_test.go b/api/data/trace_frame_test.go
new file mode 100644
index 000000000..7a8d125c0
--- /dev/null
+++ b/api/data/trace_frame_test.go
@@ -0,0 +1,109 @@
+// 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 data
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/require"
+       "google.golang.org/protobuf/proto"
+
+       tracev1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
+)
+
+func newTraceCodec() *traceQueryResponseCodec {
+       return &traceQueryResponseCodec{
+               proto: NewProtoCodec(func() proto.Message { return 
&tracev1.InternalQueryResponse{} }),
+               raw:   NewRawFrameCodec(),
+       }
+}
+
+// TestTraceCodec_RawPassthrough verifies that flag-on the codec passes a
+// columnar frame ([]byte) through unchanged on both encode and decode.
+func TestTraceCodec_RawPassthrough(t *testing.T) {
+       SetTraceWireModeRaw(true)
+       defer SetTraceWireModeRaw(false)
+       c := newTraceCodec()
+
+       frame := []byte{RawFrameMagicLeadingByte, 0x01, 0x02, 0x03}
+       body, err := c.Marshal(frame)
+       require.NoError(t, err)
+       require.Equal(t, frame, body, "raw frame must pass through Marshal 
unchanged")
+
+       got, err := c.Unmarshal(body)
+       require.NoError(t, err)
+       decoded, ok := got.([]byte)
+       require.True(t, ok, "raw-mode Unmarshal of a magic-prefixed body must 
return []byte")
+       require.Equal(t, frame, decoded, "raw frame must pass through Unmarshal 
unchanged")
+}
+
+// TestTraceCodec_RawEmptyBody verifies that flag-on, a nil/empty body (a
+// legitimate empty distributed result) falls through to the proto path, 
yielding
+// an empty *tracev1.InternalQueryResponse rather than being magic-validated.
+func TestTraceCodec_RawEmptyBody(t *testing.T) {
+       SetTraceWireModeRaw(true)
+       defer SetTraceWireModeRaw(false)
+       c := newTraceCodec()
+
+       got, err := c.Unmarshal(nil)
+       require.NoError(t, err)
+       decoded, ok := got.(*tracev1.InternalQueryResponse)
+       require.True(t, ok)
+       require.Empty(t, decoded.GetInternalTraces())
+}
+
+// TestTraceCodec_FlagOffIsProto verifies flag-off encodes byte-identically to
+// plain proto.Marshal and round-trips through the proto path.
+func TestTraceCodec_FlagOffIsProto(t *testing.T) {
+       SetTraceWireModeRaw(false)
+       c := newTraceCodec()
+       orig := &tracev1.InternalQueryResponse{
+               InternalTraces: []*tracev1.InternalTrace{
+                       {TraceId: "trace-1", Key: -42, Spans: 
[]*tracev1.Span{{SpanId: "span-1", Span: []byte("payload-1")}}},
+               },
+       }
+
+       body, err := c.Marshal(orig)
+       require.NoError(t, err)
+       want, err := proto.Marshal(orig)
+       require.NoError(t, err)
+       require.Equal(t, want, body, "flag-off must encode byte-identically to 
proto.Marshal")
+
+       got, err := c.Unmarshal(body)
+       require.NoError(t, err)
+       require.True(t, proto.Equal(orig, got.(*tracev1.InternalQueryResponse)))
+}
+
+// TestTraceCodec_FlagOnProtoFallback verifies that flag-on, a proto.Message
+// value (e.g. the tracing path) still goes through the proto path.
+func TestTraceCodec_FlagOnProtoFallback(t *testing.T) {
+       SetTraceWireModeRaw(true)
+       defer SetTraceWireModeRaw(false)
+       c := newTraceCodec()
+       orig := &tracev1.InternalQueryResponse{}
+
+       body, err := c.Marshal(orig)
+       require.NoError(t, err)
+       want, err := proto.Marshal(orig)
+       require.NoError(t, err)
+       require.Equal(t, want, body)
+
+       got, err := c.Unmarshal(body)
+       require.NoError(t, err)
+       require.True(t, proto.Equal(orig, got.(*tracev1.InternalQueryResponse)))
+}
diff --git a/banyand/internal/migration/fsutil.go 
b/banyand/internal/migration/fsutil.go
index f649d3d03..ab8c3c22d 100644
--- a/banyand/internal/migration/fsutil.go
+++ b/banyand/internal/migration/fsutil.go
@@ -55,9 +55,27 @@ func (f NoFsyncFS) Write(buffer []byte, name string, 
permission fs.Mode) (int, e
        return n, err
 }
 
-// WriteAtomic degrades to a plain (non-atomic, non-synced) write.
+// WriteAtomic writes buffer to name atomically — to a temp sibling, then
+// rename — while skipping the fsync/dir-sync that the durable path performs.
+// NoFsyncFS trades crash durability for throughput, but atomic VISIBILITY must
+// be preserved: callers such as the snapshot-manifest write
+// (tsTable.mustWriteSnapshot) rely on WriteAtomic never exposing a partially
+// written final file to a concurrent reader. A plain O_TRUNC write to the 
final
+// path opened a window where a concurrent snapshot read saw a truncated file,
+// surfacing as a "<epoch>.snp: unexpected end of JSON" parse panic in
+// mustReadSnapshot under -race.
 func (f NoFsyncFS) WriteAtomic(buffer []byte, name string, permission fs.Mode) 
(int, error) {
-       return f.Write(buffer, name, permission)
+       tmpName := name + ".tmp"
+       n, err := f.Write(buffer, tmpName, permission)
+       if err != nil {
+               _ = os.Remove(tmpName)
+               return n, err
+       }
+       if renameErr := os.Rename(tmpName, name); renameErr != nil {
+               _ = os.Remove(tmpName)
+               return n, fmt.Errorf("rename %s -> %s: %w", tmpName, name, 
renameErr)
+       }
+       return n, nil
 }
 
 // SyncPath is a no-op.
diff --git a/banyand/internal/migration/fsutil_test.go 
b/banyand/internal/migration/fsutil_test.go
index 3de297885..760ea4f09 100644
--- a/banyand/internal/migration/fsutil_test.go
+++ b/banyand/internal/migration/fsutil_test.go
@@ -18,12 +18,17 @@
 package migration
 
 import (
+       "encoding/json"
+       "fmt"
        "os"
        "path/filepath"
+       "sync"
        "testing"
 
        "github.com/stretchr/testify/require"
 
+       "github.com/apache/skywalking-banyandb/banyand/internal/storage"
+       "github.com/apache/skywalking-banyandb/pkg/fs"
        "github.com/apache/skywalking-banyandb/pkg/index/inverted"
 )
 
@@ -72,3 +77,98 @@ func TestCopyDir_MissingSource(t *testing.T) {
        _, err := CopyDir(filepath.Join(t.TempDir(), "does-not-exist"), dst)
        require.Error(t, err)
 }
+
+// TestNoFsyncFS_WriteAtomic_NoTmpLeftover asserts WriteAtomic produces the 
exact
+// content and leaves no ".tmp" sibling behind on success.
+func TestNoFsyncFS_WriteAtomic_NoTmpLeftover(t *testing.T) {
+       dir := t.TempDir()
+       nofs := NoFsyncFS{FileSystem: fs.NewLocalFileSystem()}
+       name := filepath.Join(dir, "manifest.json")
+
+       payload := []byte(`["0000000000000001","0000000000000002"]`)
+       n, err := nofs.WriteAtomic(payload, name, storage.FilePerm)
+       require.NoError(t, err)
+       require.Equal(t, len(payload), n)
+
+       got, err := os.ReadFile(name)
+       require.NoError(t, err)
+       require.Equal(t, payload, got)
+       require.NoFileExists(t, name+".tmp", "WriteAtomic must not leave a .tmp 
sibling on success")
+}
+
+// TestNoFsyncFS_WriteAtomic_ConcurrentReadsNeverSeePartial locks the atomicity
+// of NoFsyncFS.WriteAtomic: a reader parsing the manifest concurrently with
+// repeated rewrites must never observe a truncated file. Before the fix,
+// WriteAtomic did a plain O_TRUNC write to the final path, so a concurrent
+// ReadSnapshotPartNames caught the truncation window and failed with
+// "unexpected end of JSON" — the root cause of the flaky migration snapshot
+// tests. Run with -race for maximum sensitivity.
+func TestNoFsyncFS_WriteAtomic_ConcurrentReadsNeverSeePartial(t *testing.T) {
+       dir := t.TempDir()
+       lfs := fs.NewLocalFileSystem()
+       nofs := NoFsyncFS{FileSystem: lfs}
+       snpPath := filepath.Join(dir, "0000000000000001.snp")
+
+       // Seed so the manifest always exists before readers start; rename then 
keeps
+       // it continuously present, so a reader never sees ENOENT, only (with 
the bug)
+       // a partial file.
+       seed, err := json.Marshal([]string{"0000000000000001"})
+       require.NoError(t, err)
+       _, err = nofs.WriteAtomic(seed, snpPath, storage.FilePerm)
+       require.NoError(t, err)
+
+       const writes = 3000
+       var wg sync.WaitGroup
+       stop := make(chan struct{})
+       errCh := make(chan error, 8)
+
+       wg.Add(1)
+       go func() {
+               defer wg.Done()
+               defer close(stop)
+               for i := 0; i < writes; i++ {
+                       // Vary the payload length so the truncation window a 
non-atomic write
+                       // would expose differs each iteration.
+                       names := make([]string, 1+i%17)
+                       for j := range names {
+                               names[j] = fmt.Sprintf("%016x", i*100+j)
+                       }
+                       data, mErr := json.Marshal(names)
+                       if mErr != nil {
+                               errCh <- mErr
+                               return
+                       }
+                       if _, wErr := nofs.WriteAtomic(data, snpPath, 
storage.FilePerm); wErr != nil {
+                               errCh <- wErr
+                               return
+                       }
+               }
+       }()
+
+       for r := 0; r < 4; r++ {
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       for {
+                               select {
+                               case <-stop:
+                                       return
+                               default:
+                               }
+                               if _, rErr := 
storage.ReadSnapshotPartNames(lfs, snpPath); rErr != nil {
+                                       select {
+                                       case errCh <- rErr:
+                                       default:
+                                       }
+                                       return
+                               }
+                       }
+               }()
+       }
+
+       wg.Wait()
+       close(errCh)
+       for e := range errCh {
+               t.Fatalf("WriteAtomic exposed a partial snapshot to a 
concurrent reader: %v", e)
+       }
+}
diff --git a/banyand/internal/sidx/query.go b/banyand/internal/sidx/query.go
index 494f66bf8..3519d662e 100644
--- a/banyand/internal/sidx/query.go
+++ b/banyand/internal/sidx/query.go
@@ -347,12 +347,27 @@ func (s *sidx) prepareSyncResources(
        }, true
 }
 
+// errSyncBudgetReached signals that the sync query loop has collected 
MaxBatchSize
+// distinct results and the block scan can stop early. It is not a failure.
+var errSyncBudgetReached = errors.New("sidx: sync query result budget reached")
+
 func (s *sidx) processSyncLoop(ctx context.Context, req QueryRequest, 
resources *syncQueryResources) ([]*QueryResponse, error) {
        var results []*QueryResponse
        var metrics *batchMetrics
        if query.GetTracer(ctx) != nil {
                metrics = &batchMetrics{}
        }
+       // When MaxBatchSize bounds the result, stop scanning once that many 
distinct
+       // elements (= trace IDs; mergeSync dedups by data) have been 
collected. The block
+       // iterator yields blocks in key order, so the first MaxBatchSize 
distinct elements
+       // are the ordered top-N. This mirrors the streaming path, which stops 
via consumer
+       // backpressure; the sync path has none, so without this it decodes 
every matching
+       // block. MaxBatchSize<=0 keeps the full scan.
+       var budgetCounter *distinctDataCounter
+       if req.MaxBatchSize > 0 {
+               budgetCounter = newDistinctDataCounter()
+       }
+       budgetReached := false
        consume := func(batch *blockScanResultBatch) error {
                defer releaseBlockScanResultBatch(batch)
                if batch.err != nil {
@@ -374,16 +389,29 @@ func (s *sidx) processSyncLoop(ctx context.Context, req 
QueryRequest, resources
                        return mergeErr
                }
                results = append(results, chunks...)
+               if budgetCounter != nil {
+                       for _, chunk := range chunks {
+                               budgetCounter.add(chunk)
+                       }
+                       if budgetCounter.count >= req.MaxBatchSize {
+                               budgetReached = true
+                               return errSyncBudgetReached
+                       }
+               }
                return nil
        }
-       if scanErr := resources.scanner.scanSync(ctx, consume); scanErr != nil {
+       if scanErr := resources.scanner.scanSync(ctx, consume); scanErr != nil 
&& !errors.Is(scanErr, errSyncBudgetReached) {
                return nil, scanErr
        }
-       chunks, mergeErr := resources.heap.mergeSync(ctx, req.MaxBatchSize, 
metrics)
-       if mergeErr != nil {
-               return nil, mergeErr
+       // When the budget was reached the last consume already drained the 
heap; only the
+       // completed-scan path needs a final flush of any cursors left in the 
heap.
+       if !budgetReached {
+               chunks, mergeErr := resources.heap.mergeSync(ctx, 
req.MaxBatchSize, metrics)
+               if mergeErr != nil {
+                       return nil, mergeErr
+               }
+               results = append(results, chunks...)
        }
-       results = append(results, chunks...)
        return results, nil
 }
 
diff --git a/banyand/internal/sidx/query_test.go 
b/banyand/internal/sidx/query_test.go
index 59a758f38..6516a0f5b 100644
--- a/banyand/internal/sidx/query_test.go
+++ b/banyand/internal/sidx/query_test.go
@@ -19,6 +19,7 @@ package sidx
 
 import (
        "context"
+       "fmt"
        "sort"
        "strings"
        "testing"
@@ -103,6 +104,60 @@ func TestSIDX_QuerySyncMatchesStreamingQuery(t *testing.T) 
{
        require.Equal(t, flattenQueryRows(streamingRows), 
flattenQueryRows(syncRows))
 }
 
+// TestSIDX_QuerySync_EarlyStopAtMaxBatchSize verifies the sync query path 
stops
+// scanning once MaxBatchSize distinct results are collected, while still 
returning
+// the correct ordered top-N. Blocks are per series, so writing many 
single-element
+// series forces the scan across multiple batches (blockScannerBatchSize); the 
block
+// iterator yields them in ascending key order, so the bounded result's prefix 
must
+// equal the uncapped, fully-ordered scan's prefix.
+func TestSIDX_QuerySync_EarlyStopAtMaxBatchSize(t *testing.T) {
+       s := createTestSIDX(t)
+       defer func() {
+               assert.NoError(t, s.Close())
+       }()
+       ctx := context.Background()
+
+       const seriesCount = 80
+       seriesIDs := make([]common.SeriesID, 0, seriesCount)
+       for i := range seriesCount {
+               key := int64(i + 1)
+               series := common.SeriesID(i + 1)
+               seriesIDs = append(seriesIDs, series)
+               writeTestData(t, s, []WriteRequest{
+                       createTestWriteRequest(series, key, 
fmt.Sprintf("data%03d", key)),
+               }, 44, uint64(i+1))
+       }
+       waitForIntroducerLoop()
+
+       syncQuerier, ok := s.(interface {
+               QuerySync(context.Context, QueryRequest) ([]*QueryResponse, 
error)
+       })
+       require.True(t, ok)
+
+       baseReq := createTestQueryRequest(seriesIDs...)
+
+       // Uncapped scan reads every block and returns all elements in key 
order.
+       uncapped, err := syncQuerier.QuerySync(ctx, baseReq)
+       require.NoError(t, err)
+       uncappedRows := flattenQueryRows(uncapped)
+       require.Len(t, uncappedRows, seriesCount)
+
+       const maxBatch = 10
+       cappedReq := baseReq
+       cappedReq.MaxBatchSize = maxBatch
+       capped, err := syncQuerier.QuerySync(ctx, cappedReq)
+       require.NoError(t, err)
+       cappedRows := flattenQueryRows(capped)
+
+       // Bounded query collected at least MaxBatchSize and stopped before 
reading every block.
+       require.GreaterOrEqual(t, len(cappedRows), maxBatch, "must collect at 
least MaxBatchSize results")
+       require.Less(t, len(cappedRows), seriesCount, "must stop scanning 
before reading every block")
+
+       // The bounded result is the correct ordered top-N: its first 
MaxBatchSize rows
+       // equal the first MaxBatchSize rows of the uncapped, fully-ordered 
scan.
+       require.Equal(t, uncappedRows[:maxBatch], cappedRows[:maxBatch])
+}
+
 func TestSIDX_Query_EmptyResult(t *testing.T) {
        sidx := createTestSIDX(t)
        defer func() {
diff --git a/banyand/internal/sidx/sidx.go b/banyand/internal/sidx/sidx.go
index d902d145a..7f5e118b2 100644
--- a/banyand/internal/sidx/sidx.go
+++ b/banyand/internal/sidx/sidx.go
@@ -930,6 +930,43 @@ func (bch *blockCursorHeap) mergeSync(ctx context.Context, 
batchSize int, metric
        return results, nil
 }
 
+// distinctDataCounter accumulates distinct data values (= trace IDs) across 
sidx
+// merge chunks using hash buckets with collision checks. mergeSync dedups data
+// only within a single batch, so the sync query loop uses this to count 
distinct
+// results across batches and bound scanning the same way the streaming 
consumer
+// terminates on distinct-trace count.
+type distinctDataCounter struct {
+       seen  map[uint64][][]byte
+       count int
+}
+
+func newDistinctDataCounter() *distinctDataCounter {
+       return &distinctDataCounter{seen: make(map[uint64][][]byte)}
+}
+
+// add records every data value in chunk and returns the running distinct 
count.
+func (d *distinctDataCounter) add(chunk *QueryResponse) int {
+       if chunk == nil {
+               return d.count
+       }
+       for _, data := range chunk.Data {
+               h := convert.Hash(data)
+               bucket := d.seen[h]
+               duplicate := false
+               for _, existing := range bucket {
+                       if bytes.Equal(existing, data) {
+                               duplicate = true
+                               break
+                       }
+               }
+               if !duplicate {
+                       d.seen[h] = append(bucket, data)
+                       d.count++
+               }
+       }
+       return d.count
+}
+
 var blockCursorHeapPool = 
pool.Register[*blockCursorHeap]("sidx-blockCursorHeap")
 
 func generateBlockCursorHeap(asc bool) *blockCursorHeap {
diff --git a/banyand/measure/measure_suite_test.go 
b/banyand/measure/measure_suite_test.go
index 25565ebfd..7b5b08596 100644
--- a/banyand/measure/measure_suite_test.go
+++ b/banyand/measure/measure_suite_test.go
@@ -82,7 +82,7 @@ func setUp() (*services, func()) {
        measureService, err := measure.NewStandalone(metadataService, pipeline, 
nil, metricSvc, pm)
        gomega.Expect(err).NotTo(gomega.HaveOccurred())
        preloadMeasureSvc := &preloadMeasureService{metaSvc: metadataService}
-       querySvc, err := query.NewService(context.TODO(), nil, measureService, 
nil, metadataService, pipeline, metricSvc)
+       querySvc, err := query.NewService(context.TODO(), nil, measureService, 
nil, metadataService, pipeline, metricSvc, false)
        gomega.Expect(err).NotTo(gomega.HaveOccurred())
 
        var flags []string
diff --git a/banyand/measure/migration_e2e_test.go 
b/banyand/measure/migration_e2e_test.go
index 5d3b3946b..8e5962f15 100644
--- a/banyand/measure/migration_e2e_test.go
+++ b/banyand/measure/migration_e2e_test.go
@@ -580,7 +580,7 @@ func setUpMigrationTarget(measureRootPath string) 
(*services, func()) {
        measureService, err := measure.NewStandalone(metadataService, pipeline, 
nil, metricSvc, pm)
        Expect(err).NotTo(HaveOccurred())
        preloadSvc := &preloadMeasureService{metaSvc: metadataService}
-       querySvc, err := query.NewService(context.TODO(), nil, measureService, 
nil, metadataService, pipeline, metricSvc)
+       querySvc, err := query.NewService(context.TODO(), nil, measureService, 
nil, metadataService, pipeline, metricSvc, false)
        Expect(err).NotTo(HaveOccurred())
 
        metaPath, metaDeferFunc, err := test.NewSpace()
diff --git a/banyand/query/processor.go b/banyand/query/processor.go
index 57ffa1cf3..0c0cc01fa 100644
--- a/banyand/query/processor.go
+++ b/banyand/query/processor.go
@@ -41,6 +41,7 @@ import (
        "github.com/apache/skywalking-banyandb/pkg/bus"
        "github.com/apache/skywalking-banyandb/pkg/iter"
        "github.com/apache/skywalking-banyandb/pkg/logger"
+       pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
        "github.com/apache/skywalking-banyandb/pkg/query"
        "github.com/apache/skywalking-banyandb/pkg/query/executor"
        "github.com/apache/skywalking-banyandb/pkg/query/logical"
@@ -661,6 +662,12 @@ type traceQueryProcessor struct {
        traceService trace.Service
        *queryService
        *bus.UnImplementedHealthyListener
+       // distributed is true on a distributed data node, whose query response 
is
+       // sent over the wire to a remote liaison that decodes the native 
columnar
+       // frame. It is false in standalone, where the local grpc service 
consumes the
+       // response directly and expects the proto body — so the frame must not 
be
+       // emitted there.
+       distributed bool
 }
 
 func (p *traceQueryProcessor) Rev(ctx context.Context, message bus.Message) 
(resp bus.Message) {
@@ -900,20 +907,104 @@ func (p *traceQueryProcessor) buildTraceTags(result 
*model.TraceResult, queryCri
        return traceTags
 }
 
-func (p *traceQueryProcessor) logSlowQuery(queryCriteria 
*tracev1.QueryRequest, traces []*tracev1.InternalTrace, startTime time.Time) {
-       if queryCriteria.Trace || p.slowQuery <= 0 {
-               return
+// buildFrameTraceResults mirrors processTraceResults+buildTraceTags but 
outputs
+// columnar []model.TraceResult (TID/Key/Spans/SpanIDs plus one TagValue column
+// per span) for the native columnar wire frame. It replicates the 
projected-tag
+// filter and the traceID/spanID tag augmentation so the liaison reconstructs 
the
+// same spans the proto path would have produced.
+func (p *traceQueryProcessor) buildFrameTraceResults(resultIterator 
iter.Iterator[model.TraceResult],
+       queryCriteria *tracev1.QueryRequest, execPlan *traceExecutionPlan,
+) ([]model.TraceResult, error) {
+       traceIDInclusionMap := make(map[int]bool)
+       spanIDInclusionMap := make(map[int]bool)
+       for i, tagName := range execPlan.traceIDTagNames {
+               if slices.Contains(queryCriteria.TagProjection, tagName) {
+                       traceIDInclusionMap[i] = true
+               }
+       }
+       for i, tagName := range execPlan.spanIDTagNames {
+               if slices.Contains(queryCriteria.TagProjection, tagName) {
+                       spanIDInclusionMap[i] = true
+               }
        }
 
-       latency := time.Since(startTime)
-       if latency <= p.slowQuery {
-               return
+       var results []model.TraceResult
+       for {
+               result, hasNext := resultIterator.Next()
+               if !hasNext {
+                       break
+               }
+               if result.Error != nil {
+                       return nil, result.Error
+               }
+               if result.TID == "" {
+                       continue
+               }
+
+               spanCount := len(result.Spans)
+               frameResult := model.TraceResult{
+                       TID:     result.TID,
+                       Key:     result.Key,
+                       Spans:   result.Spans,
+                       SpanIDs: result.SpanIDs,
+               }
+               if result.Tags != nil && len(queryCriteria.TagProjection) > 0 {
+                       for _, tag := range result.Tags {
+                               if 
!slices.Contains(queryCriteria.TagProjection, tag.Name) {
+                                       continue
+                               }
+                               frameResult.Tags = append(frameResult.Tags, tag)
+                       }
+               }
+               if traceIDInclusionMap[result.GroupIndex] {
+                       values := make([]*modelv1.TagValue, spanCount)
+                       tidValue := &modelv1.TagValue{Value: 
&modelv1.TagValue_Str{Str: &modelv1.Str{Value: result.TID}}}
+                       for spanIdx := range values {
+                               values[spanIdx] = tidValue
+                       }
+                       frameResult.Tags = append(frameResult.Tags, 
model.Tag{Name: execPlan.traceIDTagNames[result.GroupIndex], Values: values})
+               }
+               if spanIDInclusionMap[result.GroupIndex] {
+                       values := make([]*modelv1.TagValue, spanCount)
+                       for spanIdx := 0; spanIdx < spanCount; spanIdx++ {
+                               if spanIdx < len(result.SpanIDs) {
+                                       values[spanIdx] = 
&modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: 
result.SpanIDs[spanIdx]}}}
+                               } else {
+                                       values[spanIdx] = pbv1.NullTagValue
+                               }
+                       }
+                       frameResult.Tags = append(frameResult.Tags, 
model.Tag{Name: execPlan.spanIDTagNames[result.GroupIndex], Values: values})
+               }
+               results = append(results, frameResult)
        }
 
+       return results, nil
+}
+
+func (p *traceQueryProcessor) logSlowQuery(queryCriteria 
*tracev1.QueryRequest, traces []*tracev1.InternalTrace, startTime time.Time) {
        spanCount := 0
        for _, trace := range traces {
                spanCount += len(trace.Spans)
        }
+       p.logSlowTraceQuery(queryCriteria, spanCount, startTime)
+}
+
+func (p *traceQueryProcessor) logSlowFrameQuery(queryCriteria 
*tracev1.QueryRequest, results []model.TraceResult, startTime time.Time) {
+       spanCount := 0
+       for resultIdx := range results {
+               spanCount += len(results[resultIdx].Spans)
+       }
+       p.logSlowTraceQuery(queryCriteria, spanCount, startTime)
+}
+
+func (p *traceQueryProcessor) logSlowTraceQuery(queryCriteria 
*tracev1.QueryRequest, spanCount int, startTime time.Time) {
+       if queryCriteria.Trace || p.slowQuery <= 0 {
+               return
+       }
+       latency := time.Since(startTime)
+       if latency <= p.slowQuery {
+               return
+       }
        p.log.Warn().Dur("latency", latency).RawJSON("req", 
logger.Proto(queryCriteria)).Int("resp_count", spanCount).Msg("trace slow 
query")
 }
 
@@ -961,6 +1052,22 @@ func (p *traceQueryProcessor) executeQuery(ctx 
context.Context, queryCriteria *t
                return
        }
 
+       // Native wire mode (flag-on, no tracing): emit a columnar frame body 
so the
+       // send path passes it through as opaque bytes and the liaison decodes 
it
+       // without the protobuf message-slice/oneof machinery. The tracing path 
keeps
+       // the proto body (the traceMonitor defer needs *InternalQueryResponse).
+       if p.distributed && data.TraceWireModeRaw() && traceMonitor == nil {
+               results, buildErr := p.buildFrameTraceResults(resultIterator, 
queryCriteria, execPlan)
+               if buildErr != nil {
+                       p.log.Error().Err(buildErr).RawJSON("req", 
logger.Proto(queryCriteria)).Msg("fail to process trace results")
+                       resp = bus.NewMessage(bus.MessageID(now), 
common.NewError("process trace results for trace %s: %v", 
queryCriteria.GetName(), buildErr))
+                       return
+               }
+               resp = bus.NewMessage(bus.MessageID(now), 
logical_trace.EncodeTraceResultFrame(results))
+               p.logSlowFrameQuery(queryCriteria, results, n)
+               return
+       }
+
        traces, err := p.processTraceResults(resultIterator, queryCriteria, 
execPlan)
        if err != nil {
                p.log.Error().Err(err).RawJSON("req", 
logger.Proto(queryCriteria)).Msg("fail to process trace results")
diff --git a/banyand/query/query.go b/banyand/query/query.go
index 8d5a25ae9..acd855ae0 100644
--- a/banyand/query/query.go
+++ b/banyand/query/query.go
@@ -52,7 +52,7 @@ 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,
+       metaService metadata.Repo, pipeline queue.Server, metricSvc 
observability.MetricsRegistry, distributed bool,
 ) (run.Unit, error) {
        svc := &queryService{
                metaService: metaService,
@@ -83,6 +83,7 @@ func NewService(_ context.Context, streamService 
stream.Service, measureService
        svc.tqp = &traceQueryProcessor{
                traceService: traceService,
                queryService: svc,
+               distributed:  distributed,
        }
        return svc, nil
 }
diff --git a/banyand/queue/sub/sub.go b/banyand/queue/sub/sub.go
index 4115d62cb..6dd17bb4e 100644
--- a/banyand/queue/sub/sub.go
+++ b/banyand/queue/sub/sub.go
@@ -314,7 +314,9 @@ func (s *server) marshalResponse(
                }
                return body, nil
        case []byte:
-               if topic != data.TopicInternalMeasureQuery || 
!data.MeasureWireModeRaw() {
+               rawAllowed := (topic == data.TopicInternalMeasureQuery && 
data.MeasureWireModeRaw()) ||
+                       (topic == data.TopicTraceQuery && 
data.TraceWireModeRaw())
+               if !rawAllowed {
                        s.replyWithErrType(stream, writeEntity, nil, 
fmt.Sprintf("invalid response: unexpected raw body on topic %s", topic), 
identity, "marshal_error")
                        return nil, fmt.Errorf("invalid raw body")
                }
diff --git a/banyand/stream/stream_suite_test.go 
b/banyand/stream/stream_suite_test.go
index 1c83e0f5b..5c02d073c 100644
--- a/banyand/stream/stream_suite_test.go
+++ b/banyand/stream/stream_suite_test.go
@@ -83,7 +83,7 @@ func setUp() (*services, func()) {
        streamService, err := stream.NewService(metadataService, pipeline, 
metricSvc, pm, nil)
        gomega.Expect(err).NotTo(gomega.HaveOccurred())
        preloadStreamSvc := &preloadStreamService{metaSvc: metadataService}
-       querySvc, err := query.NewService(context.TODO(), streamService, nil, 
nil, metadataService, pipeline, metricSvc)
+       querySvc, err := query.NewService(context.TODO(), streamService, nil, 
nil, metadataService, pipeline, metricSvc, false)
        gomega.Expect(err).NotTo(gomega.HaveOccurred())
        var flags []string
        metaPath, metaDeferFunc, err := test.NewSpace()
diff --git a/banyand/trace/query_vectorized.go 
b/banyand/trace/query_vectorized.go
index 3e08b2ed6..3dcc873a6 100644
--- a/banyand/trace/query_vectorized.go
+++ b/banyand/trace/query_vectorized.go
@@ -380,10 +380,12 @@ func (t *trace) buildVectorizedPhase1TraceBatch(
                if iterErr != nil {
                        return traceBatch{}, iterErr
                }
-               // Ordered SIDX path: MaxTraceSize controls SIDX batch size 
(MaxBatchSize in the
-               // request), not the total result cap. The push path emits all 
matching traces in
-               // batches; vectorized must do the same. Pass 0 to disable the 
Phase-1 trace cap.
-               plan, buildErr := vtrace.BuildMergePhase1(iters, 
sidxRequestDesc(req), 0, batchSize)
+               // Ordered SIDX path: cap Phase-1 at maxRows (= offset+limit) 
so only the top-N
+               // distinct trace IDs in sorted-merge order are carried forward 
and materialized.
+               // The merge is globally sorted, so the first maxRows traces 
are exactly the window
+               // the outer traceLimit keeps; carrying the full match set 
(maxRows=0) wastes span
+               // decode on traces the limit discards. maxRows=0 (no limit 
set) stays uncapped.
+               plan, buildErr := vtrace.BuildMergePhase1(iters, 
sidxRequestDesc(req), maxRows, batchSize)
                if buildErr != nil {
                        return traceBatch{}, fmt.Errorf("build ordered 
vectorized trace phase1: %w", buildErr)
                }
diff --git a/banyand/trace/svc_liaison.go b/banyand/trace/svc_liaison.go
index 320919ad1..92a2418f1 100644
--- a/banyand/trace/svc_liaison.go
+++ b/banyand/trace/svc_liaison.go
@@ -176,6 +176,8 @@ func (l *liaison) Role() databasev1.Role {
 
 func (l *liaison) PreRun(ctx context.Context) error {
        l.l = logger.GetLogger(l.Name())
+       data.SetTraceWireModeRaw(l.option.vectorized.Enabled)
+       l.l.Info().Bool("trace_wire_mode_raw", 
l.option.vectorized.Enabled).Msg("trace wire mode published (liaison)")
        l.l.Info().Msg("memory protector is initialized in PreRun")
        l.lfs = fs.NewLocalFileSystemWithLoggerAndLimit(l.l, l.pm.GetLimit())
        var err error
diff --git a/banyand/trace/svc_standalone.go b/banyand/trace/svc_standalone.go
index f667c5207..4f0f657a6 100644
--- a/banyand/trace/svc_standalone.go
+++ b/banyand/trace/svc_standalone.go
@@ -136,6 +136,10 @@ func (s *standalone) Role() databasev1.Role {
 
 func (s *standalone) PreRun(ctx context.Context) error {
        s.l = logger.GetLogger(s.Name())
+       // Native columnar wire frame for the data↔liaison query hop follows the
+       // vectorized flag (mirrors measure's wire-mode wiring).
+       data.SetTraceWireModeRaw(s.option.vectorized.Enabled)
+       s.l.Info().Bool("trace_wire_mode_raw", 
s.option.vectorized.Enabled).Msg("trace wire mode published (standalone)")
        s.l.Info().Msg("memory protector is initialized in PreRun")
        s.lfs = fs.NewLocalFileSystemWithLoggerAndLimit(s.l, s.pm.GetLimit())
        var err error
diff --git a/banyand/trace/trace_suite_test.go 
b/banyand/trace/trace_suite_test.go
index ee9c48581..1c9b5089d 100644
--- a/banyand/trace/trace_suite_test.go
+++ b/banyand/trace/trace_suite_test.go
@@ -83,7 +83,7 @@ func setUp() (*services, func()) {
        gomega.Expect(err).NotTo(gomega.HaveOccurred())
        preloadTraceSvc := &preloadTraceService{metaSvc: metadataService}
        // Init Query Service for trace queries
-       querySvc, err := query.NewService(context.TODO(), nil, nil, 
traceService, metadataService, pipeline, metricSvc)
+       querySvc, err := query.NewService(context.TODO(), nil, nil, 
traceService, metadataService, pipeline, metricSvc, false)
        gomega.Expect(err).NotTo(gomega.HaveOccurred())
        var flags []string
        metaPath, metaDeferFunc, err := test.NewSpace()
diff --git a/banyand/trace/vectorized_parity_test.go 
b/banyand/trace/vectorized_parity_test.go
index 00d08c9f5..6432c4043 100644
--- a/banyand/trace/vectorized_parity_test.go
+++ b/banyand/trace/vectorized_parity_test.go
@@ -239,12 +239,13 @@ func TestVectorizedParityOrderMode(t *testing.T) {
        }
 
        tests := []struct {
-               name         string
-               keys         []int64
-               traceIDs     []string
-               maxTraceSize int
-               wantCount    int
-               sortDir      modelv1.Sort
+               name          string
+               keys          []int64
+               traceIDs      []string
+               maxTraceSize  int
+               wantCount     int
+               wantPullCount int
+               sortDir       modelv1.Sort
        }{
                {
                        name:      "ASC order all traces",
@@ -261,22 +262,25 @@ func TestVectorizedParityOrderMode(t *testing.T) {
                        wantCount: 3,
                },
                {
-                       // MaxTraceSize is a batch-size hint for ordered SIDX 
queries, not a total cap.
-                       // The push path emits all 3 traces in batches of 2; 
vectorized must match.
-                       name:         "ASC order MaxTraceSize=2 with 3 traces 
returns all 3",
-                       sortDir:      modelv1.Sort_SORT_ASC,
-                       keys:         []int64{1, 2, 3},
-                       traceIDs:     []string{"trace1", "trace2", "trace3"},
-                       maxTraceSize: 2,
-                       wantCount:    3,
+                       // MaxTraceSize (= offset+limit) caps the pull path's 
materialized trace set to
+                       // the sorted top-N. The push path streams lazily and 
emits all matches, relying
+                       // on the outer traceLimit to cap; so pull is the 
MaxTraceSize-prefix of push.
+                       name:          "ASC order MaxTraceSize=2 caps pull to 
top 2 (push streams all 3)",
+                       sortDir:       modelv1.Sort_SORT_ASC,
+                       keys:          []int64{1, 2, 3},
+                       traceIDs:      []string{"trace1", "trace2", "trace3"},
+                       maxTraceSize:  2,
+                       wantCount:     3,
+                       wantPullCount: 2,
                },
                {
-                       name:         "DESC order MaxTraceSize=2 with 3 traces 
returns all 3",
-                       sortDir:      modelv1.Sort_SORT_DESC,
-                       keys:         []int64{3, 2, 1},
-                       traceIDs:     []string{"trace3", "trace2", "trace1"},
-                       maxTraceSize: 2,
-                       wantCount:    3,
+                       name:          "DESC order MaxTraceSize=2 caps pull to 
top 2 (push streams all 3)",
+                       sortDir:       modelv1.Sort_SORT_DESC,
+                       keys:          []int64{3, 2, 1},
+                       traceIDs:      []string{"trace3", "trace2", "trace1"},
+                       maxTraceSize:  2,
+                       wantCount:     3,
+                       wantPullCount: 2,
                },
        }
 
@@ -315,11 +319,18 @@ func TestVectorizedParityOrderMode(t *testing.T) {
                        defer pullRes.Release()
                        pullGot := collectResults(t, pullRes)
 
+                       // The pull path caps the materialized set at 
MaxTraceSize; the push path streams
+                       // all matches (the outer traceLimit caps it in 
production). With no cap the two
+                       // are identical; with a cap the pull result is the 
sorted MaxTraceSize-prefix.
+                       wantPull := tt.wantCount
+                       if tt.wantPullCount > 0 {
+                               wantPull = tt.wantPullCount
+                       }
                        require.Len(t, pushGot, tt.wantCount, "push path result 
count")
-                       require.Len(t, pullGot, tt.wantCount, "pull path result 
count")
+                       require.Len(t, pullGot, wantPull, "pull path result 
count")
 
-                       if diff := cmp.Diff(pushGot, pullGot, 
protocmp.Transform()); diff != "" {
-                               t.Errorf("push vs pull order mismatch (-push 
+pull):\n%s", diff)
+                       if diff := cmp.Diff(pushGot[:wantPull], pullGot, 
protocmp.Transform()); diff != "" {
+                               t.Errorf("pull path must equal the sorted 
MaxTraceSize-prefix of push (-push +pull):\n%s", diff)
                        }
                })
        }
diff --git a/pkg/cmdsetup/data.go b/pkg/cmdsetup/data.go
index cd6f6ba50..93891ff68 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)
+       q, err := query.NewService(ctx, streamSvc, measureSvc, traceSvc, 
metaSvc, pipeline, metricSvc, true)
        if err != nil {
                l.Fatal().Err(err).Msg("failed to initiate query processor")
        }
diff --git a/pkg/cmdsetup/standalone.go b/pkg/cmdsetup/standalone.go
index b76075607..86d715c91 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)
+       q, err := query.NewService(ctx, streamSvc, measureSvc, traceSvc, 
metaSvc, dataPipeline, metricSvc, false)
        if err != nil {
                l.Fatal().Err(err).Msg("failed to initiate query processor")
        }
diff --git a/pkg/query/logical/trace/trace_frame.go 
b/pkg/query/logical/trace/trace_frame.go
new file mode 100644
index 000000000..42cfe873f
--- /dev/null
+++ b/pkg/query/logical/trace/trace_frame.go
@@ -0,0 +1,325 @@
+// 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 trace
+
+import (
+       "encoding/binary"
+       "fmt"
+
+       "google.golang.org/protobuf/proto"
+
+       "github.com/apache/skywalking-banyandb/api/data"
+       modelv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+       "github.com/apache/skywalking-banyandb/pkg/query/model"
+)
+
+// traceFrameVersion is the on-wire version of the columnar trace result frame.
+const traceFrameVersion byte = 1
+
+// Tag-value type markers inside the columnar trace frame. Common scalar 
variants
+// are encoded directly (no protobuf reflection / oneof dispatch); anything 
else
+// (arrays, future variants) falls back to a per-cell proto.Marshal under
+// traceTagProto.
+const (
+       traceTagNull   byte = 0
+       traceTagStr    byte = 1
+       traceTagInt    byte = 2
+       traceTagBinary byte = 3
+       traceTagProto  byte = 4
+)
+
+// EncodeTraceResultFrame serializes a columnar slice of model.TraceResult 
into a
+// magic+version-prefixed frame: length-delimited traces, each with its spans 
and
+// columnar tags (one TagValue per span). It avoids the protobuf
+// message-slice + oneof machinery that dominates the data↔liaison hop.
+func EncodeTraceResultFrame(results []model.TraceResult) []byte {
+       // Pre-size to the exact encoded length so the buffer is allocated once 
with no
+       // doubling/realloc churn (the realloc churn was the source of the 
native path's
+       // excess bytes/RSS vs the proto path).
+       buf := make([]byte, 0, traceFrameSize(results))
+       buf = append(buf, data.RawFrameMagicLeadingByte, traceFrameVersion)
+       buf = binary.AppendUvarint(buf, uint64(len(results)))
+       for resultIdx := range results {
+               result := &results[resultIdx]
+               buf = appendLenString(buf, result.TID)
+               buf = appendFixed64(buf, uint64(result.Key))
+               buf = binary.AppendUvarint(buf, uint64(len(result.Spans)))
+               for spanIdx, spanBytes := range result.Spans {
+                       buf = appendLenBytes(buf, spanBytes)
+                       spanID := ""
+                       if spanIdx < len(result.SpanIDs) {
+                               spanID = result.SpanIDs[spanIdx]
+                       }
+                       buf = appendLenString(buf, spanID)
+               }
+               buf = binary.AppendUvarint(buf, uint64(len(result.Tags)))
+               for tagIdx := range result.Tags {
+                       tag := &result.Tags[tagIdx]
+                       buf = appendLenString(buf, tag.Name)
+                       buf = binary.AppendUvarint(buf, uint64(len(tag.Values)))
+                       for _, tagValue := range tag.Values {
+                               buf = appendTraceTagValue(buf, tagValue)
+                       }
+               }
+       }
+       return buf
+}
+
+// traceFrameSize computes the exact encoded byte length of results, mirroring
+// EncodeTraceResultFrame field-for-field so the encode buffer is pre-sized 
exactly.
+func traceFrameSize(results []model.TraceResult) int {
+       size := 2 + uvarintLen(uint64(len(results)))
+       for resultIdx := range results {
+               result := &results[resultIdx]
+               size += lenStringSize(result.TID) + 8 + 
uvarintLen(uint64(len(result.Spans)))
+               for spanIdx, spanBytes := range result.Spans {
+                       size += uvarintLen(uint64(len(spanBytes))) + 
len(spanBytes)
+                       spanIDLen := 0
+                       if spanIdx < len(result.SpanIDs) {
+                               spanIDLen = len(result.SpanIDs[spanIdx])
+                       }
+                       size += uvarintLen(uint64(spanIDLen)) + spanIDLen
+               }
+               size += uvarintLen(uint64(len(result.Tags)))
+               for tagIdx := range result.Tags {
+                       tag := &result.Tags[tagIdx]
+                       size += lenStringSize(tag.Name) + 
uvarintLen(uint64(len(tag.Values)))
+                       for _, tagValue := range tag.Values {
+                               size += 1 + traceTagValueSize(tagValue)
+                       }
+               }
+       }
+       return size
+}
+
+// traceTagValueSize returns the encoded byte length of one tag value, matching
+// appendTraceTagValue (1 type byte + payload).
+func traceTagValueSize(tv *modelv1.TagValue) int {
+       switch v := tv.GetValue().(type) {
+       case nil, *modelv1.TagValue_Null:
+               return 0
+       case *modelv1.TagValue_Str:
+               return lenStringSize(v.Str.GetValue())
+       case *modelv1.TagValue_Int:
+               return 8
+       case *modelv1.TagValue_BinaryData:
+               return uvarintLen(uint64(len(v.BinaryData))) + len(v.BinaryData)
+       default:
+               marshaledSize := proto.Size(tv)
+               return uvarintLen(uint64(marshaledSize)) + marshaledSize
+       }
+}
+
+func lenStringSize(s string) int {
+       return uvarintLen(uint64(len(s))) + len(s)
+}
+
+func uvarintLen(x uint64) int {
+       n := 1
+       for x >= 0x80 {
+               x >>= 7
+               n++
+       }
+       return n
+}
+
+// DecodeTraceResultFrame reconstructs the columnar slice of model.TraceResult
+// from a frame body. All bytes/strings are copied because the body buffer is
+// reused after this call returns.
+func DecodeTraceResultFrame(body []byte) ([]model.TraceResult, error) {
+       if len(body) < 2 || body[0] != data.RawFrameMagicLeadingByte {
+               return nil, fmt.Errorf("trace result frame: invalid magic")
+       }
+       if body[1] != traceFrameVersion {
+               return nil, fmt.Errorf("trace result frame: unsupported version 
%d", body[1])
+       }
+       cur := &frameCursor{b: body, pos: 2}
+       nTraces := cur.uvarint()
+       results := make([]model.TraceResult, 0, nTraces)
+       for traceIdx := 0; traceIdx < nTraces && cur.err == nil; traceIdx++ {
+               tid := cur.str()
+               key := int64(cur.fixed64())
+               nSpans := cur.uvarint()
+               spans := make([][]byte, 0, nSpans)
+               spanIDs := make([]string, 0, nSpans)
+               for spanIdx := 0; spanIdx < nSpans && cur.err == nil; spanIdx++ 
{
+                       spans = append(spans, cur.bytesCopy())
+                       spanIDs = append(spanIDs, cur.str())
+               }
+               nTags := cur.uvarint()
+               var tags []model.Tag
+               if nTags > 0 {
+                       tags = make([]model.Tag, 0, nTags)
+               }
+               for tagIdx := 0; tagIdx < nTags && cur.err == nil; tagIdx++ {
+                       tagName := cur.str()
+                       nValues := cur.uvarint()
+                       values := make([]*modelv1.TagValue, 0, nValues)
+                       for valueIdx := 0; valueIdx < nValues && cur.err == 
nil; valueIdx++ {
+                               values = append(values, cur.tagValue())
+                       }
+                       tags = append(tags, model.Tag{Name: tagName, Values: 
values})
+               }
+               results = append(results, model.TraceResult{TID: tid, Key: key, 
Spans: spans, SpanIDs: spanIDs, Tags: tags})
+       }
+       if cur.err != nil {
+               return nil, cur.err
+       }
+       return results, nil
+}
+
+func appendLenString(buf []byte, s string) []byte {
+       buf = binary.AppendUvarint(buf, uint64(len(s)))
+       return append(buf, s...)
+}
+
+func appendLenBytes(buf, b []byte) []byte {
+       buf = binary.AppendUvarint(buf, uint64(len(b)))
+       return append(buf, b...)
+}
+
+func appendFixed64(buf []byte, v uint64) []byte {
+       var tmp [8]byte
+       binary.LittleEndian.PutUint64(tmp[:], v)
+       return append(buf, tmp[:]...)
+}
+
+func appendTraceTagValue(buf []byte, tv *modelv1.TagValue) []byte {
+       switch v := tv.GetValue().(type) {
+       case nil, *modelv1.TagValue_Null:
+               return append(buf, traceTagNull)
+       case *modelv1.TagValue_Str:
+               buf = append(buf, traceTagStr)
+               return appendLenString(buf, v.Str.GetValue())
+       case *modelv1.TagValue_Int:
+               buf = append(buf, traceTagInt)
+               return appendFixed64(buf, uint64(v.Int.GetValue()))
+       case *modelv1.TagValue_BinaryData:
+               buf = append(buf, traceTagBinary)
+               return appendLenBytes(buf, v.BinaryData)
+       default:
+               marshaled, err := proto.Marshal(tv)
+               if err != nil {
+                       // proto.Marshal of a well-formed TagValue cannot fail; 
encode an empty
+                       // proto-fallback payload so decode reconstructs a zero 
TagValue rather
+                       // than corrupting the frame.
+                       marshaled = nil
+               }
+               buf = append(buf, traceTagProto)
+               return appendLenBytes(buf, marshaled)
+       }
+}
+
+// frameCursor is a bounds-checked cursor over a frame body; the first error is
+// sticky so callers can read straight-line and check err once at the end.
+type frameCursor struct {
+       err error
+       b   []byte
+       pos int
+}
+
+func (c *frameCursor) uvarint() int {
+       if c.err != nil {
+               return 0
+       }
+       v, n := binary.Uvarint(c.b[c.pos:])
+       if n <= 0 {
+               c.err = fmt.Errorf("trace result frame: bad uvarint at offset 
%d", c.pos)
+               return 0
+       }
+       c.pos += n
+       return int(v)
+}
+
+func (c *frameCursor) fixed64() uint64 {
+       if c.err != nil {
+               return 0
+       }
+       if c.pos+8 > len(c.b) {
+               c.err = fmt.Errorf("trace result frame: truncated fixed64 at 
offset %d", c.pos)
+               return 0
+       }
+       v := binary.LittleEndian.Uint64(c.b[c.pos:])
+       c.pos += 8
+       return v
+}
+
+func (c *frameCursor) str() string {
+       n := c.uvarint()
+       if c.err != nil {
+               return ""
+       }
+       if n < 0 || c.pos+n > len(c.b) {
+               c.err = fmt.Errorf("trace result frame: truncated string len %d 
at offset %d", n, c.pos)
+               return ""
+       }
+       s := string(c.b[c.pos : c.pos+n])
+       c.pos += n
+       return s
+}
+
+func (c *frameCursor) bytesCopy() []byte {
+       n := c.uvarint()
+       if c.err != nil {
+               return nil
+       }
+       if n < 0 || c.pos+n > len(c.b) {
+               c.err = fmt.Errorf("trace result frame: truncated bytes len %d 
at offset %d", n, c.pos)
+               return nil
+       }
+       out := make([]byte, n)
+       copy(out, c.b[c.pos:c.pos+n])
+       c.pos += n
+       return out
+}
+
+func (c *frameCursor) tagValue() *modelv1.TagValue {
+       if c.err != nil {
+               return nil
+       }
+       if c.pos >= len(c.b) {
+               c.err = fmt.Errorf("trace result frame: truncated tag value at 
offset %d", c.pos)
+               return nil
+       }
+       typ := c.b[c.pos]
+       c.pos++
+       switch typ {
+       case traceTagNull:
+               return &modelv1.TagValue{Value: &modelv1.TagValue_Null{}}
+       case traceTagStr:
+               return &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: 
&modelv1.Str{Value: c.str()}}}
+       case traceTagInt:
+               return &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: 
&modelv1.Int{Value: int64(c.fixed64())}}}
+       case traceTagBinary:
+               return &modelv1.TagValue{Value: 
&modelv1.TagValue_BinaryData{BinaryData: c.bytesCopy()}}
+       case traceTagProto:
+               raw := c.bytesCopy()
+               if c.err != nil {
+                       return nil
+               }
+               tv := &modelv1.TagValue{}
+               if err := proto.Unmarshal(raw, tv); err != nil {
+                       c.err = fmt.Errorf("trace result frame: tag value proto 
fallback: %w", err)
+                       return nil
+               }
+               return tv
+       default:
+               c.err = fmt.Errorf("trace result frame: unknown tag value type 
%d", typ)
+               return nil
+       }
+}
diff --git a/pkg/query/logical/trace/trace_frame_test.go 
b/pkg/query/logical/trace/trace_frame_test.go
new file mode 100644
index 000000000..3f6b7e8be
--- /dev/null
+++ b/pkg/query/logical/trace/trace_frame_test.go
@@ -0,0 +1,130 @@
+// 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 trace
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/require"
+       "google.golang.org/protobuf/proto"
+
+       "github.com/apache/skywalking-banyandb/api/data"
+       modelv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+       "github.com/apache/skywalking-banyandb/pkg/query/model"
+)
+
+func strTV(s string) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: 
&modelv1.Str{Value: s}}}
+}
+
+func intTV(i int64) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: 
&modelv1.Int{Value: i}}}
+}
+
+func nullTV() *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_Null{}}
+}
+
+func binTV(b []byte) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: 
&modelv1.TagValue_BinaryData{BinaryData: b}}
+}
+
+func intArrTV(vs ...int64) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_IntArray{IntArray: 
&modelv1.IntArray{Value: vs}}}
+}
+
+func sampleTraceResults() []model.TraceResult {
+       return []model.TraceResult{
+               {
+                       TID:     "trace-1",
+                       Key:     -42,
+                       Spans:   [][]byte{[]byte("payload-1"), 
[]byte("payload-2")},
+                       SpanIDs: []string{"span-1", "span-2"},
+                       Tags: []model.Tag{
+                               {Name: "service_id", Values: 
[]*modelv1.TagValue{strTV("svc-0"), strTV("svc-1")}},
+                               {Name: "duration", Values: 
[]*modelv1.TagValue{intTV(123), intTV(456)}},
+                               {Name: "missing", Values: 
[]*modelv1.TagValue{nullTV(), nullTV()}},
+                               {Name: "binary", Values: 
[]*modelv1.TagValue{binTV([]byte{0x1, 0x2, 0x3}), nullTV()}},
+                               {Name: "ints", Values: 
[]*modelv1.TagValue{intArrTV(7, 8, 9), intArrTV(10)}},
+                       },
+               },
+               {
+                       TID:     "trace-2",
+                       Key:     100,
+                       Spans:   [][]byte{[]byte("p")},
+                       SpanIDs: []string{"s"},
+                       Tags:    nil,
+               },
+       }
+}
+
+func requireTraceResultsEqual(t *testing.T, want, got []model.TraceResult) {
+       t.Helper()
+       require.Len(t, got, len(want))
+       for i := range want {
+               require.Equal(t, want[i].TID, got[i].TID, "trace %d TID", i)
+               require.Equal(t, want[i].Key, got[i].Key, "trace %d Key", i)
+               require.Equal(t, want[i].Spans, got[i].Spans, "trace %d Spans", 
i)
+               require.Equal(t, want[i].SpanIDs, got[i].SpanIDs, "trace %d 
SpanIDs", i)
+               require.Len(t, got[i].Tags, len(want[i].Tags), "trace %d tag 
count", i)
+               for j := range want[i].Tags {
+                       require.Equal(t, want[i].Tags[j].Name, 
got[i].Tags[j].Name, "trace %d tag %d name", i, j)
+                       require.Len(t, got[i].Tags[j].Values, 
len(want[i].Tags[j].Values), "trace %d tag %d value count", i, j)
+                       for k := range want[i].Tags[j].Values {
+                               require.True(t, 
proto.Equal(want[i].Tags[j].Values[k], got[i].Tags[j].Values[k]),
+                                       "trace %d tag %d value %d: want %v got 
%v", i, j, k, want[i].Tags[j].Values[k], got[i].Tags[j].Values[k])
+                       }
+               }
+       }
+}
+
+func TestTraceResultFrame_RoundTrip(t *testing.T) {
+       orig := sampleTraceResults()
+       body := EncodeTraceResultFrame(orig)
+       require.Greater(t, len(body), 0)
+       require.Equal(t, data.RawFrameMagicLeadingByte, body[0], "frame must 
carry the raw-frame magic")
+       require.Equal(t, traceFrameVersion, body[1], "frame must carry the 
version byte")
+
+       got, err := DecodeTraceResultFrame(body)
+       require.NoError(t, err)
+       requireTraceResultsEqual(t, orig, got)
+}
+
+// TestTraceResultFrame_ExactPreSize locks the invariant that traceFrameSize
+// equals the encoded length, so the encode buffer is pre-sized exactly (no
+// realloc, no over-allocation).
+func TestTraceResultFrame_ExactPreSize(t *testing.T) {
+       orig := sampleTraceResults()
+       body := EncodeTraceResultFrame(orig)
+       require.Equal(t, traceFrameSize(orig), len(body), "traceFrameSize must 
equal the encoded length")
+}
+
+func TestTraceResultFrame_Empty(t *testing.T) {
+       body := EncodeTraceResultFrame(nil)
+       require.Equal(t, data.RawFrameMagicLeadingByte, body[0])
+       got, err := DecodeTraceResultFrame(body)
+       require.NoError(t, err)
+       require.Empty(t, got)
+}
+
+func TestTraceResultFrame_BadMagic(t *testing.T) {
+       _, err := DecodeTraceResultFrame([]byte{0xFF, traceFrameVersion})
+       require.Error(t, err)
+       _, err = DecodeTraceResultFrame([]byte{data.RawFrameMagicLeadingByte, 
0x09})
+       require.Error(t, err)
+}
diff --git a/pkg/query/logical/trace/trace_plan_distributed.go 
b/pkg/query/logical/trace/trace_plan_distributed.go
index 073310e65..902fb6d31 100644
--- a/pkg/query/logical/trace/trace_plan_distributed.go
+++ b/pkg/query/logical/trace/trace_plan_distributed.go
@@ -157,48 +157,38 @@ func (p *distributedPlan) Execute(ctx context.Context) 
(iter.Iterator[model.Trac
        }
        var allErr error
        var responseCount int
-       var st []sort.Iterator[*comparableTrace]
+       var st []sort.Iterator[*comparableTraceResult]
        for _, f := range ff {
-               if m, getErr := f.Get(); getErr != nil {
+               m, getErr := f.Get()
+               if getErr != nil {
                        allErr = multierr.Append(allErr, getErr)
-               } else {
-                       d := m.Data()
-                       if d == nil {
-                               continue
-                       }
-                       resp := d.(*tracev1.InternalQueryResponse)
-                       responseCount++
-                       if span != nil {
-                               span.AddSubTrace(resp.TraceQueryResult)
-                       }
-                       st = append(st,
-                               newSortableTraces(resp.InternalTraces, 
p.sortByTraceID))
+                       continue
+               }
+               d := m.Data()
+               if d == nil {
+                       continue
                }
+               nodeResults, decodeErr := p.decodeNodeResults(d, span)
+               if decodeErr != nil {
+                       allErr = multierr.Append(allErr, decodeErr)
+                       continue
+               }
+               responseCount++
+               st = append(st, 
newSortableTraceResults(iter.FromSlice(nodeResults), p.sortByTraceID))
        }
        if span != nil {
                span.Tagf("response_count", "%d", responseCount)
        }
        sortIter := sort.NewItemIter(st, p.desc)
-       var result []*tracev1.InternalTrace
-       seen := make(map[string]*tracev1.InternalTrace)
+       var result []model.TraceResult
+       seen := make(map[string]int)
        for sortIter.Next() {
-               trace := sortIter.Val().InternalTrace
-               if _, ok := seen[trace.TraceId]; !ok {
-                       seen[trace.TraceId] = trace
-                       result = append(result, trace)
+               tr := sortIter.Val().result
+               if idx, ok := seen[tr.TID]; !ok {
+                       seen[tr.TID] = len(result)
+                       result = append(result, tr)
                } else {
-                       for _, span := range trace.Spans {
-                               spanExists := false
-                               for _, existingSpan := range 
seen[trace.TraceId].Spans {
-                                       if existingSpan.SpanId == span.SpanId {
-                                               spanExists = true
-                                               break
-                                       }
-                               }
-                               if !spanExists {
-                                       seen[trace.TraceId].Spans = 
append(seen[trace.TraceId].Spans, span)
-                               }
-                       }
+                       mergeTraceResultSpans(&result[idx], &tr)
                }
        }
        if span != nil {
@@ -212,6 +202,33 @@ func (p *distributedPlan) Execute(ctx context.Context) 
(iter.Iterator[model.Trac
        }, nil
 }
 
+// decodeNodeResults converts a single node response into columnar
+// []model.TraceResult, accepting either the proto 
*tracev1.InternalQueryResponse
+// (flag-off / tracing path) or the native columnar frame ([]byte). The proto
+// branch is converted via internalTraceToResult so both paths feed a unified
+// model.TraceResult merge.
+func (p *distributedPlan) decodeNodeResults(d any, span *query.Span) 
([]model.TraceResult, error) {
+       switch payload := d.(type) {
+       case *tracev1.InternalQueryResponse:
+               if span != nil {
+                       span.AddSubTrace(payload.TraceQueryResult)
+               }
+               results := make([]model.TraceResult, 0, 
len(payload.InternalTraces))
+               for _, internalTrace := range payload.InternalTraces {
+                       results = append(results, 
internalTraceToResult(internalTrace))
+               }
+               return results, nil
+       case []byte:
+               results, decodeErr := DecodeTraceResultFrame(payload)
+               if decodeErr != nil {
+                       return nil, fmt.Errorf("decode trace result frame: %w", 
decodeErr)
+               }
+               return results, nil
+       default:
+               return nil, fmt.Errorf("unexpected trace query response type 
%T", d)
+       }
+}
+
 func (p *distributedPlan) String() string {
        return fmt.Sprintf("distributed:%s", p.queryTemplate.String())
 }
@@ -228,72 +245,92 @@ func (p *distributedPlan) Limit(maxVal int) {
        p.maxTraceSize = uint32(maxVal)
 }
 
-var _ sort.Comparable = (*comparableTrace)(nil)
-
-type comparableTrace struct {
-       *tracev1.InternalTrace
-       sortField []byte
-}
-
-func newComparableTrace(t *tracev1.InternalTrace, sortByTraceID bool) 
(*comparableTrace, error) {
-       var sortField []byte
-       if sortByTraceID {
-               sortField = []byte(t.TraceId)
-       } else {
-               sortField = convert.Int64ToBytes(t.Key)
+// internalTraceToResult converts a proto *tracev1.InternalTrace (row-oriented:
+// per-span tag lists) into a columnar model.TraceResult (per-tag value column
+// aligned across spans, NullTagValue for spans missing a tag). It is the 
inverse
+// of the data-node frame build and matches the legacy iterator's 
column-building.
+func internalTraceToResult(trace *tracev1.InternalTrace) model.TraceResult {
+       result := model.TraceResult{
+               TID:     trace.TraceId,
+               Key:     trace.Key,
+               Spans:   make([][]byte, 0, len(trace.Spans)),
+               SpanIDs: make([]string, 0, len(trace.Spans)),
        }
-
-       return &comparableTrace{
-               InternalTrace: t,
-               sortField:     sortField,
-       }, nil
-}
-
-func (t *comparableTrace) SortedField() []byte {
-       return t.sortField
-}
-
-var _ sort.Iterator[*comparableTrace] = (*sortableTraces)(nil)
-
-type sortableTraces struct {
-       cur             *comparableTrace
-       traces          []*tracev1.InternalTrace
-       index           int
-       isSortByTraceID bool
-}
-
-func newSortableTraces(traces []*tracev1.InternalTrace, isSortByTraceID bool) 
*sortableTraces {
-       return &sortableTraces{
-               traces:          traces,
-               isSortByTraceID: isSortByTraceID,
+       tagValuesByName := make(map[string][]*modelv1.TagValue)
+       var tagOrder []string
+       for spanIdx, span := range trace.Spans {
+               result.Spans = append(result.Spans, span.Span)
+               result.SpanIDs = append(result.SpanIDs, span.SpanId)
+               spanTags := make(map[string]*modelv1.TagValue, len(span.Tags))
+               for _, tag := range span.Tags {
+                       if _, exists := tagValuesByName[tag.Key]; !exists {
+                               tagOrder = append(tagOrder, tag.Key)
+                               tagValuesByName[tag.Key] = 
make([]*modelv1.TagValue, spanIdx)
+                               for fillIdx := range tagValuesByName[tag.Key] {
+                                       tagValuesByName[tag.Key][fillIdx] = 
pbv1.NullTagValue
+                               }
+                       }
+                       spanTags[tag.Key] = tag.Value
+               }
+               for _, tagName := range tagOrder {
+                       tagValue, exists := spanTags[tagName]
+                       if !exists {
+                               tagValue = pbv1.NullTagValue
+                       }
+                       tagValuesByName[tagName] = 
append(tagValuesByName[tagName], tagValue)
+               }
        }
+       for _, tagName := range tagOrder {
+               result.Tags = append(result.Tags, model.Tag{Name: tagName, 
Values: tagValuesByName[tagName]})
+       }
+       return result
 }
 
-func (*sortableTraces) Close() error {
-       return nil
-}
-
-func (t *sortableTraces) Next() bool {
-       return t.iter(func(it *tracev1.InternalTrace) (*comparableTrace, error) 
{
-               return newComparableTrace(it, t.isSortByTraceID)
-       })
-}
-
-func (t *sortableTraces) Val() *comparableTrace {
-       return t.cur
+// mergeTraceResultSpans merges spans from src into dst (same TID): a span is
+// appended only when its SpanID is not already present in dst, and the 
matching
+// per-tag value column is extended in lockstep. This mirrors the legacy
+// InternalTrace span-dedup but on the columnar model.TraceResult. Tag columns
+// present only in src are first unioned into dst (NullTagValue-backfilled for 
the
+// spans dst already holds) so no src-only tag column is dropped during the 
merge.
+func mergeTraceResultSpans(dst, src *model.TraceResult) {
+       existing := make(map[string]struct{}, len(dst.SpanIDs))
+       for _, spanID := range dst.SpanIDs {
+               existing[spanID] = struct{}{}
+       }
+       for _, srcTag := range src.Tags {
+               if _, ok := findTag(dst.Tags, srcTag.Name); ok {
+                       continue
+               }
+               backfill := make([]*modelv1.TagValue, len(dst.SpanIDs))
+               for fillIdx := range backfill {
+                       backfill[fillIdx] = pbv1.NullTagValue
+               }
+               dst.Tags = append(dst.Tags, model.Tag{Name: srcTag.Name, 
Values: backfill})
+       }
+       for srcIdx, srcSpanID := range src.SpanIDs {
+               if _, ok := existing[srcSpanID]; ok {
+                       continue
+               }
+               existing[srcSpanID] = struct{}{}
+               dst.SpanIDs = append(dst.SpanIDs, srcSpanID)
+               dst.Spans = append(dst.Spans, src.Spans[srcIdx])
+               for tagIdx := range dst.Tags {
+                       value := pbv1.NullTagValue
+                       if srcTag, ok := findTag(src.Tags, 
dst.Tags[tagIdx].Name); ok && srcIdx < len(srcTag.Values) {
+                               value = srcTag.Values[srcIdx]
+                       }
+                       dst.Tags[tagIdx].Values = 
append(dst.Tags[tagIdx].Values, value)
+               }
+       }
 }
 
-func (t *sortableTraces) iter(fn func(*tracev1.InternalTrace) 
(*comparableTrace, error)) bool {
-       if t.index >= len(t.traces) {
-               return false
-       }
-       cur, err := fn(t.traces[t.index])
-       t.index++
-       if err != nil {
-               return t.iter(fn)
+func findTag(tags []model.Tag, name string) (model.Tag, bool) {
+       for _, tag := range tags {
+               if tag.Name == name {
+                       return tag, true
+               }
        }
-       t.cur = cur
-       return true
+       return model.Tag{}, false
 }
 
 var _ executor.TraceExecutable = (*distributedTraceLimit)(nil)
@@ -357,7 +394,7 @@ func newDistributedTraceLimit(input logical.UnresolvedPlan, 
offset, limit uint32
 
 type distributedTraceResultIterator struct {
        err    error
-       traces []*tracev1.InternalTrace
+       traces []model.TraceResult
        index  int
 }
 
@@ -369,47 +406,7 @@ func (t *distributedTraceResultIterator) Next() 
(model.TraceResult, bool) {
                return model.TraceResult{}, false
        }
 
-       trace := t.traces[t.index]
+       result := t.traces[t.index]
        t.index++
-
-       result := model.TraceResult{
-               TID:     trace.TraceId,
-               Spans:   make([][]byte, 0, len(trace.Spans)),
-               SpanIDs: make([]string, 0, len(trace.Spans)),
-               Tags:    make([]model.Tag, 0, len(trace.Spans)),
-       }
-       tagValuesByName := make(map[string][]*modelv1.TagValue)
-       var tagOrder []string
-
-       for spanIdx, span := range trace.Spans {
-               // Add span data
-               result.Spans = append(result.Spans, span.Span)
-               // Extract tags from this span and aggregate by name
-               spanTags := make(map[string]*modelv1.TagValue, len(span.Tags))
-               for _, tag := range span.Tags {
-                       if _, exists := tagValuesByName[tag.Key]; !exists {
-                               tagOrder = append(tagOrder, tag.Key)
-                               tagValuesByName[tag.Key] = 
make([]*modelv1.TagValue, spanIdx)
-                               for fillIdx := range tagValuesByName[tag.Key] {
-                                       tagValuesByName[tag.Key][fillIdx] = 
pbv1.NullTagValue
-                               }
-                       }
-                       spanTags[tag.Key] = tag.Value
-               }
-               for _, tagName := range tagOrder {
-                       tagValue, exists := spanTags[tagName]
-                       if !exists {
-                               tagValue = pbv1.NullTagValue
-                       }
-                       tagValuesByName[tagName] = 
append(tagValuesByName[tagName], tagValue)
-               }
-               result.SpanIDs = append(result.SpanIDs, span.SpanId)
-       }
-       for _, tagName := range tagOrder {
-               result.Tags = append(result.Tags, model.Tag{
-                       Name:   tagName,
-                       Values: tagValuesByName[tagName],
-               })
-       }
        return result, true
 }
diff --git a/pkg/query/logical/trace/trace_plan_distributed_test.go 
b/pkg/query/logical/trace/trace_plan_distributed_test.go
index 9464627d2..18561df35 100644
--- a/pkg/query/logical/trace/trace_plan_distributed_test.go
+++ b/pkg/query/logical/trace/trace_plan_distributed_test.go
@@ -25,41 +25,35 @@ import (
        modelv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
        tracev1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
        pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+       "github.com/apache/skywalking-banyandb/pkg/query/model"
 )
 
-func TestDistributedTraceResultIteratorPreservesTagOrderAndSpanAlignment(t 
*testing.T) {
-       iterator := &distributedTraceResultIterator{
-               traces: []*tracev1.InternalTrace{
+func TestInternalTraceToResultPreservesTagOrderAndSpanAlignment(t *testing.T) {
+       got := internalTraceToResult(&tracev1.InternalTrace{
+               TraceId: "trace-1",
+               Spans: []*tracev1.Span{
                        {
-                               TraceId: "trace-1",
-                               Spans: []*tracev1.Span{
-                                       {
-                                               Span:   []byte("span-1"),
-                                               SpanId: "span-1",
-                                               Tags: []*modelv1.Tag{
-                                                       {Key: "service_id", 
Value: strTagValueForDistributedTest("svc")},
-                                                       {Key: "trace_id", 
Value: pbv1.NullTagValue},
-                                                       {Key: "trace_id", 
Value: strTagValueForDistributedTest("trace-1")},
-                                                       {Key: "span_id", Value: 
strTagValueForDistributedTest("span-1")},
-                                               },
-                                       },
-                                       {
-                                               Span:   []byte("span-2"),
-                                               SpanId: "span-2",
-                                               Tags: []*modelv1.Tag{
-                                                       {Key: "service_id", 
Value: strTagValueForDistributedTest("svc")},
-                                                       {Key: "trace_id", 
Value: strTagValueForDistributedTest("trace-1")},
-                                                       {Key: "span_id", Value: 
strTagValueForDistributedTest("span-2")},
-                                               },
-                                       },
+                               Span:   []byte("span-1"),
+                               SpanId: "span-1",
+                               Tags: []*modelv1.Tag{
+                                       {Key: "service_id", Value: 
strTagValueForDistributedTest("svc")},
+                                       {Key: "trace_id", Value: 
pbv1.NullTagValue},
+                                       {Key: "trace_id", Value: 
strTagValueForDistributedTest("trace-1")},
+                                       {Key: "span_id", Value: 
strTagValueForDistributedTest("span-1")},
+                               },
+                       },
+                       {
+                               Span:   []byte("span-2"),
+                               SpanId: "span-2",
+                               Tags: []*modelv1.Tag{
+                                       {Key: "service_id", Value: 
strTagValueForDistributedTest("svc")},
+                                       {Key: "trace_id", Value: 
strTagValueForDistributedTest("trace-1")},
+                                       {Key: "span_id", Value: 
strTagValueForDistributedTest("span-2")},
                                },
                        },
                },
-       }
+       })
 
-       got, ok := iterator.Next()
-
-       require.True(t, ok)
        require.Len(t, got.Tags, 3)
        require.Equal(t, "service_id", got.Tags[0].Name)
        require.Equal(t, "trace_id", got.Tags[1].Name)
@@ -70,36 +64,29 @@ func 
TestDistributedTraceResultIteratorPreservesTagOrderAndSpanAlignment(t *test
        require.Equal(t, "span-2", got.Tags[2].Values[1].GetStr().GetValue())
 }
 
-func TestDistributedTraceResultIteratorNullFillsMissingSpanTags(t *testing.T) {
-       iterator := &distributedTraceResultIterator{
-               traces: []*tracev1.InternalTrace{
+func TestInternalTraceToResultNullFillsMissingSpanTags(t *testing.T) {
+       got := internalTraceToResult(&tracev1.InternalTrace{
+               TraceId: "trace-1",
+               Spans: []*tracev1.Span{
                        {
-                               TraceId: "trace-1",
-                               Spans: []*tracev1.Span{
-                                       {
-                                               Span:   []byte("span-1"),
-                                               SpanId: "span-1",
-                                               Tags: []*modelv1.Tag{
-                                                       {Key: "service_id", 
Value: strTagValueForDistributedTest("svc")},
-                                                       {Key: "only_on_first", 
Value: strTagValueForDistributedTest("first")},
-                                               },
-                                       },
-                                       {
-                                               Span:   []byte("span-2"),
-                                               SpanId: "span-2",
-                                               Tags: []*modelv1.Tag{
-                                                       {Key: "service_id", 
Value: strTagValueForDistributedTest("svc")},
-                                                       {Key: "only_on_second", 
Value: strTagValueForDistributedTest("second")},
-                                               },
-                                       },
+                               Span:   []byte("span-1"),
+                               SpanId: "span-1",
+                               Tags: []*modelv1.Tag{
+                                       {Key: "service_id", Value: 
strTagValueForDistributedTest("svc")},
+                                       {Key: "only_on_first", Value: 
strTagValueForDistributedTest("first")},
+                               },
+                       },
+                       {
+                               Span:   []byte("span-2"),
+                               SpanId: "span-2",
+                               Tags: []*modelv1.Tag{
+                                       {Key: "service_id", Value: 
strTagValueForDistributedTest("svc")},
+                                       {Key: "only_on_second", Value: 
strTagValueForDistributedTest("second")},
                                },
                        },
                },
-       }
-
-       got, ok := iterator.Next()
+       })
 
-       require.True(t, ok)
        spanCount := len(got.Spans)
        require.Equal(t, 2, spanCount)
 
@@ -121,6 +108,53 @@ func 
TestDistributedTraceResultIteratorNullFillsMissingSpanTags(t *testing.T) {
        require.Equal(t, "second", 
values["only_on_second"][1].GetStr().GetValue())
 }
 
+func TestMergeTraceResultSpansUnionsSrcOnlyTagColumns(t *testing.T) {
+       // dst (from node A) holds span s1 with only service_id.
+       dst := model.TraceResult{
+               TID:     "t1",
+               Spans:   [][]byte{[]byte("s1")},
+               SpanIDs: []string{"s1"},
+               Tags: []model.Tag{
+                       {Name: "service_id", Values: 
[]*modelv1.TagValue{strTagValueForDistributedTest("svc")}},
+               },
+       }
+       // src (from node B) re-reports s1 (duplicate) and adds s2, plus a tag 
column
+       // http_method that dst never saw.
+       src := model.TraceResult{
+               TID:     "t1",
+               Spans:   [][]byte{[]byte("s1"), []byte("s2")},
+               SpanIDs: []string{"s1", "s2"},
+               Tags: []model.Tag{
+                       {Name: "service_id", Values: 
[]*modelv1.TagValue{strTagValueForDistributedTest("svc"), 
strTagValueForDistributedTest("svc")}},
+                       {Name: "http_method", Values: 
[]*modelv1.TagValue{strTagValueForDistributedTest("GET"), 
strTagValueForDistributedTest("POST")}},
+               },
+       }
+
+       mergeTraceResultSpans(&dst, &src)
+
+       // Duplicate span s1 is skipped; only s2 is appended.
+       require.Equal(t, []string{"s1", "s2"}, dst.SpanIDs)
+       require.Len(t, dst.Spans, 2)
+
+       // The src-only http_method column is unioned in (not dropped), and 
every
+       // column stays aligned with the span count.
+       values := make(map[string][]*modelv1.TagValue, len(dst.Tags))
+       for _, tag := range dst.Tags {
+               require.Lenf(t, tag.Values, len(dst.SpanIDs), "tag %q must have 
one value per span", tag.Name)
+               values[tag.Name] = tag.Values
+       }
+       require.Contains(t, values, "service_id")
+       require.Contains(t, values, "http_method")
+
+       require.Equal(t, "svc", values["service_id"][0].GetStr().GetValue())
+       require.Equal(t, "svc", values["service_id"][1].GetStr().GetValue())
+
+       // s1 predates the src-only column, so it is NULL-backfilled; the newly
+       // appended s2 carries its real value.
+       require.Same(t, pbv1.NullTagValue, values["http_method"][0])
+       require.Equal(t, "POST", values["http_method"][1].GetStr().GetValue())
+}
+
 func strTagValueForDistributedTest(value string) *modelv1.TagValue {
        return &modelv1.TagValue{
                Value: &modelv1.TagValue_Str{

Reply via email to