You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/03/06 10:05:04 UTC

(skywalking-banyandb) branch main updated: Fix sidx tag filter range check issue (#991)

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

wusheng 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 54b7a645c Fix sidx tag filter range check issue (#991)
54b7a645c is described below

commit 54b7a645c3c06b269f1e6709b5aa321110b4c274
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Fri Mar 6 18:04:59 2026 +0800

    Fix sidx tag filter range check issue (#991)
---
 .github/actions/build-docker-image/action.yml      |   2 +-
 .github/actions/setup-build-env/action.yml         |   2 +-
 .github/workflows/e2e.yml                          |   4 +-
 .github/workflows/prepare.yml                      |   2 +-
 .github/workflows/property-repair.yml              |   2 +-
 .github/workflows/test-integration-distributed.yml |   2 +-
 .github/workflows/test-integration-standalone.yml  |   2 +-
 .github/workflows/test.yml                         |   2 +-
 CHANGES.md                                         |   1 +
 banyand/cmd/dump/sidx.go                           | 106 ++++++++++++++--
 banyand/internal/sidx/block.go                     |   8 +-
 banyand/internal/sidx/block_scanner.go             | 134 ++++++++++++++++++++-
 banyand/internal/sidx/block_scanner_test.go        |  97 +++++++++++++++
 banyand/internal/sidx/interfaces.go                |   4 +-
 banyand/internal/sidx/iter.go                      |   4 +-
 banyand/internal/sidx/iter_test.go                 |  14 +--
 banyand/internal/sidx/part_key_iter.go             |  30 ++++-
 banyand/internal/sidx/part_key_iter_test.go        |   8 +-
 banyand/internal/sidx/tag_filter_op.go             |  17 +--
 banyand/internal/sidx/tag_filter_op_test.go        | 115 +++++++++---------
 .../duration_range_and_ipv4_order_timestamp.ql     |  23 ++++
 .../duration_range_and_ipv4_order_timestamp.yml    |  50 ++++++++
 .../duration_range_and_ipv4_order_timestamp.yml    |  24 ++++
 test/cases/trace/trace.go                          |   2 +
 24 files changed, 541 insertions(+), 114 deletions(-)

diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml
index 372a191f4..c2f6a62f4 100644
--- a/.github/actions/build-docker-image/action.yml
+++ b/.github/actions/build-docker-image/action.yml
@@ -54,7 +54,7 @@ runs:
         ls -lh banyandb-testing-image.tar.gz
 
     - name: Upload docker image artifact
-      uses: actions/upload-artifact@v4
+      uses: actions/upload-artifact@v7
       with:
         name: ${{ inputs.artifact-name }}
         path: banyandb-testing-image.tar.gz
diff --git a/.github/actions/setup-build-env/action.yml b/.github/actions/setup-build-env/action.yml
index ae6137fb2..6dc18bfef 100644
--- a/.github/actions/setup-build-env/action.yml
+++ b/.github/actions/setup-build-env/action.yml
@@ -32,7 +32,7 @@ runs:
   steps:
     - name: Download build artifacts
       if: inputs.download-artifacts == 'true'
-      uses: actions/download-artifact@v7
+      uses: actions/download-artifact@v8
       with:
         name: build-artifacts
 
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 793dade91..60825a5e5 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -104,13 +104,13 @@ jobs:
           df -h
           du -sh .
           docker images
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v7
         if: ${{ failure() }}
         name: Upload Logs
         with:
           name: test-logs-${{ matrix.test.name }}
           path: "${{ env.SW_INFRA_E2E_LOG_DIR }}"
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v7
         if: ${{ (failure() && matrix.test.name == 'Lifecycle') }}
         name: Upload generated data
         with:
diff --git a/.github/workflows/prepare.yml b/.github/workflows/prepare.yml
index 281f171e9..1668911ff 100644
--- a/.github/workflows/prepare.yml
+++ b/.github/workflows/prepare.yml
@@ -55,7 +55,7 @@ jobs:
           npm run build
           cd ..
       - name: Upload generated files
-        uses: actions/upload-artifact@v6
+        uses: actions/upload-artifact@v7
         with:
           name: build-artifacts
           path: |
diff --git a/.github/workflows/property-repair.yml b/.github/workflows/property-repair.yml
index cd875dce5..2774dfb47 100644
--- a/.github/workflows/property-repair.yml
+++ b/.github/workflows/property-repair.yml
@@ -64,7 +64,7 @@ jobs:
         if: ${{ failure() }}
         id: sanitize-name
         run: echo "sanitized=$(echo '${{ inputs.test-name }}' | sed 's/[^a-zA-Z0-9._-]/-/g')" >> $GITHUB_OUTPUT
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v7
         if: ${{ failure() }}
         name: Upload BanyanDB Data Folder
         with:
diff --git a/.github/workflows/test-integration-distributed.yml b/.github/workflows/test-integration-distributed.yml
index 66d0d9b23..40f1e4f1f 100644
--- a/.github/workflows/test-integration-distributed.yml
+++ b/.github/workflows/test-integration-distributed.yml
@@ -23,7 +23,7 @@ jobs:
   test-integration-distributed:
     strategy:
       matrix:
-        tz: ["UTC", "Asia/Shanghai", "America/Los_Angeles"]
+        tz: ["UTC", "America/Los_Angeles"]
     uses: ./.github/workflows/test.yml
     with:
       test-name: Integration Distributed
diff --git a/.github/workflows/test-integration-standalone.yml b/.github/workflows/test-integration-standalone.yml
index b54260ff6..3ec40b2ca 100644
--- a/.github/workflows/test-integration-standalone.yml
+++ b/.github/workflows/test-integration-standalone.yml
@@ -23,7 +23,7 @@ jobs:
   test-integration-standalone:
     strategy:
       matrix:
-        tz: ["UTC", "Asia/Shanghai", "America/Los_Angeles"]
+        tz: ["UTC", "America/Los_Angeles"]
     uses: ./.github/workflows/test.yml
     with:
       test-name: Integration Standalone
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 047328f69..306bd4572 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -75,7 +75,7 @@ jobs:
         if: ${{ failure() }}
         id: sanitize-name
         run: echo "sanitized=$(echo '${{ inputs.test-name }}-${{ inputs.timezone }}' | sed 's/[^a-zA-Z0-9._-]/-/g')" >> $GITHUB_OUTPUT
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v7
         if: ${{ failure() }}
         name: Upload BanyanDB Data Folder
         with:
diff --git a/CHANGES.md b/CHANGES.md
index 48c7daebc..2b5010d24 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -38,6 +38,7 @@ Release Notes.
 - Fix the lifecycle panic when the trace has no sidx.
 - Fix panic in sidx merge and flush operations when part counts don't match expectations.
 - Fix trace queries with range conditions on the same tag (e.g., duration) combined with ORDER BY by deduplicating tag names when merging logical expression branches.
+- Fix sidx tag filter range check returning inverted skip decision and use correct int64 encoding for block min/max.
 
 ### Document
 
diff --git a/banyand/cmd/dump/sidx.go b/banyand/cmd/dump/sidx.go
index e4425a063..c084119b1 100644
--- a/banyand/cmd/dump/sidx.go
+++ b/banyand/cmd/dump/sidx.go
@@ -39,6 +39,7 @@ import (
 	"github.com/apache/skywalking-banyandb/banyand/internal/sidx"
 	"github.com/apache/skywalking-banyandb/banyand/protector"
 	"github.com/apache/skywalking-banyandb/pkg/convert"
+	"github.com/apache/skywalking-banyandb/pkg/encoding"
 	"github.com/apache/skywalking-banyandb/pkg/fs"
 	"github.com/apache/skywalking-banyandb/pkg/index/inverted"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
@@ -797,6 +798,13 @@ func dumpSidxFullScan(sidxPath, segmentPath string, criteria *modelv1.Criteria,
 		}
 	}
 
+	// Discover projected tag value types so binary values (for example int64) can be rendered as readable values.
+	projectionTagTypes, err := discoverProjectionTagTypes(sidxPath, projectionTagNames)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Warning: failed to discover projection tag types: %v\n", err)
+		projectionTagTypes = nil
+	}
+
 	// Create dynamic tag registry if criteria is provided
 	var tagRegistry *dynamicTagRegistry
 	if criteria != nil {
@@ -878,9 +886,9 @@ func dumpSidxFullScan(sidxPath, segmentPath string, criteria *modelv1.Criteria,
 
 	// Output results
 	if csvOutput {
-		return outputScanResultsAsCSV(results, seriesMap, projectionTagNames, dataFilter)
+		return outputScanResultsAsCSV(results, seriesMap, projectionTagNames, projectionTagTypes, dataFilter)
 	}
-	return outputScanResultsAsText(results, sidxPath, seriesMap, projectionTagNames, dataFilter)
+	return outputScanResultsAsText(results, sidxPath, seriesMap, projectionTagNames, projectionTagTypes, dataFilter)
 }
 
 // parseProjectionTags parses a comma-separated list of tag names.
@@ -939,7 +947,9 @@ func loadSeriesMap(segmentPath string) (map[common.SeriesID]string, error) {
 	return seriesMap, nil
 }
 
-func outputScanResultsAsText(results []*sidx.QueryResponse, sidxPath string, seriesMap map[common.SeriesID]string, projectionTagNames []string, dataFilter string) error {
+func outputScanResultsAsText(results []*sidx.QueryResponse, sidxPath string, seriesMap map[common.SeriesID]string,
+	projectionTagNames []string, projectionTagTypes map[string]pbv1.ValueType, dataFilter string,
+) error {
 	fmt.Printf("Opening sidx: %s\n", sidxPath)
 	fmt.Printf("================================================================================\n\n")
 
@@ -984,9 +994,9 @@ func outputScanResultsAsText(results []*sidx.QueryResponse, sidxPath string, ser
 			if len(projectionTagNames) > 0 && resp.Tags != nil {
 				for _, tagName := range projectionTagNames {
 					if tagValues, ok := resp.Tags[tagName]; ok && i < len(tagValues) {
-						tagValue := tagValues[i]
+						tagValue := decodeProjectedTagValue(tagValues[i], projectionTagTypes[tagName])
 						// Calculate size of the tag value
-						tagSize := len(tagValue)
+						tagSize := len(tagValues[i])
 						fmt.Printf("  %s: %s (size: %d bytes)\n", tagName, tagValue, tagSize)
 					}
 				}
@@ -1008,7 +1018,9 @@ func outputScanResultsAsText(results []*sidx.QueryResponse, sidxPath string, ser
 	return nil
 }
 
-func outputScanResultsAsCSV(results []*sidx.QueryResponse, seriesMap map[common.SeriesID]string, projectionTagNames []string, dataFilter string) error {
+func outputScanResultsAsCSV(results []*sidx.QueryResponse, seriesMap map[common.SeriesID]string,
+	projectionTagNames []string, projectionTagTypes map[string]pbv1.ValueType, dataFilter string,
+) error {
 	writer := csv.NewWriter(os.Stdout)
 	defer writer.Flush()
 
@@ -1058,9 +1070,9 @@ func outputScanResultsAsCSV(results []*sidx.QueryResponse, seriesMap map[common.
 
 				if resp.Tags != nil {
 					if tagValues, ok := resp.Tags[tagName]; ok && i < len(tagValues) {
-						tagValue = tagValues[i]
+						tagValue = decodeProjectedTagValue(tagValues[i], projectionTagTypes[tagName])
 						// Calculate size of the tag value
-						tagSize = fmt.Sprintf("%d", len(tagValue))
+						tagSize = fmt.Sprintf("%d", len(tagValues[i]))
 					}
 				}
 
@@ -1077,3 +1089,81 @@ func outputScanResultsAsCSV(results []*sidx.QueryResponse, seriesMap map[common.
 
 	return nil
 }
+
+func discoverProjectionTagTypes(sidxPath string, projectionTagNames []string) (map[string]pbv1.ValueType, error) {
+	if len(projectionTagNames) == 0 {
+		return nil, nil
+	}
+	partEntries, err := os.ReadDir(sidxPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read sidx path %s: %w", sidxPath, err)
+	}
+	partPaths := make([]string, 0, len(partEntries))
+	for _, entry := range partEntries {
+		if !entry.IsDir() {
+			continue
+		}
+		partPaths = append(partPaths, filepath.Join(sidxPath, entry.Name()))
+	}
+	sort.Strings(partPaths)
+
+	result := make(map[string]pbv1.ValueType, len(projectionTagNames))
+	for _, tagName := range projectionTagNames {
+		for _, partPath := range partPaths {
+			tmPath := filepath.Join(partPath, tagName+".tm")
+			data, readErr := os.ReadFile(tmPath)
+			if readErr != nil {
+				continue
+			}
+			valueType, parseErr := parseSidxTagValueType(data)
+			if parseErr != nil {
+				fmt.Fprintf(os.Stderr, "Warning: failed to parse %s: %v\n", tmPath, parseErr)
+				break
+			}
+			result[tagName] = valueType
+			break
+		}
+	}
+	return result, nil
+}
+
+func parseSidxTagValueType(data []byte) (pbv1.ValueType, error) {
+	src, _, err := encoding.DecodeBytes(data)
+	if err != nil {
+		return pbv1.ValueTypeUnknown, fmt.Errorf("cannot decode tag name: %w", err)
+	}
+	if len(src) < 1 {
+		return pbv1.ValueTypeUnknown, fmt.Errorf("invalid tag metadata: missing value type")
+	}
+	return pbv1.ValueType(src[0]), nil
+}
+
+func decodeProjectedTagValue(raw string, valueType pbv1.ValueType) string {
+	if raw == "" {
+		return raw
+	}
+	rawBytes := []byte(raw)
+
+	switch valueType {
+	case pbv1.ValueTypeInt64:
+		if len(rawBytes) != 8 {
+			return raw
+		}
+		return strconv.FormatInt(convert.BytesToInt64(rawBytes), 10)
+	case pbv1.ValueTypeFloat64:
+		if len(rawBytes) != 8 {
+			return raw
+		}
+		return strconv.FormatFloat(convert.BytesToFloat64(rawBytes), 'f', -1, 64)
+	case pbv1.ValueTypeTimestamp:
+		if len(rawBytes) != 8 {
+			return raw
+		}
+		nanos := convert.BytesToInt64(rawBytes)
+		return strconv.FormatInt(nanos, 10)
+	case pbv1.ValueTypeBinaryData:
+		return fmt.Sprintf("%x", rawBytes)
+	default:
+		return raw
+	}
+}
diff --git a/banyand/internal/sidx/block.go b/banyand/internal/sidx/block.go
index 4c68bad3e..c240dbbfc 100644
--- a/banyand/internal/sidx/block.go
+++ b/banyand/internal/sidx/block.go
@@ -313,7 +313,7 @@ func (b *block) mustWriteTag(tagName string, td *tagData, bm *blockMetadata, ww
 		if len(v) != 8 {
 			return
 		}
-		val := encoding.BytesToInt64(v)
+		val := convert.BytesToInt64(v)
 		if !hasMinMax {
 			minVal = val
 			maxVal = val
@@ -351,8 +351,8 @@ func (b *block) mustWriteTag(tagName string, td *tagData, bm *blockMetadata, ww
 	}
 
 	if td.valueType == pbv1.ValueTypeInt64 && hasMinMax {
-		tm.min = encoding.Int64ToBytes(nil, minVal)
-		tm.max = encoding.Int64ToBytes(nil, maxVal)
+		tm.min = convert.Int64ToBytes(minVal)
+		tm.max = convert.Int64ToBytes(maxVal)
 	}
 	isDictionaryEncoded := encodeType == encoding.EncodeTypeDictionary
 	if !isDictionaryEncoded {
@@ -543,7 +543,7 @@ func fullTagAppend(bi, b *blockPointer, offset int) {
 	}
 }
 
-func assertIdxAndOffset(name string, length int, idx int, offset int) {
+func assertIdxAndOffset(name string, length, idx, offset int) {
 	if idx >= offset {
 		logger.Panicf("%q idx %d must be less than offset %d", name, idx, offset)
 	}
diff --git a/banyand/internal/sidx/block_scanner.go b/banyand/internal/sidx/block_scanner.go
index a7b39daa7..bfa1f7b3c 100644
--- a/banyand/internal/sidx/block_scanner.go
+++ b/banyand/internal/sidx/block_scanner.go
@@ -20,6 +20,7 @@ package sidx
 import (
 	"context"
 	"fmt"
+	"sort"
 
 	"github.com/dustin/go-humanize"
 
@@ -28,9 +29,14 @@ import (
 	"github.com/apache/skywalking-banyandb/pkg/index"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
 	"github.com/apache/skywalking-banyandb/pkg/pool"
+	"github.com/apache/skywalking-banyandb/pkg/query"
 )
 
-const blockScannerBatchSize = 32
+const (
+	blockScannerBatchSize  = 32
+	perBlockSkipTraceLimit = 20
+	skipSummaryTopN        = 3
+)
 
 type blockScanResult struct {
 	p  *part
@@ -72,19 +78,36 @@ func releaseBlockScanResultBatch(bsb *blockScanResultBatch) {
 
 var blockScanResultBatchPool = pool.Register[*blockScanResultBatch]("sidx-blockScannerBatch")
 
+type blockSkipRecorderFunc func(reason string, bm *blockMetadata)
+
+type blockSkipDetail struct {
+	reason   string
+	seriesID common.SeriesID
+	minKey   int64
+	maxKey   int64
+}
+
+type blockSkipStats struct {
+	reasonCounts map[string]int
+	details      []blockSkipDetail
+	totalSkipped int
+}
+
 type scanFinalizer func()
 
 type blockScanner struct {
 	pm         protector.Memory
 	filter     index.Filter
 	l          *logger.Logger
+	span       *query.Span
+	skipStats  *blockSkipStats
 	parts      []*part
 	finalizers []scanFinalizer
 	seriesIDs  []common.SeriesID
 	minKey     int64
 	maxKey     int64
-	asc        bool
 	batchSize  int
+	asc        bool
 }
 
 func (bsn *blockScanner) scan(ctx context.Context, blockCh chan *blockScanResultBatch) {
@@ -96,10 +119,37 @@ func (bsn *blockScanner) scan(ctx context.Context, blockCh chan *blockScanResult
 		return
 	}
 
+	var (
+		totalBlockBytes uint64
+		scannedBlocks   int
+	)
+
+	var skipRecorder blockSkipRecorderFunc
+	if tracer := query.GetTracer(ctx); tracer != nil {
+		span, spanCtx := tracer.StartSpan(ctx, "sidx.scan-blocks")
+		bsn.span = span
+		ctx = spanCtx
+		skipRecorder = bsn.recordSkip
+		span.Tagf("part_count", "%d", len(bsn.parts))
+		span.Tagf("series_id_count", "%d", len(bsn.seriesIDs))
+		span.Tagf("min_key", "%d", bsn.minKey)
+		span.Tagf("max_key", "%d", bsn.maxKey)
+		span.Tagf("ascending", "%t", bsn.asc)
+		span.Tagf("batch_size", "%d", bsn.batchSize)
+		defer func() {
+			if span != nil {
+				span.Tagf("scanned_blocks", "%d", scannedBlocks)
+				span.Tagf("total_block_bytes", "%d", totalBlockBytes)
+				bsn.flushSkipSummaryToSpan()
+				span.Stop()
+			}
+		}()
+	}
+
 	it := generateIter()
 	defer releaseIter(it)
 
-	it.init(bsn.parts, bsn.seriesIDs, bsn.minKey, bsn.maxKey, bsn.filter, bsn.asc)
+	it.init(bsn.parts, bsn.seriesIDs, bsn.minKey, bsn.maxKey, bsn.filter, bsn.asc, skipRecorder)
 
 	batch := generateBlockScanResultBatch()
 	if it.Error() != nil {
@@ -113,7 +163,6 @@ func (bsn *blockScanner) scan(ctx context.Context, blockCh chan *blockScanResult
 		batchThreshold = blockScannerBatchSize
 	}
 
-	var totalBlockBytes uint64
 	for it.nextBlock() {
 		if !bsn.checkContext(ctx) {
 			releaseBlockScanResultBatch(batch)
@@ -141,6 +190,7 @@ func (bsn *blockScanner) scan(ctx context.Context, blockCh chan *blockScanResult
 		// Quota OK, add block to batch
 		bsn.addBlockToBatch(batch, bm, p)
 		totalBlockBytes += blockSize
+		scannedBlocks++
 
 		// Check if batch is full
 		if len(batch.bss) >= batchThreshold || len(batch.bss) >= cap(batch.bss) {
@@ -234,3 +284,79 @@ func (bsn *blockScanner) close() {
 		bsn.finalizers[i]()
 	}
 }
+
+func (bsn *blockScanner) recordSkip(reason string, bm *blockMetadata) {
+	if bsn == nil || reason == "" {
+		return
+	}
+	if bsn.skipStats == nil {
+		bsn.skipStats = &blockSkipStats{
+			reasonCounts: make(map[string]int),
+			details:      make([]blockSkipDetail, 0, perBlockSkipTraceLimit),
+		}
+	}
+	bsn.skipStats.totalSkipped++
+	bsn.skipStats.reasonCounts[reason]++
+	if len(bsn.skipStats.details) >= perBlockSkipTraceLimit || bm == nil {
+		return
+	}
+	bsn.skipStats.details = append(bsn.skipStats.details, blockSkipDetail{
+		reason:   reason,
+		seriesID: bm.seriesID,
+		minKey:   bm.minKey,
+		maxKey:   bm.maxKey,
+	})
+}
+
+func (bsn *blockScanner) flushSkipSummaryToSpan() {
+	if bsn == nil || bsn.span == nil || bsn.skipStats == nil || bsn.skipStats.totalSkipped == 0 {
+		return
+	}
+
+	stats := bsn.skipStats
+	bsn.span.Tagf("skipped_total", "%d", stats.totalSkipped)
+
+	if len(stats.reasonCounts) == 0 {
+		return
+	}
+
+	type reasonCount struct {
+		reason string
+		count  int
+	}
+
+	reasons := make([]reasonCount, 0, len(stats.reasonCounts))
+	for reason, count := range stats.reasonCounts {
+		reasons = append(reasons, reasonCount{
+			reason: reason,
+			count:  count,
+		})
+	}
+
+	sort.Slice(reasons, func(i, j int) bool {
+		return reasons[i].count > reasons[j].count
+	})
+
+	otherCount := 0
+	for i, rc := range reasons {
+		if i < skipSummaryTopN {
+			idx := i + 1
+			bsn.span.Tag(fmt.Sprintf("skipped_reason_%d", idx), rc.reason)
+			bsn.span.Tagf(fmt.Sprintf("skipped_reason_%d_count", idx), "%d", rc.count)
+			continue
+		}
+		otherCount += rc.count
+	}
+
+	if otherCount > 0 && len(reasons) > skipSummaryTopN {
+		bsn.span.Tagf("skipped_other_reason_count", "%d", otherCount)
+	}
+
+	for i, detail := range stats.details {
+		prefix := fmt.Sprintf("skip_%d_", i)
+		bsn.span.Tag(prefix+"reason", detail.reason)
+		bsn.span.Tagf(prefix+"series_id", "%d", detail.seriesID)
+		bsn.span.Tagf(prefix+"min_key", "%d", detail.minKey)
+		bsn.span.Tagf(prefix+"max_key", "%d", detail.maxKey)
+	}
+}
diff --git a/banyand/internal/sidx/block_scanner_test.go b/banyand/internal/sidx/block_scanner_test.go
index 1e9a4fca1..b30f80bc1 100644
--- a/banyand/internal/sidx/block_scanner_test.go
+++ b/banyand/internal/sidx/block_scanner_test.go
@@ -20,6 +20,7 @@ package sidx
 import (
 	"context"
 	"errors"
+	"strconv"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -30,6 +31,7 @@ import (
 	"github.com/apache/skywalking-banyandb/pkg/index"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
 	pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+	"github.com/apache/skywalking-banyandb/pkg/query"
 )
 
 func TestBlockScannerStructures(t *testing.T) {
@@ -453,3 +455,98 @@ func TestBlockScanner_BatchSizeHandling(t *testing.T) {
 
 	scanner.close()
 }
+
+func TestBlockScanner_SpanRecordsSkippedBlocks(t *testing.T) {
+	// Build a simple in-memory part with data so that iter produces at least one block,
+	// then apply a block filter that skips all blocks and verify the scan-blocks span
+	// records the skip reason and summary tags.
+	testElements := []testElement{
+		{
+			seriesID: common.SeriesID(1),
+			userKey:  100,
+			data:     []byte("data1"),
+		},
+		{
+			seriesID: common.SeriesID(1),
+			userKey:  200,
+			data:     []byte("data2"),
+		},
+	}
+
+	elements := createTestElements(testElements)
+	defer releaseElements(elements)
+
+	mp := GenerateMemPart()
+	defer ReleaseMemPart(mp)
+	mp.mustInitFromElements(elements)
+
+	testPart := openMemPart(mp)
+	defer testPart.close()
+
+	// Block filter that skips every candidate block.
+	skipAllFilter := &mockBlockFilter{shouldSkip: true}
+
+	scanner := &blockScanner{
+		pm:        &test.MockMemoryProtector{ExpectQuotaExceeded: false},
+		l:         logger.GetLogger(),
+		parts:     []*part{testPart},
+		seriesIDs: []common.SeriesID{1},
+		minKey:    0,
+		maxKey:    1000,
+		filter:    skipAllFilter,
+		asc:       true,
+	}
+
+	// Attach a tracer to the context so blockScanner.scan creates the sidx.scan-blocks span.
+	baseCtx := context.Background()
+	tracer, tracedCtx := query.NewTracer(baseCtx, "test-trace-skip-blocks")
+	require.NotNil(t, tracer)
+
+	blockCh := make(chan *blockScanResultBatch, 4)
+
+	go func() {
+		defer close(blockCh)
+		scanner.scan(tracedCtx, blockCh)
+	}()
+
+	for batch := range blockCh {
+		if batch != nil {
+			releaseBlockScanResultBatch(batch)
+		}
+	}
+
+	scanner.close()
+
+	trace := tracer.ToProto()
+	require.NotNil(t, trace)
+
+	var scanSpanTags map[string]string
+	for _, span := range trace.Spans {
+		if span.GetMessage() != "sidx.scan-blocks" {
+			continue
+		}
+		scanSpanTags = make(map[string]string, len(span.Tags))
+		for _, tag := range span.Tags {
+			if tag == nil {
+				continue
+			}
+			scanSpanTags[tag.Key] = tag.Value
+		}
+		break
+	}
+
+	require.NotNil(t, scanSpanTags, "expected sidx.scan-blocks span to be present")
+
+	// Verify that total skipped blocks is recorded.
+	skippedTotal, ok := scanSpanTags["skipped_total"]
+	require.True(t, ok, "skipped_total tag must be present on scan-blocks span")
+	parsedTotal, err := strconv.Atoi(skippedTotal)
+	require.NoError(t, err, "skipped_total must be an integer")
+	require.Greater(t, parsedTotal, 0, "skipped_total should be greater than 0")
+
+	// Verify that the dominant skip reason is captured and summarized.
+	require.Equal(t, "block_filter_should_skip", scanSpanTags["skipped_reason_1"])
+
+	// Verify that at least the first skipped block's detail is recorded.
+	require.Equal(t, "block_filter_should_skip", scanSpanTags["skip_0_reason"])
+}
diff --git a/banyand/internal/sidx/interfaces.go b/banyand/internal/sidx/interfaces.go
index 5c4a88219..d0c3e5d50 100644
--- a/banyand/internal/sidx/interfaces.go
+++ b/banyand/internal/sidx/interfaces.go
@@ -123,13 +123,11 @@ type ScanProgressFunc func(currentPart, totalParts int, rowsFound int)
 //nolint:govet // struct layout optimized for readability; 64 bytes is acceptable
 type ScanQueryRequest struct {
 	TagFilter     model.TagFilterMatcher
-	TagProjection []model.TagProjection
 	OnProgress    ScanProgressFunc
 	MinKey        *int64
 	MaxKey        *int64
+	TagProjection []model.TagProjection
 	MaxBatchSize  int
-	// OnProgress is an optional callback for progress reporting during scan.
-	// Called after processing each part with the current progress.
 }
 
 // QueryResponse contains a batch of query results and execution metadata.
diff --git a/banyand/internal/sidx/iter.go b/banyand/internal/sidx/iter.go
index e48bd4a60..8e5665fb3 100644
--- a/banyand/internal/sidx/iter.go
+++ b/banyand/internal/sidx/iter.go
@@ -71,7 +71,7 @@ func (it *iter) reset() {
 	it.asc = false
 }
 
-func (it *iter) init(parts []*part, sids []common.SeriesID, minKey, maxKey int64, blockFilter index.Filter, asc bool) {
+func (it *iter) init(parts []*part, sids []common.SeriesID, minKey, maxKey int64, blockFilter index.Filter, asc bool, skipRecorder blockSkipRecorderFunc) {
 	it.reset()
 	it.parts = append(it.parts[:0], parts...)
 	it.asc = asc
@@ -94,7 +94,7 @@ func (it *iter) init(parts []*part, sids []common.SeriesID, minKey, maxKey int64
 		pki := generatePartKeyIter()
 		it.partIters[i] = pki
 
-		pki.init(p, sids, minKey, maxKey, blockFilter, asc)
+		pki.init(p, sids, minKey, maxKey, blockFilter, asc, skipRecorder)
 		if err := pki.error(); err != nil {
 			if !errors.Is(err, io.EOF) {
 				releasePartKeyIter(pki)
diff --git a/banyand/internal/sidx/iter_test.go b/banyand/internal/sidx/iter_test.go
index fe4388ff3..b170549dd 100644
--- a/banyand/internal/sidx/iter_test.go
+++ b/banyand/internal/sidx/iter_test.go
@@ -270,7 +270,7 @@ func TestIterEdgeCases(t *testing.T) {
 	t.Run("empty_parts_list", func(t *testing.T) {
 		for _, asc := range []bool{true, false} {
 			it := generateIter()
-			it.init(nil, []common.SeriesID{1, 2, 3}, 100, 200, nil, asc)
+			it.init(nil, []common.SeriesID{1, 2, 3}, 100, 200, nil, asc, nil)
 			assert.False(t, it.nextBlock())
 			assert.Nil(t, it.Error())
 			releaseIter(it)
@@ -295,7 +295,7 @@ func TestIterEdgeCases(t *testing.T) {
 
 		for _, asc := range []bool{true, false} {
 			it := generateIter()
-			it.init([]*part{testPart}, []common.SeriesID{}, 0, 1000, nil, asc)
+			it.init([]*part{testPart}, []common.SeriesID{}, 0, 1000, nil, asc, nil)
 			assert.False(t, it.nextBlock())
 			assert.Nil(t, it.Error())
 			releaseIter(it)
@@ -335,7 +335,7 @@ func TestIterEdgeCases(t *testing.T) {
 		for _, asc := range []bool{true, false} {
 			it := generateIter()
 			// Query range that doesn't overlap with any blocks
-			it.init([]*part{testPart1, testPart2}, []common.SeriesID{1, 2}, 400, 500, nil, asc)
+			it.init([]*part{testPart1, testPart2}, []common.SeriesID{1, 2}, 400, 500, nil, asc, nil)
 			assert.False(t, it.nextBlock())
 			assert.Nil(t, it.Error())
 			releaseIter(it)
@@ -359,7 +359,7 @@ func TestIterEdgeCases(t *testing.T) {
 
 		for _, asc := range []bool{true, false} {
 			it := generateIter()
-			it.init([]*part{testPart}, []common.SeriesID{1}, 50, 150, nil, asc)
+			it.init([]*part{testPart}, []common.SeriesID{1}, 50, 150, nil, asc, nil)
 
 			assert.True(t, it.nextBlock())
 			assert.False(t, it.nextBlock()) // Should be only one block
@@ -386,7 +386,7 @@ func TestIterEdgeCases(t *testing.T) {
 
 		for _, asc := range []bool{true, false} {
 			it := generateIter()
-			it.init([]*part{testPart}, []common.SeriesID{1}, 0, 200, mockFilter, asc)
+			it.init([]*part{testPart}, []common.SeriesID{1}, 0, 200, mockFilter, asc, nil)
 
 			assert.False(t, it.nextBlock())
 			assert.Error(t, it.Error())
@@ -558,7 +558,7 @@ func TestIterOverlappingBlockGroups(t *testing.T) {
 
 	for _, asc := range []bool{true, false} {
 		it := generateIter()
-		it.init([]*part{part1, part2}, []common.SeriesID{1, 2}, 0, 500, nil, asc)
+		it.init([]*part{part1, part2}, []common.SeriesID{1, 2}, 0, 500, nil, asc, nil)
 
 		// Now we iterate individual blocks from both parts
 		partsSeen := make(map[*part]struct{})
@@ -603,7 +603,7 @@ func runIteratorPass(t *testing.T, tc iterTestCase, parts []*part, asc bool) []b
 	it := generateIter()
 	defer releaseIter(it)
 
-	it.init(parts, tc.querySids, tc.minKey, tc.maxKey, tc.blockFilter, asc)
+	it.init(parts, tc.querySids, tc.minKey, tc.maxKey, tc.blockFilter, asc, nil)
 
 	var foundBlocks []blockExpectation
 
diff --git a/banyand/internal/sidx/part_key_iter.go b/banyand/internal/sidx/part_key_iter.go
index edb7de9dc..8a894df24 100644
--- a/banyand/internal/sidx/part_key_iter.go
+++ b/banyand/internal/sidx/part_key_iter.go
@@ -130,7 +130,16 @@ func (sc *seriesCursor) advance() (bool, error) {
 			return false, fmt.Errorf("block index %d out of range for primary %d", ref.blockIdx, ref.primaryIdx)
 		}
 		bm := &bma.arr[ref.blockIdx]
-		if bm.maxKey < sc.iter.minKey || bm.minKey > sc.iter.maxKey {
+		if bm.maxKey < sc.iter.minKey {
+			if sc.iter != nil {
+				sc.iter.recordBlockSkip("block_out_of_key_range_before_min_key", bm)
+			}
+			continue
+		}
+		if bm.minKey > sc.iter.maxKey {
+			if sc.iter != nil {
+				sc.iter.recordBlockSkip("block_out_of_key_range_after_max_key", bm)
+			}
 			continue
 		}
 		if sc.iter.blockFilter != nil {
@@ -139,6 +148,9 @@ func (sc *seriesCursor) advance() (bool, error) {
 				return false, err
 			}
 			if shouldSkip {
+				if sc.iter != nil {
+					sc.iter.recordBlockSkip("block_filter_should_skip", bm)
+				}
 				continue
 			}
 		}
@@ -184,17 +196,18 @@ func (sch *seriesCursorHeap) Pop() any {
 }
 
 type partKeyIter struct {
-	err                  error
 	blockFilter          index.Filter
+	err                  error
+	recordSkip           blockSkipRecorderFunc
 	sidSet               map[common.SeriesID]struct{}
 	p                    *part
 	primaryCache         map[int]*blockMetadataArray
 	curBlock             *blockMetadata
 	cursorPool           []seriesCursor
-	cursorHeap           seriesCursorHeap
 	sids                 []common.SeriesID
 	primaryBuf           []byte
 	compressedPrimaryBuf []byte
+	cursorHeap           seriesCursorHeap
 	minKey               int64
 	maxKey               int64
 	asc                  bool
@@ -243,15 +256,17 @@ func (pki *partKeyIter) reset() {
 
 	pki.compressedPrimaryBuf = pki.compressedPrimaryBuf[:0]
 	pki.primaryBuf = pki.primaryBuf[:0]
+	pki.recordSkip = nil
 }
 
-func (pki *partKeyIter) init(p *part, sids []common.SeriesID, minKey, maxKey int64, blockFilter index.Filter, asc bool) {
+func (pki *partKeyIter) init(p *part, sids []common.SeriesID, minKey, maxKey int64, blockFilter index.Filter, asc bool, skipRecorder blockSkipRecorderFunc) {
 	pki.reset()
 	pki.p = p
 	pki.minKey = minKey
 	pki.maxKey = maxKey
 	pki.blockFilter = blockFilter
 	pki.asc = asc
+	pki.recordSkip = skipRecorder
 
 	if len(sids) == 0 {
 		pki.err = io.EOF
@@ -466,6 +481,13 @@ func (pki *partKeyIter) shouldSkipBlock(bm *blockMetadata) (bool, error) {
 	return pki.blockFilter.ShouldSkip(tfo)
 }
 
+func (pki *partKeyIter) recordBlockSkip(reason string, bm *blockMetadata) {
+	if pki == nil || pki.recordSkip == nil {
+		return
+	}
+	pki.recordSkip(reason, bm)
+}
+
 func (pki *partKeyIter) current() (*blockMetadata, *part) {
 	return pki.curBlock, pki.p
 }
diff --git a/banyand/internal/sidx/part_key_iter_test.go b/banyand/internal/sidx/part_key_iter_test.go
index f31d75007..67ce1bd08 100644
--- a/banyand/internal/sidx/part_key_iter_test.go
+++ b/banyand/internal/sidx/part_key_iter_test.go
@@ -58,7 +58,7 @@ func runPartKeyIterPass(t *testing.T, part *part, sids []common.SeriesID, minKey
 	iter := generatePartKeyIter()
 	defer releasePartKeyIter(iter)
 
-	iter.init(part, sids, minKey, maxKey, blockFilter, asc)
+	iter.init(part, sids, minKey, maxKey, blockFilter, asc, nil)
 
 	var results []blockExpectation
 	for iter.nextBlock() {
@@ -261,7 +261,7 @@ func TestPartKeyIterGroupsOverlappingRanges(t *testing.T) {
 
 	iter := generatePartKeyIter()
 	defer releasePartKeyIter(iter)
-	iter.init(part, []common.SeriesID{1, 2, 3}, 0, 500, nil, true)
+	iter.init(part, []common.SeriesID{1, 2, 3}, 0, 500, nil, true, nil)
 	var ids []common.SeriesID
 	for iter.nextBlock() {
 		block, _ := iter.current()
@@ -372,7 +372,7 @@ func TestPartKeyIterExhaustion(t *testing.T) {
 			iter := generatePartKeyIter()
 			defer releasePartKeyIter(iter)
 
-			iter.init(part, []common.SeriesID{1, 2}, 0, 200, nil, asc)
+			iter.init(part, []common.SeriesID{1, 2}, 0, 200, nil, asc, nil)
 
 			blockCount := 0
 			for iter.nextBlock() {
@@ -450,7 +450,7 @@ func TestPartKeyIterRequeuesOnGapBetweenBlocks(t *testing.T) {
 			iter := generatePartKeyIter()
 			defer releasePartKeyIter(iter)
 
-			iter.init(part, []common.SeriesID{1}, 0, 100000, nil, asc)
+			iter.init(part, []common.SeriesID{1}, 0, 100000, nil, asc, nil)
 
 			var blocks []struct {
 				min int64
diff --git a/banyand/internal/sidx/tag_filter_op.go b/banyand/internal/sidx/tag_filter_op.go
index 638624143..301e6923d 100644
--- a/banyand/internal/sidx/tag_filter_op.go
+++ b/banyand/internal/sidx/tag_filter_op.go
@@ -21,6 +21,9 @@ import (
 	"bytes"
 	"fmt"
 
+	"github.com/blugelabs/bluge/numeric"
+
+	"github.com/apache/skywalking-banyandb/pkg/convert"
 	"github.com/apache/skywalking-banyandb/pkg/encoding"
 	"github.com/apache/skywalking-banyandb/pkg/filter"
 	"github.com/apache/skywalking-banyandb/pkg/fs"
@@ -148,7 +151,7 @@ func (tfo *tagFilterOp) Range(tagName string, rangeOpts index.RangeOpts) (bool,
 
 	// Only perform range check for numeric types with min/max values
 	if cache.valueType != pbv1.ValueTypeInt64 || len(cache.min) == 0 || len(cache.max) == 0 {
-		return true, nil // Conservative approach for non-numeric or missing min/max
+		return false, nil // Conservative approach for non-numeric or missing min/max - don't skip
 	}
 
 	// Check lower bound
@@ -157,10 +160,9 @@ func (tfo *tagFilterOp) Range(tagName string, rangeOpts index.RangeOpts) (bool,
 		if !ok {
 			return false, fmt.Errorf("lower bound is not a float value: %v", rangeOpts.Lower)
 		}
-		value := make([]byte, 0)
-		value = encoding.Int64ToBytes(value, int64(lower.Value))
+		value := convert.Int64ToBytes(numeric.Float64ToInt64(lower.Value))
 		if bytes.Compare(cache.max, value) == -1 || (!rangeOpts.IncludesLower && bytes.Equal(cache.max, value)) {
-			return false, nil
+			return true, nil
 		}
 	}
 
@@ -170,14 +172,13 @@ func (tfo *tagFilterOp) Range(tagName string, rangeOpts index.RangeOpts) (bool,
 		if !ok {
 			return false, fmt.Errorf("upper bound is not a float value: %v", rangeOpts.Upper)
 		}
-		value := make([]byte, 0)
-		value = encoding.Int64ToBytes(value, int64(upper.Value))
+		value := convert.Int64ToBytes(numeric.Float64ToInt64(upper.Value))
 		if bytes.Compare(cache.min, value) == 1 || (!rangeOpts.IncludesUpper && bytes.Equal(cache.min, value)) {
-			return false, nil
+			return true, nil
 		}
 	}
 
-	return true, nil
+	return false, nil
 }
 
 // getTagFilterCache retrieves or creates cached tag filter data.
diff --git a/banyand/internal/sidx/tag_filter_op_test.go b/banyand/internal/sidx/tag_filter_op_test.go
index 8357e9fc6..4a3e55213 100644
--- a/banyand/internal/sidx/tag_filter_op_test.go
+++ b/banyand/internal/sidx/tag_filter_op_test.go
@@ -22,9 +22,11 @@ import (
 	"fmt"
 	"testing"
 
+	"github.com/blugelabs/bluge/numeric"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/apache/skywalking-banyandb/pkg/convert"
 	"github.com/apache/skywalking-banyandb/pkg/encoding"
 	"github.com/apache/skywalking-banyandb/pkg/filter"
 	"github.com/apache/skywalking-banyandb/pkg/index"
@@ -225,11 +227,11 @@ func TestTagFilterOpRange(t *testing.T) {
 }
 
 func TestTagFilterOpRangeWithCache(t *testing.T) {
-	// Create a cache with numeric data
+	// Create a cache with numeric data using convert.Int64ToBytes (order-preserving encoding)
 	cache := &tagFilterCache{
 		valueType: pbv1.ValueTypeInt64,
-		min:       encoding.Int64ToBytes(nil, 100),
-		max:       encoding.Int64ToBytes(nil, 500),
+		min:       convert.Int64ToBytes(100),
+		max:       convert.Int64ToBytes(500),
 	}
 
 	tfo := &tagFilterOp{
@@ -252,72 +254,73 @@ func TestTagFilterOpRangeWithCache(t *testing.T) {
 		expectError    bool
 	}{
 		{
-			name: "range completely below",
-			rangeOpts: index.RangeOpts{
-				Lower:         &index.FloatTermValue{Value: 10},
-				Upper:         &index.FloatTermValue{Value: 50},
-				IncludesLower: true,
-				IncludesUpper: true,
-			},
+			name:           "range completely below",
+			rangeOpts:      index.NewIntRangeOpts(10, 50, true, true),
+			expectedResult: true,
+			expectError:    false,
+			description:    "should skip when range is completely below min",
+		},
+		{
+			name:           "range completely above",
+			rangeOpts:      index.NewIntRangeOpts(600, 800, true, true),
+			expectedResult: true,
+			expectError:    false,
+			description:    "should skip when range is completely above max",
+		},
+		{
+			name:           "range overlaps",
+			rangeOpts:      index.NewIntRangeOpts(200, 300, true, true),
 			expectedResult: false,
 			expectError:    false,
-			description:    "should return false when range is completely below min",
+			description:    "should not skip when range overlaps with min/max",
 		},
 		{
-			name: "range completely above",
-			rangeOpts: index.RangeOpts{
-				Lower:         &index.FloatTermValue{Value: 600},
-				Upper:         &index.FloatTermValue{Value: 800},
-				IncludesLower: true,
-				IncludesUpper: true,
-			},
+			name:           "range contains all",
+			rangeOpts:      index.NewIntRangeOpts(50, 600, true, true),
 			expectedResult: false,
 			expectError:    false,
-			description:    "should return false when range is completely above max",
+			description:    "should not skip when range contains all values",
 		},
 		{
-			name: "range overlaps",
+			name: "lower boundary exclusive miss",
 			rangeOpts: index.RangeOpts{
-				Lower:         &index.FloatTermValue{Value: 200},
-				Upper:         &index.FloatTermValue{Value: 300},
-				IncludesLower: true,
-				IncludesUpper: true,
+				Lower:         &index.FloatTermValue{Value: numeric.Int64ToFloat64(500)},
+				IncludesLower: false,
 			},
 			expectedResult: true,
 			expectError:    false,
-			description:    "should return true when range overlaps with min/max",
+			description:    "should skip when lower boundary is exclusive and equals max",
 		},
 		{
-			name: "range contains all",
+			name: "upper boundary exclusive miss",
 			rangeOpts: index.RangeOpts{
-				Lower:         &index.FloatTermValue{Value: 50},
-				Upper:         &index.FloatTermValue{Value: 600},
-				IncludesLower: true,
-				IncludesUpper: true,
+				Upper:         &index.FloatTermValue{Value: numeric.Int64ToFloat64(100)},
+				IncludesUpper: false,
 			},
 			expectedResult: true,
 			expectError:    false,
-			description:    "should return true when range contains all values",
+			description:    "should skip when upper boundary is exclusive and equals min",
 		},
 		{
-			name: "lower boundary exclusive miss",
-			rangeOpts: index.RangeOpts{
-				Lower:         &index.FloatTermValue{Value: 500},
-				IncludesLower: false,
-			},
+			name:           "inclusive lower equals block max",
+			rangeOpts:      index.NewIntRangeOpts(500, 600, true, true),
 			expectedResult: false,
 			expectError:    false,
-			description:    "should return false when lower boundary is exclusive and equals max",
+			description:    "should not skip when lower is inclusive and equals max",
 		},
 		{
-			name: "upper boundary exclusive miss",
-			rangeOpts: index.RangeOpts{
-				Upper:         &index.FloatTermValue{Value: 100},
-				IncludesUpper: false,
-			},
+			name:           "inclusive upper equals block min",
+			rangeOpts:      index.NewIntRangeOpts(50, 100, true, true),
 			expectedResult: false,
 			expectError:    false,
-			description:    "should return false when upper boundary is exclusive and equals min",
+			description:    "should not skip when upper is inclusive and equals min",
+		},
+		{
+			name:           "query above block max - adjacent exclusive",
+			rangeOpts:      index.NewIntRangeOpts(200, 300, false, true),
+			expectedResult: false,
+			expectError:    false,
+			description:    "should not skip when exclusive lower (200) is within block [100,500]",
 		},
 	}
 
@@ -356,16 +359,11 @@ func TestTagFilterOpRangeNonNumeric(t *testing.T) {
 		},
 	}
 
-	// For non-numeric types, should always return true (conservative approach)
-	result, err := tfo.Range("service", index.RangeOpts{
-		Lower:         &index.FloatTermValue{Value: 100},
-		Upper:         &index.FloatTermValue{Value: 200},
-		IncludesLower: true,
-		IncludesUpper: true,
-	})
+	// For non-numeric types, should not skip (conservative approach - return false)
+	result, err := tfo.Range("service", index.NewIntRangeOpts(100, 200, true, true))
 
 	assert.NoError(t, err)
-	assert.True(t, result, "should return true for non-numeric types")
+	assert.False(t, result, "should not skip for non-numeric types (conservative approach)")
 }
 
 func TestDecodeBloomFilterFromBytes(t *testing.T) {
@@ -440,8 +438,8 @@ func BenchmarkTagFilterOpRange(b *testing.B) {
 	// Setup
 	cache := &tagFilterCache{
 		valueType: pbv1.ValueTypeInt64,
-		min:       encoding.Int64ToBytes(nil, 100),
-		max:       encoding.Int64ToBytes(nil, 500),
+		min:       convert.Int64ToBytes(100),
+		max:       convert.Int64ToBytes(500),
 	}
 
 	tfo := &tagFilterOp{
@@ -456,12 +454,7 @@ func BenchmarkTagFilterOpRange(b *testing.B) {
 		},
 	}
 
-	rangeOpts := index.RangeOpts{
-		Lower:         &index.FloatTermValue{Value: 200},
-		Upper:         &index.FloatTermValue{Value: 300},
-		IncludesLower: true,
-		IncludesUpper: true,
-	}
+	rangeOpts := index.NewIntRangeOpts(200, 300, true, true)
 
 	b.ResetTimer()
 
@@ -821,8 +814,8 @@ func TestTagFilterOpErrorHandling(t *testing.T) {
 	t.Run("invalid range bounds", func(t *testing.T) {
 		cache := &tagFilterCache{
 			valueType: pbv1.ValueTypeInt64,
-			min:       encoding.Int64ToBytes(nil, 100),
-			max:       encoding.Int64ToBytes(nil, 500),
+			min:       convert.Int64ToBytes(100),
+			max:       convert.Int64ToBytes(500),
 		}
 
 		tfo := &tagFilterOp{
diff --git a/test/cases/trace/data/input/duration_range_and_ipv4_order_timestamp.ql b/test/cases/trace/data/input/duration_range_and_ipv4_order_timestamp.ql
new file mode 100644
index 000000000..b5684c4f6
--- /dev/null
+++ b/test/cases/trace/data/input/duration_range_and_ipv4_order_timestamp.ql
@@ -0,0 +1,23 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+SELECT () FROM TRACE zipkin IN zipkinTrace
+TIME > '-1h'
+WHERE duration >= 100 AND duration <= 1000 AND local_endpoint_ipv4 = '192.168.1.10'
+ORDER BY zipkin-timestamp DESC
+LIMIT 10
diff --git a/test/cases/trace/data/input/duration_range_and_ipv4_order_timestamp.yml b/test/cases/trace/data/input/duration_range_and_ipv4_order_timestamp.yml
new file mode 100644
index 000000000..19f0d8c7c
--- /dev/null
+++ b/test/cases/trace/data/input/duration_range_and_ipv4_order_timestamp.yml
@@ -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.
+
+name: "zipkin"
+groups: ["zipkinTrace"]
+limit: 10
+order_by:
+  index_rule_name: "zipkin-timestamp"
+  sort: "SORT_DESC"
+criteria:
+  le:
+    op: "LOGICAL_OP_AND"
+    left:
+      le:
+        op: "LOGICAL_OP_AND"
+        left:
+          condition:
+            name: "duration"
+            op: "BINARY_OP_GE"
+            value:
+              int:
+                value: 100
+        right:
+          condition:
+            name: "duration"
+            op: "BINARY_OP_LE"
+            value:
+              int:
+                value: 1000
+    right:
+      condition:
+        name: "local_endpoint_ipv4"
+        op: "BINARY_OP_EQ"
+        value:
+          str:
+            value: "192.168.1.10"
diff --git a/test/cases/trace/data/want/duration_range_and_ipv4_order_timestamp.yml b/test/cases/trace/data/want/duration_range_and_ipv4_order_timestamp.yml
new file mode 100644
index 000000000..40c9a153f
--- /dev/null
+++ b/test/cases/trace/data/want/duration_range_and_ipv4_order_timestamp.yml
@@ -0,0 +1,24 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+traces:
+  - spans:
+    - span: zipkin_trace_001_span_001
+      spanId: span_001
+    - span: zipkin_trace_001_span_002
+      spanId: span_002
+    traceId: zipkin_trace_001
diff --git a/test/cases/trace/trace.go b/test/cases/trace/trace.go
index cbe61d32c..c57ab8656 100644
--- a/test/cases/trace/trace.go
+++ b/test/cases/trace/trace.go
@@ -49,6 +49,8 @@ var _ = g.DescribeTable("Scanning Traces", func(args helpers.Args) {
 	g.Entry("order by duration", helpers.Args{Input: "order_duration_desc", Duration: 1 * time.Hour}),
 	g.Entry("duration range 10-1000 order by timestamp",
 		helpers.Args{Input: "duration_range_order_timestamp", Duration: 1 * time.Hour}),
+	g.Entry("duration range and ipv4 filter order by timestamp",
+		helpers.Args{Input: "duration_range_and_ipv4_order_timestamp", Duration: 1 * time.Hour}),
 	g.Entry("filter by service id", helpers.Args{Input: "eq_service_order_timestamp_desc", Duration: 1 * time.Hour}),
 	g.Entry("filter by service instance id", helpers.Args{Input: "eq_service_instance_order_time_asc", Duration: 1 * time.Hour}),
 	g.Entry("filter by endpoint", helpers.Args{Input: "eq_endpoint_order_duration_asc", Duration: 1 * time.Hour}),