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 28cd2a7a2 Support displaying a measure's indexed tags in the dump tool 
(#1143)
28cd2a7a2 is described below

commit 28cd2a7a214f9526e7ae58fe21a3b124c4d617e1
Author: mrproliu <[email protected]>
AuthorDate: Thu May 28 20:46:24 2026 +0800

    Support displaying a measure's indexed tags in the dump tool (#1143)
    
    * Support displaying a measure's indexed tags in the dump tool
---
 CHANGES.md                                  |   1 +
 banyand/cmd/dump/measure.go                 |  84 +++++--
 banyand/internal/dump/index_resolver.go     | 202 ++++++++++++++++
 banyand/internal/dump/measure/iterator.go   |  28 +++
 banyand/internal/dump/measure/measure.go    |   7 +
 banyand/internal/dump/measure/suite_test.go | 357 +++++++++++++++++++++++++++-
 pkg/index/index.go                          |   5 +
 pkg/index/inverted/inverted_series.go       |  53 +++++
 pkg/index/inverted/inverted_series_test.go  |  66 +++++
 9 files changed, 780 insertions(+), 23 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index b86953289..abcd47d66 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -57,6 +57,7 @@ Release Notes.
   - Fix the `modRevision` contract on no-op Update RPCs 
(`MeasureRegistryService.Update`, etc.). Previously `updateResource` detected 
unchanged content via `CheckerMap` and short-circuited without writing to the 
property store, but the caller had already fabricated `modRevision = 
time.Now().UnixNano()` and returned it. The returned revision never appeared in 
the property watch stream, so `AwaitRevisionApplied(R)` would hang. 
`updateResource` now returns `(int64, error)` — the existing pr [...]
   - Add end-to-end observability for liaison internal queue pipelines with 
per-topic metrics for queue_sub and queue_pub, along with Grafana panels and 
troubleshooting docs.
   - Introduce measure migration tool.
+- Support displaying a measure's indexed tags in the dump tool, resolved per 
part so peak memory is bounded by the part rather than a segment-wide series 
map.
 
 ### Bug Fixes
 
diff --git a/banyand/cmd/dump/measure.go b/banyand/cmd/dump/measure.go
index 252299123..1bcd430c6 100644
--- a/banyand/cmd/dump/measure.go
+++ b/banyand/cmd/dump/measure.go
@@ -27,7 +27,6 @@ import (
        "github.com/spf13/cobra"
        "google.golang.org/protobuf/encoding/protojson"
 
-       "github.com/apache/skywalking-banyandb/api/common"
        databasev1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
        modelv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
        "github.com/apache/skywalking-banyandb/banyand/internal/dump"
@@ -136,7 +135,7 @@ func dumpMeasureShard(opts measureDumpOptions) error {
 type measureDumpContext struct {
        tagFilter        logical.TagFilter
        fileSystem       fs.FileSystem
-       seriesMap        map[common.SeriesID]string
+       indexResolver    *dump.IndexResolver
        writer           *csv.Writer
        opts             measureDumpOptions
        partIDs          []uint64
@@ -164,12 +163,13 @@ func newMeasureDumpContext(opts measureDumpOptions) 
(*measureDumpContext, error)
        ctx.partIDs = partIDs
        fmt.Fprintf(os.Stderr, "Found %d parts in shard\n", len(partIDs))
 
-       ctx.seriesMap, err = dump.LoadSegmentSeriesMap(opts.segmentPath)
+       // The offline CLI has no schema, so it resolves indexed tags by 
IndexRuleID
+       // (ruleToTag is nil). EntityValues come from each part on demand, so no
+       // segment-wide series map is loaded.
+       ctx.indexResolver, err = dump.NewIndexResolver(opts.segmentPath, 
dump.DefaultIndexCacheSize, nil)
        if err != nil {
-               fmt.Fprintf(os.Stderr, "Warning: Failed to load series 
information: %v\n", err)
-               ctx.seriesMap = nil
-       } else {
-               fmt.Fprintf(os.Stderr, "Loaded %d series from segment\n", 
len(ctx.seriesMap))
+               fmt.Fprintf(os.Stderr, "Warning: Failed to open index resolver 
(indexed tags unavailable): %v\n", err)
+               ctx.indexResolver = nil
        }
 
        if opts.criteriaJSON != "" {
@@ -237,7 +237,7 @@ func (ctx *measureDumpContext) initOutput() error {
        }
 
        ctx.writer = csv.NewWriter(os.Stdout)
-       header := []string{"PartID", "Timestamp", "Version", "SeriesID", 
"Series"}
+       header := []string{"PartID", "Timestamp", "Version", "SeriesID", 
"Series", "IndexedTags"}
        header = append(header, ctx.fieldColumns...)
        header = append(header, ctx.tagColumns...)
        if err := ctx.writer.Write(header); err != nil {
@@ -250,6 +250,9 @@ func (ctx *measureDumpContext) close() {
        if ctx.writer != nil {
                ctx.writer.Flush()
        }
+       if ctx.indexResolver != nil {
+               _ = ctx.indexResolver.Close()
+       }
 }
 
 func (ctx *measureDumpContext) processParts() error {
@@ -261,6 +264,9 @@ func (ctx *measureDumpContext) processParts() error {
                        fmt.Fprintf(os.Stderr, "Warning: failed to open part 
%016x: %v\n", partID, err)
                        continue
                }
+               if ctx.indexResolver != nil {
+                       reader.SetIndexResolver(ctx.indexResolver)
+               }
 
                partRowCount := 0
                it := reader.Iterator()
@@ -317,11 +323,11 @@ func (ctx *measureDumpContext) shouldSkip(tags 
map[string][]byte) bool {
 
 func (ctx *measureDumpContext) writeRow(partID uint64, row dumpmeasure.Row) 
error {
        if ctx.opts.csvOutput {
-               if err := writeMeasureRowAsCSV(ctx.writer, partID, row, 
ctx.fieldColumns, ctx.tagColumns, ctx.seriesMap); err != nil {
+               if err := writeMeasureRowAsCSV(ctx.writer, partID, row, 
ctx.fieldColumns, ctx.tagColumns); err != nil {
                        return err
                }
        } else {
-               writeMeasureRowAsText(partID, row, ctx.rowNum+1, 
ctx.projectionTags, ctx.projectionFields, ctx.seriesMap)
+               writeMeasureRowAsText(partID, row, ctx.rowNum+1, 
ctx.projectionTags, ctx.projectionFields)
        }
        ctx.rowNum++
        return nil
@@ -360,16 +366,46 @@ func parseMeasureProjectionTags(projectionStr string) 
[]string {
        return result
 }
 
-func measureSeriesText(row dumpmeasure.Row, seriesMap 
map[common.SeriesID]string) string {
-       if len(row.EntityValues) > 0 {
-               return string(row.EntityValues)
+func measureSeriesText(row dumpmeasure.Row) string {
+       return string(row.EntityValues)
+}
+
+func sortedRuleIDs(m map[uint32][][]byte) []uint32 {
+       ids := make([]uint32, 0, len(m))
+       for id := range m {
+               ids = append(ids, id)
        }
-       if seriesMap != nil {
-               if text, ok := seriesMap[row.SeriesID]; ok {
-                       return text
+       sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+       return ids
+}
+
+// formatIndexedTagValues renders one IndexRuleID's values (several for an 
array
+// tag). The index does not store the value type, so values are shown as text
+// when printable and as a byte size otherwise.
+func formatIndexedTagValues(values [][]byte) string {
+       parts := make([]string, 0, len(values))
+       for _, v := range values {
+               if isPrintable(v) {
+                       parts = append(parts, string(v))
+               } else {
+                       parts = append(parts, fmt.Sprintf("(binary:%d bytes)", 
len(v)))
                }
        }
-       return ""
+       return strings.Join(parts, ",")
+}
+
+// formatIndexedTagsCSV serializes all indexed tags into one CSV cell as
+// "ruleID=v1,v2;ruleID=...", sorted by IndexRuleID.
+func formatIndexedTagsCSV(m map[uint32][][]byte) string {
+       if len(m) == 0 {
+               return ""
+       }
+       ids := sortedRuleIDs(m)
+       parts := make([]string, 0, len(ids))
+       for _, id := range ids {
+               parts = append(parts, fmt.Sprintf("%d=%s", id, 
formatIndexedTagValues(m[id])))
+       }
+       return strings.Join(parts, ";")
 }
 
 func writeMeasureRowAsText(
@@ -378,7 +414,6 @@ func writeMeasureRowAsText(
        rowNum int,
        projectionTags []string,
        projectionFields []string,
-       seriesMap map[common.SeriesID]string,
 ) {
        fmt.Printf("Row %d:\n", rowNum)
        fmt.Printf("  PartID: %d (0x%016x)\n", partID, partID)
@@ -386,7 +421,7 @@ func writeMeasureRowAsText(
        fmt.Printf("  Version: %d\n", row.Version)
        fmt.Printf("  SeriesID: %d\n", row.SeriesID)
 
-       if seriesText := measureSeriesText(row, seriesMap); seriesText != "" {
+       if seriesText := measureSeriesText(row); seriesText != "" {
                fmt.Printf("  Series: %s\n", seriesText)
        }
 
@@ -441,6 +476,13 @@ func writeMeasureRowAsText(
                        }
                }
        }
+
+       if len(row.IndexedTags) > 0 {
+               fmt.Printf("  IndexedTags (by IndexRuleID):\n")
+               for _, ruleID := range sortedRuleIDs(row.IndexedTags) {
+                       fmt.Printf("    %d: %s\n", ruleID, 
formatIndexedTagValues(row.IndexedTags[ruleID]))
+               }
+       }
        fmt.Printf("\n")
 }
 
@@ -450,14 +492,14 @@ func writeMeasureRowAsCSV(
        row dumpmeasure.Row,
        fieldColumns []string,
        tagColumns []string,
-       seriesMap map[common.SeriesID]string,
 ) error {
        csvRow := []string{
                fmt.Sprintf("%d", partID),
                formatTimestamp(row.Timestamp),
                fmt.Sprintf("%d", row.Version),
                fmt.Sprintf("%d", row.SeriesID),
-               measureSeriesText(row, seriesMap),
+               measureSeriesText(row),
+               formatIndexedTagsCSV(row.IndexedTags),
        }
 
        // Add field values
diff --git a/banyand/internal/dump/index_resolver.go 
b/banyand/internal/dump/index_resolver.go
new file mode 100644
index 000000000..c4d97629c
--- /dev/null
+++ b/banyand/internal/dump/index_resolver.go
@@ -0,0 +1,202 @@
+// 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 dump
+
+import (
+       "bytes"
+       "context"
+       "path/filepath"
+
+       lru "github.com/hashicorp/golang-lru"
+
+       "github.com/apache/skywalking-banyandb/api/common"
+       modelv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+       "github.com/apache/skywalking-banyandb/pkg/convert"
+       "github.com/apache/skywalking-banyandb/pkg/index"
+       "github.com/apache/skywalking-banyandb/pkg/index/inverted"
+       "github.com/apache/skywalking-banyandb/pkg/logger"
+       pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+)
+
+// DefaultIndexCacheSize is the default number of series whose resolved indexed
+// tags an IndexResolver keeps in its frequency-aware cache.
+const DefaultIndexCacheSize = 4096
+
+// IndexedTagSpec describes an indexed tag so the resolver can decode its raw
+// stored value into a typed TagValue keyed by name. Callers that know the 
schema
+// pass a IndexRuleID -> IndexedTagSpec map to NewIndexResolver.
+type IndexedTagSpec struct {
+       Family string
+       Name   string
+       Type   pbv1.ValueType
+}
+
+// IndexResolver recovers a measure's indexed (non-entity) tag values, which 
the
+// write path stores only in the segment-level series index (sidx) and never as
+// data columns. Lookups are on-demand per series and held in a fixed-size,
+// frequency-aware (2Q) cache, so the potentially large indexed values are 
never
+// all held in memory at once.
+//
+// Resolve returns values keyed by IndexRuleID (no schema needed). When the
+// caller supplies a IndexRuleID -> IndexedTagSpec mapping, DecodeTagValues
+// additionally decodes a resolved map into typed TagValues keyed by 
"family.tag".
+type IndexResolver struct {
+       store     index.SeriesStore
+       cache     *lru.TwoQueueCache
+       ruleToTag map[uint32]IndexedTagSpec
+}
+
+// NewIndexResolver opens the segment's series index under segmentPath/sidx for
+// read-only lookups. ruleToTag is optional: when the schema is known it lets
+// DecodeTagValues decode values by tag name, otherwise pass nil and use 
Resolve.
+// cacheSize bounds the frequency-aware cache; values <= 0 fall back to
+// DefaultIndexCacheSize. Call Close when done.
+func NewIndexResolver(segmentPath string, cacheSize int,
+       ruleToTag map[uint32]IndexedTagSpec,
+) (*IndexResolver, error) {
+       if cacheSize <= 0 {
+               cacheSize = DefaultIndexCacheSize
+       }
+       cache, err := lru.New2Q(cacheSize)
+       if err != nil {
+               return nil, err
+       }
+       store, err := inverted.NewStore(inverted.StoreOpts{
+               Path:   filepath.Join(segmentPath, dirNameSidx),
+               Logger: logger.GetLogger(logName),
+       })
+       if err != nil {
+               return nil, err
+       }
+       return &IndexResolver{
+               store:     store,
+               cache:     cache,
+               ruleToTag: ruleToTag,
+       }, nil
+}
+
+// Resolve returns the indexed tag values stored for the series identified by
+// seriesID, whose EntityValues the caller already knows (the index document is
+// keyed by EntityValues). Each rule id maps to one or more raw value byte 
slices
+// (one for a scalar tag, several for an array tag). It returns nil when
+// entityValues is empty or the series has no indexed tags. Results are cached
+// per seriesID, so a series spanning several blocks or parts resolves once.
+func (r *IndexResolver) Resolve(seriesID common.SeriesID, entityValues []byte) 
(map[uint32][][]byte, error) {
+       if v, ok := r.cache.Get(seriesID); ok {
+               return v.(map[uint32][][]byte), nil
+       }
+       if len(entityValues) == 0 {
+               return nil, nil
+       }
+       raw, err := r.store.StoredFields(context.Background(), entityValues)
+       if err != nil {
+               return nil, err
+       }
+       result := parseIndexedFields(raw)
+       r.cache.Add(seriesID, result)
+       return result, nil
+}
+
+// PartSeriesMap builds a SeriesID -> EntityValues map scoped to the given set 
of
+// series by scanning the series index once and keeping only the entries whose
+// SeriesID (Hash of EntityValues) is in seriesIDs. It is the fallback that
+// recovers EntityValues for a part with no part-level series metadata, while
+// keeping peak memory bounded by the part's distinct series count. It stops
+// early once every requested series has been found.
+func (r *IndexResolver) PartSeriesMap(seriesIDs map[common.SeriesID]struct{}) 
(map[common.SeriesID][]byte, error) {
+       if len(seriesIDs) == 0 {
+               return nil, nil
+       }
+       iter, err := r.store.SeriesIterator(context.Background())
+       if err != nil {
+               return nil, err
+       }
+       result := make(map[common.SeriesID][]byte, len(seriesIDs))
+       for iter.Next() {
+               entityValues := iter.Val().EntityValues
+               if len(entityValues) == 0 {
+                       continue
+               }
+               seriesID := common.SeriesID(convert.Hash(entityValues))
+               if _, ok := seriesIDs[seriesID]; !ok {
+                       continue
+               }
+               result[seriesID] = bytes.Clone(entityValues)
+               if len(result) == len(seriesIDs) {
+                       break
+               }
+       }
+       if closeErr := iter.Close(); closeErr != nil {
+               return nil, closeErr
+       }
+       return result, nil
+}
+
+// DecodeTagValues decodes already-resolved indexed tags (a Row's IndexedTags)
+// into typed TagValues keyed by "family.tag" (or just "tag" when the family is
+// empty), using the ruleToTag mapping given to NewIndexResolver. It does no 
I/O.
+// It returns nil when that mapping is absent (schema unknown) or there are no
+// indexed tags.
+func (r *IndexResolver) DecodeTagValues(indexed map[uint32][][]byte) 
map[string]*modelv1.TagValue {
+       if r.ruleToTag == nil || len(indexed) == 0 {
+               return nil
+       }
+       out := make(map[string]*modelv1.TagValue, len(indexed))
+       for ruleID, values := range indexed {
+               spec, ok := r.ruleToTag[ruleID]
+               if !ok {
+                       continue
+               }
+               key := spec.Name
+               if spec.Family != "" {
+                       key = spec.Family + "." + spec.Name
+               }
+               var scalar []byte
+               if len(values) > 0 {
+                       scalar = values[0]
+               }
+               // DecodeTagValue uses value for scalar types and valueArr for 
array
+               // types; passing both lets the value type pick the right one.
+               out[key] = DecodeTagValue(spec.Type, scalar, values)
+       }
+       return out
+}
+
+// Close releases the underlying series index store.
+func (r *IndexResolver) Close() error {
+       return r.store.Close()
+}
+
+// parseIndexedFields keys a document's stored fields by IndexRuleID. An
+// index-rule field name is a marshaled uint32 IndexRuleID (exactly 4 bytes);
+// any other-width field is skipped, so a non-rule name (e.g. an index-mode
+// tag name) can neither panic BytesToUint32 nor map to a garbage rule id. A
+// rule id maps to one value for a scalar tag, several for an array tag.
+func parseIndexedFields(raw map[string][][]byte) map[uint32][][]byte {
+       if len(raw) == 0 {
+               return nil
+       }
+       result := make(map[uint32][][]byte, len(raw))
+       for name, values := range raw {
+               if len(name) != 4 {
+                       continue
+               }
+               result[convert.BytesToUint32([]byte(name))] = values
+       }
+       return result
+}
diff --git a/banyand/internal/dump/measure/iterator.go 
b/banyand/internal/dump/measure/iterator.go
index 0c75c54f4..9802a303a 100644
--- a/banyand/internal/dump/measure/iterator.go
+++ b/banyand/internal/dump/measure/iterator.go
@@ -20,6 +20,7 @@ package measure
 import (
        "fmt"
 
+       "github.com/apache/skywalking-banyandb/api/common"
        "github.com/apache/skywalking-banyandb/banyand/internal/dump"
        "github.com/apache/skywalking-banyandb/pkg/compress/zstd"
        "github.com/apache/skywalking-banyandb/pkg/encoding"
@@ -46,6 +47,21 @@ func (p *PartReader) Iterator() *dump.Iterator[Row] {
                }
                blocks = append(blocks, bms...)
        }
+       // When the part has no part-level series metadata (the standalone 
write path
+       // writes none), recover EntityValues for just this part's series by 
scanning
+       // the series index scoped to the part — keeping peak memory bounded by 
the
+       // part rather than loading a segment-wide map.
+       if p.seriesMap == nil && p.indexResolver != nil {
+               seriesIDs := make(map[common.SeriesID]struct{})
+               for _, bm := range blocks {
+                       seriesIDs[bm.seriesID] = struct{}{}
+               }
+               seriesMap, err := p.indexResolver.PartSeriesMap(seriesIDs)
+               if err != nil {
+                       return dump.NewErrIterator[Row](fmt.Errorf("cannot 
build part series map: %w", err))
+               }
+               p.seriesMap = seriesMap
+       }
        var decoder encoding.BytesBlockDecoder
        return dump.NewIterator(len(blocks), func(blockIdx int) ([]Row, error) {
                return p.decodeBlock(&decoder, blocks[blockIdx])
@@ -104,6 +120,17 @@ func (p *PartReader) decodeBlock(decoder 
*encoding.BytesBlockDecoder, bm *blockM
                entityValues = p.seriesMap[bm.seriesID]
        }
 
+       // Indexed (non-column) tags live only in the series index; resolve 
them once
+       // per block (one series) and share across the block's rows.
+       var indexedTags map[uint32][][]byte
+       if p.indexResolver != nil {
+               var resolveErr error
+               indexedTags, resolveErr = p.indexResolver.Resolve(bm.seriesID, 
entityValues)
+               if resolveErr != nil {
+                       return nil, fmt.Errorf("cannot resolve indexed tags for 
series %d: %w", bm.seriesID, resolveErr)
+               }
+       }
+
        rows := make([]Row, 0, len(timestamps))
        for i := range timestamps {
                tags := make(map[string][]byte, len(tagsByDataPoint))
@@ -131,6 +158,7 @@ func (p *PartReader) decodeBlock(decoder 
*encoding.BytesBlockDecoder, bm *blockM
                        Fields:       fields,
                        TagTypes:     tagTypesForRow,
                        FieldTypes:   fieldTypesForRow,
+                       IndexedTags:  indexedTags,
                })
        }
        return rows, nil
diff --git a/banyand/internal/dump/measure/measure.go 
b/banyand/internal/dump/measure/measure.go
index b992d5735..c6cb40581 100644
--- a/banyand/internal/dump/measure/measure.go
+++ b/banyand/internal/dump/measure/measure.go
@@ -104,6 +104,7 @@ type PartReader struct {
        tagFamilyMetadata    map[string]fs.Reader
        tagFamilies          map[string]fs.Reader
        seriesMap            map[common.SeriesID][]byte // part-level 
smeta.bin, nil if absent
+       indexResolver        *dump.IndexResolver        // optional; resolves 
indexed (non-column) tags
        path                 string
        primaryBlockMetadata []primaryBlockMetadata
        partMetadata         PartMetadata
@@ -117,6 +118,7 @@ type Row struct {
        Fields       map[string][]byte
        TagTypes     map[string]pbv1.ValueType
        FieldTypes   map[string]pbv1.ValueType
+       IndexedTags  map[uint32][][]byte
        EntityValues []byte
        Timestamp    int64
        Version      int64
@@ -240,6 +242,11 @@ func (p *PartReader) PartID() uint64 { return 
p.partMetadata.ID }
 // smeta.bin, or nil if smeta.bin is absent.
 func (p *PartReader) SeriesMap() map[common.SeriesID][]byte { return 
p.seriesMap }
 
+// SetIndexResolver attaches an index resolver so the iterator fills each Row's
+// IndexedTags with the series' indexed (non-column) tag values. Optional; the
+// resolver is owned by the caller (the PartReader does not close it).
+func (p *PartReader) SetIndexResolver(r *dump.IndexResolver) { p.indexResolver 
= r }
+
 // Close releases all file handles. Safe to call multiple times.
 func (p *PartReader) Close() error {
        closePart(p)
diff --git a/banyand/internal/dump/measure/suite_test.go 
b/banyand/internal/dump/measure/suite_test.go
index 634e4d72a..c5f77a046 100644
--- a/banyand/internal/dump/measure/suite_test.go
+++ b/banyand/internal/dump/measure/suite_test.go
@@ -21,6 +21,7 @@ import (
        "context"
        "fmt"
        "io/fs"
+       "os"
        "path/filepath"
        "strconv"
        "testing"
@@ -30,6 +31,7 @@ import (
        "github.com/stretchr/testify/require"
        "google.golang.org/protobuf/types/known/timestamppb"
 
+       "github.com/apache/skywalking-banyandb/api/common"
        "github.com/apache/skywalking-banyandb/api/data"
        commonv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
        databasev1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
@@ -42,14 +44,19 @@ import (
        "github.com/apache/skywalking-banyandb/banyand/protector"
        "github.com/apache/skywalking-banyandb/banyand/queue"
        "github.com/apache/skywalking-banyandb/pkg/bus"
+       "github.com/apache/skywalking-banyandb/pkg/convert"
        localfs "github.com/apache/skywalking-banyandb/pkg/fs"
+       "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/test"
 )
 
 const (
-       rtGroup   = "dump_rt_group"
-       rtMeasure = "dump_rt_measure"
+       rtGroup     = "dump_rt_group"
+       rtMeasure   = "dump_rt_measure"
+       idxrGroup   = "dump_idxr_group"
+       idxrMeasure = "dump_idxr_measure"
 )
 
 // TestMeasureWriteRoundTrip drives the top-level measure write path (the same
@@ -245,3 +252,349 @@ func findPartDirs(root string) []string {
        })
        return dirs
 }
+
+// TestMeasureIndexedTagResolvedFromIndex verifies the dump recovers a 
measure's
+// indexed (non-entity) tag values from the series index via an IndexResolver,
+// even though they are absent from the data columns. It covers a scalar 
string,
+// a scalar int and a string-array indexed tag.
+func TestMeasureIndexedTagResolvedFromIndex(t *testing.T) {
+       req := require.New(t)
+       require.NoError(t, logger.Init(logger.Logging{Env: "dev", Level: 
"warn"}))
+       gomega.RegisterFailHandler(func(message string, _ ...int) { 
panic(message) })
+
+       pipeline := queue.Local()
+       metaSvc, err := metadataservice.NewService()
+       req.NoError(err)
+       metricSvc := obsservice.NewMetricService(metaSvc, pipeline, "test", nil)
+       pm := protector.NewMemory(metricSvc)
+       measureSvc, err := measure.NewStandalone(metaSvc, pipeline, nil, 
metricSvc, pm)
+       req.NoError(err)
+
+       metaPath, metaDefer, err := test.NewSpace()
+       req.NoError(err)
+       defer metaDefer()
+       ports, err := test.AllocateFreePorts(1)
+       req.NoError(err)
+       rootPath, rootDefer, err := test.NewSpace()
+       req.NoError(err)
+       defer rootDefer()
+
+       flags := []string{
+               "--schema-server-root-path=" + metaPath,
+               fmt.Sprintf("--schema-server-grpc-port=%d", ports[0]),
+               "--schema-server-grpc-host=127.0.0.1",
+               "--measure-root-path=" + rootPath,
+               "--measure-flush-timeout=200ms",
+       }
+       moduleDefer := test.SetupModules(flags, pipeline, metaSvc, measureSvc)
+       stopped := false
+       defer func() {
+               if !stopped {
+                       moduleDefer()
+               }
+       }()
+
+       registerIndexedResolveMeasure(t, metaSvc)
+       require.Eventually(t, func() bool {
+               _, ok := measureSvc.LoadGroup(idxrGroup)
+               return ok
+       }, 30*time.Second, 200*time.Millisecond, "measure group not loaded")
+       time.Sleep(time.Second)
+
+       const total = 2
+       baseTS := time.Now().Truncate(time.Minute)
+       bp := pipeline.NewBatchPublisher(5 * time.Second)
+       for i := 0; i < total; i++ {
+               iStr := strconv.Itoa(i)
+               ts := baseTS.Add(time.Duration(i) * time.Minute)
+               writeReq := &measurev1.WriteRequest{
+                       Metadata: &commonv1.Metadata{Name: idxrMeasure, Group: 
idxrGroup},
+                       DataPoint: &measurev1.DataPointValue{
+                               Timestamp: timestamppb.New(ts),
+                               TagFamilies: []*modelv1.TagFamilyForWrite{{
+                                       Tags: []*modelv1.TagValue{
+                                               strTagValue("series" + iStr),   
                // series (entity)
+                                               strTagValue("plain" + iStr),    
                // strTag (plain column)
+                                               strTagValue("indexed" + iStr),  
                // idxStr (indexed)
+                                               intTagValue(int64(70 + i)),     
                // idxInt (indexed)
+                                               strArrTagValue("arr"+iStr+"a", 
"arr"+iStr+"b"), // idxArr (indexed)
+                                       },
+                               }},
+                               Fields: []*modelv1.FieldValue{
+                                       {Value: &modelv1.FieldValue_Int{Int: 
&modelv1.Int{Value: int64(100 + i)}}},
+                               },
+                       },
+               }
+               _, errPub := bp.Publish(context.TODO(), data.TopicMeasureWrite, 
bus.NewMessage(bus.MessageID(i), &measurev1.InternalWriteRequest{
+                       EntityValues: []*modelv1.TagValue{strTagValue("series" 
+ iStr)},
+                       Request:      writeReq,
+               }))
+               req.NoError(errPub)
+       }
+       req.Empty(bp.Close())
+
+       var partDirs []string
+       require.Eventually(t, func() bool {
+               partDirs = findPartDirs(rootPath)
+               return len(partDirs) > 0
+       }, 30*time.Second, 200*time.Millisecond, "no on-disk part was flushed")
+
+       // Resolve each rule's assigned IndexRuleID while the schema server is 
still
+       // up, so we can later assert per-rule that multiple rules on one row 
stay
+       // separate (each under its own id).
+       reg := metaSvc.SchemaRegistry()
+       ruleID := func(name string) uint32 {
+               ir, gErr := reg.GetIndexRule(context.TODO(), 
&commonv1.Metadata{Name: name, Group: idxrGroup})
+               req.NoError(gErr)
+               return ir.GetMetadata().GetId()
+       }
+       strRuleID, intRuleID, arrRuleID := ruleID("idxr_str_rule"), 
ruleID("idxr_int_rule"), ruleID("idxr_arr_rule")
+
+       // Stop the live service so it releases bluge's exclusive lock on the 
series
+       // index; the dump (like the offline CLI) reads the index from a 
quiesced
+       // database. Deferred cleanup is suppressed via the stopped flag.
+       moduleDefer()
+       stopped = true
+
+       segmentPath := findSidxSegmentPath(t, rootPath)
+
+       ruleToTag := map[uint32]dump.IndexedTagSpec{
+               strRuleID: {Family: "default", Name: "idxStr", Type: 
pbv1.ValueTypeStr},
+               intRuleID: {Family: "default", Name: "idxInt", Type: 
pbv1.ValueTypeInt64},
+               arrRuleID: {Family: "default", Name: "idxArr", Type: 
pbv1.ValueTypeStrArr},
+       }
+       // The standalone write path persists no part-level smeta.bin, so the 
resolver
+       // recovers EntityValues for each part by scanning the series index 
scoped to
+       // that part (PartSeriesMap) — the per-part bounded fallback, no 
segment-wide
+       // map.
+       resolver, err := dump.NewIndexResolver(segmentPath, 0, ruleToTag)
+       req.NoError(err)
+       // Closed explicitly before the smeta-path phase reopens the same index
+       // (bluge holds an exclusive lock, so only one store may be open at a 
time).
+
+       fileSystem := localfs.NewLocalFileSystem()
+       seen := 0
+       entityBySeries := map[common.SeriesID]string{}
+       partSeriesByDir := map[string]map[common.SeriesID][]byte{}
+       for _, partDir := range partDirs {
+               partID, parseErr := strconv.ParseUint(filepath.Base(partDir), 
16, 64)
+               req.NoError(parseErr)
+               p, openErr := OpenPart(partID, filepath.Dir(partDir), 
fileSystem)
+               req.NoError(openErr)
+               // Standalone parts carry no smeta.bin, so the iterator must 
take the
+               // per-part fallback scan to recover EntityValues.
+               req.Nil(p.SeriesMap(), "standalone part has no smeta.bin")
+               p.SetIndexResolver(resolver)
+               curSeries := map[common.SeriesID][]byte{}
+               it := p.Iterator()
+               for it.Next() {
+                       r := it.Row()
+                       seen++
+                       // Identify the row by its plain (column) tag, e.g. 
"plain1" -> n=1.
+                       plain := 
dump.DecodeTagValue(r.TagTypes["default.strTag"], r.Tags["default.strTag"], 
nil).GetStr().GetValue()
+                       n, atoiErr := strconv.Atoi(plain[len("plain"):])
+                       req.NoError(atoiErr)
+                       iStr := strconv.Itoa(n)
+                       // Indexed and entity tags are NOT stored as data 
columns; the plain
+                       // tag and the field are.
+                       req.NotContains(r.Tags, "default.idxStr")
+                       req.NotContains(r.Tags, "default.idxInt")
+                       req.NotContains(r.Tags, "default.idxArr")
+                       req.NotContains(r.Tags, "default.series")
+                       req.Contains(r.Fields, "intField")
+                       // No part-level smeta exists (standalone write path), 
so EntityValues
+                       // here proves the per-part fallback scan recovered it; 
the entity tag
+                       // value is part of the encoded EntityValues.
+                       req.NotEmpty(r.EntityValues, "fallback scan must 
recover EntityValues")
+                       req.Contains(string(r.EntityValues), "series"+iStr)
+                       entityBySeries[r.SeriesID] = string(r.EntityValues)
+                       curSeries[r.SeriesID] = append([]byte(nil), 
r.EntityValues...)
+                       // All three rules' values are recovered, each under 
its own
+                       // IndexRuleID (multiple rules on one row stay 
separate, not merged).
+                       req.Len(r.IndexedTags, 3, "all three index rules 
resolved")
+                       strVals := r.IndexedTags[strRuleID]
+                       req.Len(strVals, 1, "scalar string indexed tag -> one 
value")
+                       req.Equal("indexed"+iStr, string(strVals[0]))
+                       intVals := r.IndexedTags[intRuleID]
+                       req.Len(intVals, 1, "scalar int indexed tag -> one 
value")
+                       req.Equal(convert.Int64ToBytes(int64(70+n)), intVals[0])
+                       arrVals := r.IndexedTags[arrRuleID]
+                       req.Len(arrVals, 2, "array indexed tag -> two values")
+                       arrSet := map[string]bool{string(arrVals[0]): true, 
string(arrVals[1]): true}
+                       req.True(arrSet["arr"+iStr+"a"] && 
arrSet["arr"+iStr+"b"], "both array elements recovered")
+                       // With the rule->tag mapping, the row's 
already-resolved IndexedTags
+                       // decode to typed TagValues keyed by "family.tag".
+                       named := resolver.DecodeTagValues(r.IndexedTags)
+                       req.Equal("indexed"+iStr, 
named["default.idxStr"].GetStr().GetValue())
+                       req.Equal(int64(70+n), 
named["default.idxInt"].GetInt().GetValue())
+                       req.ElementsMatch([]string{"arr" + iStr + "a", "arr" + 
iStr + "b"}, named["default.idxArr"].GetStrArray().GetValue())
+               }
+               req.NoError(it.Err())
+               it.Close()
+               p.Close()
+               partSeriesByDir[partDir] = curSeries
+       }
+       req.Equal(total, seen, "every data point should be read back")
+       req.Len(entityBySeries, total, "each entity maps to a distinct series")
+
+       // Direct coverage of the bounded per-part scan: it returns 
EntityValues only
+       // for the requested series, and nothing for unknown series.
+       allIDs := make(map[common.SeriesID]struct{}, len(entityBySeries))
+       for sid := range entityBySeries {
+               allIDs[sid] = struct{}{}
+       }
+       full, err := resolver.PartSeriesMap(allIDs)
+       req.NoError(err)
+       req.Len(full, len(entityBySeries), "scan returns exactly the requested 
series")
+       for sid, ev := range entityBySeries {
+               req.Equal(ev, string(full[sid]))
+       }
+
+       var oneID common.SeriesID
+       for sid := range allIDs {
+               oneID = sid
+               break
+       }
+       subset, err := 
resolver.PartSeriesMap(map[common.SeriesID]struct{}{oneID: {}})
+       req.NoError(err)
+       req.Len(subset, 1, "a subset request stays scoped to that subset")
+       req.Equal(entityBySeries[oneID], string(subset[oneID]))
+
+       bogus, err := 
resolver.PartSeriesMap(map[common.SeriesID]struct{}{common.SeriesID(0xdeadbeef):
 {}})
+       req.NoError(err)
+       req.Empty(bogus, "an unknown series resolves to nothing")
+
+       // Distributed write-path coverage: a part produced by the data-node 
path
+       // carries a part-level smeta.bin, so OpenPart loads its series map 
directly
+       // and the iterator skips the index scan. Simulate that by writing a 
real
+       // smeta.bin per part, then reopen with a fresh resolver (empty cache) 
so the
+       // indexed tags are genuinely re-resolved from the smeta-provided 
EntityValues.
+       req.NoError(resolver.Close())
+       smetaResolver, err := dump.NewIndexResolver(segmentPath, 0, ruleToTag)
+       req.NoError(err)
+       defer smetaResolver.Close()
+       smetaSeen := 0
+       for partDir, series := range partSeriesByDir {
+               partID, parseErr := strconv.ParseUint(filepath.Base(partDir), 
16, 64)
+               req.NoError(parseErr)
+               docs := make(index.Documents, 0, len(series))
+               for _, entityValues := range series {
+                       docs = append(docs, index.Document{EntityValues: 
entityValues})
+               }
+               smeta, marshalErr := docs.Marshal()
+               req.NoError(marshalErr)
+               req.NoError(os.WriteFile(filepath.Join(partDir, "smeta.bin"), 
smeta, 0o600))
+
+               p, openErr := OpenPart(partID, filepath.Dir(partDir), 
fileSystem)
+               req.NoError(openErr)
+               req.NotNil(p.SeriesMap(), "smeta.bin present -> series map 
loaded, index scan skipped")
+               p.SetIndexResolver(smetaResolver)
+               it := p.Iterator()
+               for it.Next() {
+                       r := it.Row()
+                       smetaSeen++
+                       req.Equal(series[r.SeriesID], r.EntityValues, 
"EntityValues sourced from smeta.bin")
+                       plain := 
dump.DecodeTagValue(r.TagTypes["default.strTag"], r.Tags["default.strTag"], 
nil).GetStr().GetValue()
+                       n, atoiErr := strconv.Atoi(plain[len("plain"):])
+                       req.NoError(atoiErr)
+                       iStr := strconv.Itoa(n)
+                       req.Len(r.IndexedTags, 3, "indexed tags still resolve 
on the smeta fast path")
+                       req.Equal("indexed"+iStr, 
string(r.IndexedTags[strRuleID][0]))
+                       req.Equal(convert.Int64ToBytes(int64(70+n)), 
r.IndexedTags[intRuleID][0])
+                       arrVals := r.IndexedTags[arrRuleID]
+                       req.Len(arrVals, 2)
+                       arrSet := map[string]bool{string(arrVals[0]): true, 
string(arrVals[1]): true}
+                       req.True(arrSet["arr"+iStr+"a"] && 
arrSet["arr"+iStr+"b"], "both array elements recovered")
+               }
+               req.NoError(it.Err())
+               it.Close()
+               p.Close()
+       }
+       req.Equal(total, smetaSeen, "every data point read back on the smeta 
path too")
+}
+
+func registerIndexedResolveMeasure(t *testing.T, metaSvc 
metadataservice.Service) {
+       reg := metaSvc.SchemaRegistry()
+       ctx := context.TODO()
+       _, err := reg.CreateGroup(ctx, &commonv1.Group{
+               Metadata: &commonv1.Metadata{Name: idxrGroup},
+               Catalog:  commonv1.Catalog_CATALOG_MEASURE,
+               ResourceOpts: &commonv1.ResourceOpts{
+                       ShardNum:        1,
+                       SegmentInterval: &commonv1.IntervalRule{Unit: 
commonv1.IntervalRule_UNIT_DAY, Num: 1},
+                       Ttl:             &commonv1.IntervalRule{Unit: 
commonv1.IntervalRule_UNIT_DAY, Num: 7},
+               },
+       })
+       require.NoError(t, err)
+       _, err = reg.CreateMeasure(ctx, &databasev1.Measure{
+               Metadata: &commonv1.Metadata{Name: idxrMeasure, Group: 
idxrGroup},
+               TagFamilies: []*databasev1.TagFamilySpec{{
+                       Name: "default",
+                       Tags: []*databasev1.TagSpec{
+                               {Name: "series", Type: 
databasev1.TagType_TAG_TYPE_STRING},
+                               {Name: "strTag", Type: 
databasev1.TagType_TAG_TYPE_STRING},
+                               {Name: "idxStr", Type: 
databasev1.TagType_TAG_TYPE_STRING},
+                               {Name: "idxInt", Type: 
databasev1.TagType_TAG_TYPE_INT},
+                               {Name: "idxArr", Type: 
databasev1.TagType_TAG_TYPE_STRING_ARRAY},
+                       },
+               }},
+               Fields: []*databasev1.FieldSpec{
+                       {
+                               Name:              "intField",
+                               FieldType:         
databasev1.FieldType_FIELD_TYPE_INT,
+                               EncodingMethod:    
databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+                               CompressionMethod: 
databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+                       },
+               },
+               Entity: &databasev1.Entity{TagNames: []string{"series"}},
+       })
+       require.NoError(t, err)
+       for _, ir := range []struct {
+               name string
+               tag  string
+       }{
+               {"idxr_str_rule", "idxStr"},
+               {"idxr_int_rule", "idxInt"},
+               {"idxr_arr_rule", "idxArr"},
+       } {
+               _, err = reg.CreateIndexRule(ctx, &databasev1.IndexRule{
+                       Metadata: &commonv1.Metadata{Name: ir.name, Group: 
idxrGroup},
+                       Tags:     []string{ir.tag},
+                       Type:     databasev1.IndexRule_TYPE_INVERTED,
+               })
+               require.NoError(t, err)
+       }
+       _, err = reg.CreateIndexRuleBinding(ctx, &databasev1.IndexRuleBinding{
+               Metadata: &commonv1.Metadata{Name: "idxr_binding", Group: 
idxrGroup},
+               Subject:  &databasev1.Subject{Name: idxrMeasure, Catalog: 
commonv1.Catalog_CATALOG_MEASURE},
+               Rules:    []string{"idxr_str_rule", "idxr_int_rule", 
"idxr_arr_rule"},
+               BeginAt:  timestamppb.New(time.Now().Add(-time.Hour)),
+               ExpireAt: timestamppb.New(time.Now().Add(24 * time.Hour)),
+       })
+       require.NoError(t, err)
+}
+
+func strTagValue(s string) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: 
&modelv1.Str{Value: s}}}
+}
+
+func intTagValue(v int64) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: 
&modelv1.Int{Value: v}}}
+}
+
+func strArrTagValue(vals ...string) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_StrArray{StrArray: 
&modelv1.StrArray{Value: vals}}}
+}
+
+func findSidxSegmentPath(t *testing.T, root string) string {
+       t.Helper()
+       var seg string
+       _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) 
error {
+               if err == nil && d.IsDir() && d.Name() == "sidx" {
+                       seg = filepath.Dir(path)
+               }
+               return nil
+       })
+       require.NotEmpty(t, seg, "sidx directory not found under %s", root)
+       return seg
+}
diff --git a/pkg/index/index.go b/pkg/index/index.go
index 2c6372e99..2e5f11db6 100644
--- a/pkg/index/index.go
+++ b/pkg/index/index.go
@@ -880,6 +880,11 @@ type SeriesStore interface {
        SeriesIterator(context.Context) (FieldIterator[Series], error)
        SeriesSort(ctx context.Context, indexQuery Query, orderBy *OrderBy,
                preLoadSize int, fieldKeys []FieldKey) (iter 
FieldIterator[*DocumentResult], err error)
+       // StoredFields returns the document's stored fields keyed by name (a 
field
+       // may have several values), always excluding internal bookkeeping 
fields
+       // (_id, _timestamp, ...). A non-empty projection limits the result to 
the
+       // listed fields. Returns nil when no document matches.
+       StoredFields(ctx context.Context, docID []byte, projection ...FieldKey) 
(map[string][][]byte, error)
 }
 
 // SeriesMatcherType represents the type of series matcher.
diff --git a/pkg/index/inverted/inverted_series.go 
b/pkg/index/inverted/inverted_series.go
index 5a64a4283..9c749ec00 100644
--- a/pkg/index/inverted/inverted_series.go
+++ b/pkg/index/inverted/inverted_series.go
@@ -226,6 +226,59 @@ func (s *store) Search(ctx context.Context,
        return parseResult(dmi, projection, limit)
 }
 
+// StoredFields implements index.SeriesStore.
+func (s *store) StoredFields(ctx context.Context, docID []byte, projection 
...index.FieldKey) (map[string][][]byte, error) {
+       reader, err := s.writer.Reader()
+       if err != nil {
+               return nil, err
+       }
+       defer func() {
+               if r := recover(); r != nil {
+                       _ = reader.Close()
+                       panic(r)
+               }
+               _ = reader.Close()
+       }()
+
+       q := bluge.NewTermQuery(convert.BytesToString(docID))
+       q.SetField(docIDField)
+       dmi, err := reader.Search(ctx, bluge.NewAllMatches(q))
+       if err != nil {
+               return nil, err
+       }
+       match, err := dmi.Next()
+       if err != nil {
+               return nil, err
+       }
+       if match == nil {
+               return nil, nil
+       }
+       var want map[string]struct{}
+       if len(projection) > 0 {
+               want = make(map[string]struct{}, len(projection))
+               for i := range projection {
+                       want[projection[i].Marshal()] = struct{}{}
+               }
+       }
+       fields := make(map[string][][]byte)
+       if visitErr := match.VisitStoredFields(func(field string, value []byte) 
bool {
+               switch field {
+               case docIDField, seriesIDField, timestampField, versionField:
+                       return true // always skip internal bookkeeping fields, 
even if projected
+               }
+               if want != nil {
+                       if _, ok := want[field]; !ok {
+                               return true // not in the requested projection
+                       }
+               }
+               fields[field] = append(fields[field], bytes.Clone(value))
+               return true
+       }); visitErr != nil {
+               return nil, visitErr
+       }
+       return fields, nil
+}
+
 func parseResult(dmi search.DocumentMatchIterator, loadedFields 
[]index.FieldKey, limit int) ([]index.SeriesDocument, error) {
        result := make([]index.SeriesDocument, 0, 10)
        next, err := dmi.Next()
diff --git a/pkg/index/inverted/inverted_series_test.go 
b/pkg/index/inverted/inverted_series_test.go
index 3814915eb..c841dc697 100644
--- a/pkg/index/inverted/inverted_series_test.go
+++ b/pkg/index/inverted/inverted_series_test.go
@@ -1007,3 +1007,69 @@ func updateData(tester *require.Assertions, s 
index.SeriesStore) {
        tester.NoError(s.UpdateSeriesBatch(b1))
        tester.NoError(s.UpdateSeriesBatch(b2))
 }
+
+func TestStore_StoredFields(t *testing.T) {
+       tester := require.New(t)
+       path, fn := setUp(tester)
+       s, err := NewStore(StoreOpts{
+               Path:   path,
+               Logger: logger.GetLogger("test"),
+       })
+       tester.NoError(err)
+       defer func() {
+               tester.NoError(s.Close())
+               fn()
+       }()
+       insertData(tester, s)
+       ctx := context.TODO()
+
+       // No projection: every stored field of test2 is returned, internal
+       // bookkeeping fields (_id, _timestamp, ...) excluded.
+       all, err := s.StoredFields(ctx, []byte("test2"))
+       tester.NoError(err)
+       tester.Equal([][]byte{convert.Int64ToBytes(100)}, 
all[fieldKeyDuration.Marshal()])
+       tester.Equal([][]byte{[]byte("svc2")}, 
all[fieldKeyServiceName.Marshal()])
+       tester.Equal([][]byte{convert.Int64ToBytes(100)}, 
all[fieldKeyStartTime.Marshal()])
+       tester.NotContains(all, docIDField)
+       tester.NotContains(all, timestampField)
+
+       // Projection: only the requested field is returned.
+       only, err := s.StoredFields(ctx, []byte("test2"), fieldKeyDuration)
+       tester.NoError(err)
+       tester.Len(only, 1)
+       tester.Equal([][]byte{convert.Int64ToBytes(100)}, 
only[fieldKeyDuration.Marshal()])
+
+       // A two-field projection returns exactly those two.
+       two, err := s.StoredFields(ctx, []byte("test2"), fieldKeyDuration, 
fieldKeyServiceName)
+       tester.NoError(err)
+       tester.Len(two, 2)
+       tester.Contains(two, fieldKeyDuration.Marshal())
+       tester.Contains(two, fieldKeyServiceName.Marshal())
+
+       // A missing document yields nil.
+       none, err := s.StoredFields(ctx, []byte("does-not-exist"))
+       tester.NoError(err)
+       tester.Nil(none)
+
+       // An array indexed tag stores several values under one field name; 
they all
+       // come back (the result is [][]byte per field, not a single value).
+       arrKey := index.FieldKey{IndexRuleID: 4099}
+       tester.NoError(s.InsertSeriesBatch(index.Batch{Documents: 
[]index.Document{{
+               EntityValues: []byte("arr1"),
+               Fields: []index.Field{
+                       field(arrKey, []byte("a"), true),
+                       field(arrKey, []byte("b"), true),
+                       field(fieldKeyServiceName, []byte("svcArr"), true),
+               },
+       }}}))
+       arr, err := s.StoredFields(ctx, []byte("arr1"))
+       tester.NoError(err)
+       tester.ElementsMatch([][]byte{[]byte("a"), []byte("b")}, 
arr[arrKey.Marshal()])
+       tester.Equal([][]byte{[]byte("svcArr")}, 
arr[fieldKeyServiceName.Marshal()])
+
+       // Internal bookkeeping fields are excluded even when explicitly 
projected.
+       internalProj, err := s.StoredFields(ctx, []byte("test2"), 
index.FieldKey{TagName: docIDField}, fieldKeyDuration)
+       tester.NoError(err)
+       tester.NotContains(internalProj, docIDField)
+       tester.Equal([][]byte{convert.Int64ToBytes(100)}, 
internalProj[fieldKeyDuration.Marshal()])
+}


Reply via email to