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 59b33b531 Support multiple types for the same tag in a single measure
or stream part (#1126)
59b33b531 is described below
commit 59b33b531257c3909bfa17dfd26daca8802f6387
Author: Huang Youliang <[email protected]>
AuthorDate: Wed Jul 8 15:16:06 2026 +0800
Support multiple types for the same tag in a single measure or stream part
(#1126)
---
banyand/measure/block.go | 40 ++++++-
banyand/measure/block_test.go | 4 +-
banyand/measure/column.go | 44 +++++++
banyand/measure/column_test.go | 19 +++
banyand/measure/measure_suite_test.go | 2 +-
banyand/measure/merger.go | 72 +++++++++++-
banyand/measure/merger_test.go | 188 ++++++++++++++++++++++++++++++
banyand/measure/metadata_test.go | 77 +++++++++++-
banyand/measure/migration_copy.go | 2 +-
banyand/measure/part.go | 4 +
banyand/measure/part_metadata.go | 21 ++++
banyand/measure/svc_standalone.go | 1 +
banyand/stream/block.go | 40 ++++++-
banyand/stream/block_test.go | 4 +-
banyand/stream/merger.go | 72 +++++++++++-
banyand/stream/merger_test.go | 186 +++++++++++++++++++++++++++++
banyand/stream/metadata_test.go | 80 ++++++++++++-
banyand/stream/migration_copy.go | 2 +-
banyand/stream/migration_element_index.go | 2 +-
banyand/stream/part.go | 4 +
banyand/stream/part_metadata.go | 21 ++++
banyand/stream/stream_suite_test.go | 2 +-
banyand/stream/svc_standalone.go | 1 +
banyand/stream/tag.go | 44 +++++++
banyand/stream/tag_test.go | 19 +++
25 files changed, 916 insertions(+), 35 deletions(-)
diff --git a/banyand/measure/block.go b/banyand/measure/block.go
index cb88287c3..3164d076f 100644
--- a/banyand/measure/block.go
+++ b/banyand/measure/block.go
@@ -216,6 +216,7 @@ func (b *block) marshalTagFamily(tf columnFamily, bm
*blockMetadata, ww *writers
func (b *block) unmarshalTagFamily(decoder *encoding.BytesBlockDecoder,
tfIndex int, name string,
columnFamilyMetadataBlock *dataBlock, tagProjection []string,
metaReader, valueReader fs.Reader, count int,
+ schemaTagTypes map[string]pbv1.ValueType,
) {
if len(tagProjection) < 1 {
return
@@ -234,13 +235,28 @@ func (b *block) unmarshalTagFamily(decoder
*encoding.BytesBlockDecoder, tfIndex
cc := b.tagFamilies[tfIndex].resizeColumns(len(tagProjection))
NEXT:
for j := range tagProjection {
+ tp := tagProjection[j]
+ schemaType, hasSchemaType := schemaTagTypes[tp]
+ if hasSchemaType {
+ typedName := encodeTypedColumn(tp, schemaType)
+ for i := range cfm.columnMetadata {
+ if cfm.columnMetadata[i].name == typedName {
+ cc[j].mustReadValues(decoder,
valueReader, cfm.columnMetadata[i], uint64(b.Len()))
+ cc[j].name = tp
+ continue NEXT
+ }
+ }
+ }
for i := range cfm.columnMetadata {
- if tagProjection[j] == cfm.columnMetadata[i].name {
+ if cfm.columnMetadata[i].name == tp {
+ if hasSchemaType &&
cfm.columnMetadata[i].valueType != schemaType {
+ continue
+ }
cc[j].mustReadValues(decoder, valueReader,
cfm.columnMetadata[i], uint64(b.Len()))
continue NEXT
}
}
- cc[j].name = tagProjection[j]
+ cc[j].name = tp
cc[j].valueType = pbv1.ValueTypeUnknown
cc[j].resizeValues(count)
for k := range cc[j].values {
@@ -305,7 +321,7 @@ func (b *block) uncompressedSizeBytes() uint64 {
return n
}
-func (b *block) mustReadFrom(decoder *encoding.BytesBlockDecoder, p *part, bm
blockMetadata) {
+func (b *block) mustReadFrom(decoder *encoding.BytesBlockDecoder, p *part, bm
blockMetadata, schemaTagTypes map[string]pbv1.ValueType) {
b.reset()
b.timestamps, b.versions = mustReadTimestampsFrom(b.timestamps,
b.versions, &bm.timestamps, int(bm.count), p.timestamps)
@@ -334,7 +350,7 @@ func (b *block) mustReadFrom(decoder
*encoding.BytesBlockDecoder, p *part, bm bl
}
b.unmarshalTagFamily(decoder, i, name, block,
bm.tagProjection[i].Names, p.tagFamilyMetadata[name],
- p.tagFamilies[name], int(bm.count))
+ p.tagFamilies[name], int(bm.count), schemaTagTypes)
}
}
@@ -829,7 +845,7 @@ NEXT_FIELD:
}
}
bc.bm.tagFamilies = tf
- tmpBlock.mustReadFrom(&bc.columnValuesDecoder, bc.p, bc.bm)
+ tmpBlock.mustReadFrom(&bc.columnValuesDecoder, bc.p, bc.bm,
bc.schemaTagTypes)
start, end, ok := timestamp.FindRange(tmpBlock.timestamps,
bc.minTimestamp, bc.maxTimestamp)
if !ok {
@@ -952,16 +968,20 @@ func fastTagAppend(bi, b *blockPointer, offset int) error
{
if len(bi.tagFamilies) != len(b.tagFamilies) {
return fmt.Errorf("unexpected number of tag families: got %d;
want %d", len(b.tagFamilies), len(bi.tagFamilies))
}
+ existingDataSize := len(bi.timestamps)
for i := range bi.tagFamilies {
if bi.tagFamilies[i].name != b.tagFamilies[i].name {
+ rollbackFastTagAppend(bi, existingDataSize)
return fmt.Errorf("unexpected tag family name: got %q;
want %q", b.tagFamilies[i].name, bi.tagFamilies[i].name)
}
if len(bi.tagFamilies[i].columns) !=
len(b.tagFamilies[i].columns) {
+ rollbackFastTagAppend(bi, existingDataSize)
return fmt.Errorf("unexpected number of tags for tag
family %q: got %d; want %d",
bi.tagFamilies[i].name,
len(b.tagFamilies[i].columns), len(bi.tagFamilies[i].columns))
}
for j := range bi.tagFamilies[i].columns {
if bi.tagFamilies[i].columns[j].name !=
b.tagFamilies[i].columns[j].name {
+ rollbackFastTagAppend(bi, existingDataSize)
return fmt.Errorf("unexpected tag name for tag
family %q: got %q; want %q",
bi.tagFamilies[i].name,
b.tagFamilies[i].columns[j].name, bi.tagFamilies[i].columns[j].name)
}
@@ -972,6 +992,16 @@ func fastTagAppend(bi, b *blockPointer, offset int) error {
return nil
}
+func rollbackFastTagAppend(bi *blockPointer, existingDataSize int) {
+ for i := range bi.tagFamilies {
+ for j := range bi.tagFamilies[i].columns {
+ if len(bi.tagFamilies[i].columns[j].values) >
existingDataSize {
+ bi.tagFamilies[i].columns[j].values =
bi.tagFamilies[i].columns[j].values[:existingDataSize]
+ }
+ }
+ }
+}
+
func fullTagAppend(bi, b *blockPointer, offset int) {
existDataSize := len(bi.timestamps)
appendTagFamilies := func(tf columnFamily) {
diff --git a/banyand/measure/block_test.go b/banyand/measure/block_test.go
index 9414954c7..04b0bb61b 100644
--- a/banyand/measure/block_test.go
+++ b/banyand/measure/block_test.go
@@ -329,7 +329,7 @@ func Test_marshalAndUnmarshalTagFamily(t *testing.T) {
unmarshaled.timestamps = make([]int64, len(b.timestamps))
unmarshaled.resizeTagFamilies(1)
- unmarshaled.unmarshalTagFamily(decoder, tfIndex, name,
bm.getTagFamilyMetadata(name), tagProjection[name], metaBuffer, dataBuffer, 1)
+ unmarshaled.unmarshalTagFamily(decoder, tfIndex, name,
bm.getTagFamilyMetadata(name), tagProjection[name], metaBuffer, dataBuffer, 1,
nil)
if diff := cmp.Diff(unmarshaled.tagFamilies[0], b.tagFamilies[0],
cmp.AllowUnexported(columnFamily{}, column{}),
@@ -419,7 +419,7 @@ func Test_marshalAndUnmarshalBlock(t *testing.T) {
})
}
bm.tagProjection = tp
- unmarshaled.mustReadFrom(decoder, p, bm)
+ unmarshaled.mustReadFrom(decoder, p, bm, nil)
// blockMetadata is using a map, so the order of tag families is not
guaranteed
unmarshaled.sortTagFamilies()
diff --git a/banyand/measure/column.go b/banyand/measure/column.go
index c5bf2978b..795110b2e 100644
--- a/banyand/measure/column.go
+++ b/banyand/measure/column.go
@@ -18,6 +18,8 @@
package measure
import (
+ "strings"
+
"github.com/apache/skywalking-banyandb/pkg/bytes"
"github.com/apache/skywalking-banyandb/pkg/convert"
"github.com/apache/skywalking-banyandb/pkg/encoding"
@@ -27,6 +29,48 @@ import (
"github.com/apache/skywalking-banyandb/pkg/pool"
)
+const typedColumnSeparator = "#"
+
+var (
+ valueTypeToSuffix = map[pbv1.ValueType]string{
+ pbv1.ValueTypeStr: "str",
+ pbv1.ValueTypeInt64: "int",
+ pbv1.ValueTypeFloat64: "float",
+ pbv1.ValueTypeBinaryData: "bin",
+ pbv1.ValueTypeStrArr: "str_arr",
+ pbv1.ValueTypeInt64Arr: "int_arr",
+ pbv1.ValueTypeTimestamp: "ts",
+ pbv1.ValueTypeUnknown: "",
+ }
+ suffixToValueType = map[string]pbv1.ValueType{
+ "str": pbv1.ValueTypeStr,
+ "int": pbv1.ValueTypeInt64,
+ "float": pbv1.ValueTypeFloat64,
+ "bin": pbv1.ValueTypeBinaryData,
+ "str_arr": pbv1.ValueTypeStrArr,
+ "int_arr": pbv1.ValueTypeInt64Arr,
+ "ts": pbv1.ValueTypeTimestamp,
+ }
+)
+
+func encodeTypedColumn(name string, vt pbv1.ValueType) string {
+ suffix, ok := valueTypeToSuffix[vt]
+ if !ok || suffix == "" {
+ return name
+ }
+ return name + typedColumnSeparator + suffix
+}
+
+func decodeTypedColumn(key string) string {
+ for suffix := range suffixToValueType {
+ sepSuffix := typedColumnSeparator + suffix
+ if strings.HasSuffix(key, sepSuffix) {
+ return key[:len(key)-len(sepSuffix)]
+ }
+ }
+ return key
+}
+
func generateInt64Slice(length int) *[]int64 {
v := int64SlicePool.Get()
if v == nil {
diff --git a/banyand/measure/column_test.go b/banyand/measure/column_test.go
index 9f2e39ecd..e54bd3f6a 100644
--- a/banyand/measure/column_test.go
+++ b/banyand/measure/column_test.go
@@ -271,3 +271,22 @@ func getColumnEncodeTypeName(encType encoding.EncodeType)
string {
return fmt.Sprintf("Unknown(%d)", encType)
}
}
+
+func TestEncodeTypedColumn(t *testing.T) {
+ assert.Equal(t, "state#int", encodeTypedColumn("state",
pbv1.ValueTypeInt64))
+ assert.Equal(t, "state#str", encodeTypedColumn("state",
pbv1.ValueTypeStr))
+ assert.Equal(t, "http.status#int", encodeTypedColumn("http.status",
pbv1.ValueTypeInt64))
+ assert.Equal(t, "data#bin", encodeTypedColumn("data",
pbv1.ValueTypeBinaryData))
+ assert.Equal(t, "unknown", encodeTypedColumn("unknown",
pbv1.ValueTypeUnknown))
+}
+
+func TestDecodeTypedColumn(t *testing.T) {
+ assert.Equal(t, "state", decodeTypedColumn("state#int"))
+ assert.Equal(t, "state", decodeTypedColumn("state#str"))
+ assert.Equal(t, "http.status", decodeTypedColumn("http.status#int"))
+ assert.Equal(t, "http.status", decodeTypedColumn("http.status"))
+ assert.Equal(t, "simple", decodeTypedColumn("simple"))
+ assert.Equal(t, "my.str", decodeTypedColumn("my.str"))
+ assert.Equal(t, "arr.int", decodeTypedColumn("arr.int"))
+ assert.Equal(t, "data.bin", decodeTypedColumn("data.bin"))
+}
diff --git a/banyand/measure/measure_suite_test.go
b/banyand/measure/measure_suite_test.go
index 7b5b08596..8d307eb68 100644
--- a/banyand/measure/measure_suite_test.go
+++ b/banyand/measure/measure_suite_test.go
@@ -97,7 +97,7 @@ func setUp() (*services, func()) {
)
rootPath, deferFunc, err := test.NewSpace()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
- flags = append(flags, "--measure-root-path="+rootPath)
+ flags = append(flags, "--measure-root-path="+rootPath,
"--measure-max-merge-parts=2")
moduleDeferFunc := test.SetupModules(
flags,
pipeline,
diff --git a/banyand/measure/merger.go b/banyand/measure/merger.go
index 26251f4e6..de3693289 100644
--- a/banyand/measure/merger.go
+++ b/banyand/measure/merger.go
@@ -30,6 +30,7 @@ import (
"github.com/apache/skywalking-banyandb/pkg/cgroups"
"github.com/apache/skywalking-banyandb/pkg/encoding"
"github.com/apache/skywalking-banyandb/pkg/fs"
+ pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
"github.com/apache/skywalking-banyandb/pkg/watcher"
)
@@ -237,6 +238,7 @@ func (tst *tsTable) mergeParts(fileSystem fs.FileSystem,
closeCh <-chan struct{}
return nil, errNoPartToMerge
}
dstPath := partPath(root, partID)
+ conflictColumns := collectConflictColumns(parts)
var totalSize int64
pii := make([]*partMergeIter, 0, len(parts))
for i := range parts {
@@ -251,7 +253,7 @@ func (tst *tsTable) mergeParts(fileSystem fs.FileSystem,
closeCh <-chan struct{}
bw := generateBlockWriter()
bw.mustInitForFilePart(fileSystem, dstPath, shouldCache)
- pm, mergedTagType, err := mergeBlocks(closeCh, bw, br)
+ pm, mergedTagType, err := mergeBlocks(closeCh, bw, br, conflictColumns)
releaseBlockWriter(bw)
releaseBlockReader(br)
for i := range pii {
@@ -270,7 +272,7 @@ func (tst *tsTable) mergeParts(fileSystem fs.FileSystem,
closeCh <-chan struct{}
var errClosed = fmt.Errorf("the merger is closed")
-func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter, br *blockReader)
(*partMetadata, *tagType, error) {
+func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter, br *blockReader,
conflictColumns map[string]map[string]struct{}) (*partMetadata, *tagType,
error) {
pendingBlockIsEmpty := true
pendingBlock := generateBlockPointer()
defer releaseBlockPointer(pendingBlock)
@@ -288,6 +290,10 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
decoder = nil
}
}
+ loadAndRename := func() {
+ br.loadBlockData(getDecoder())
+ renameConflictColumns(&br.block.block, conflictColumns)
+ }
for br.nextBlockMetadata() {
select {
case <-closeCh:
@@ -297,7 +303,7 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
b := br.block
if pendingBlockIsEmpty {
- br.loadBlockData(getDecoder())
+ loadAndRename()
pendingBlock.copyFrom(b)
pendingBlockIsEmpty = false
continue
@@ -307,7 +313,7 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
(pendingBlock.isFull() &&
pendingBlock.bm.timestamps.max <= b.bm.timestamps.min) {
bw.mustWriteBlock(pendingBlock.bm.seriesID,
&pendingBlock.block)
releaseDecoder()
- br.loadBlockData(getDecoder())
+ loadAndRename()
pendingBlock.copyFrom(b)
continue
}
@@ -318,7 +324,7 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
}
tmpBlock.reset()
tmpBlock.bm.seriesID = b.bm.seriesID
- br.loadBlockData(getDecoder())
+ loadAndRename()
mergeTwoBlocks(tmpBlock, pendingBlock, b)
if len(tmpBlock.timestamps) <= maxBlockLength &&
tmpBlock.uncompressedSizeBytes() <= maxUncompressedBlockSize {
if len(tmpBlock.timestamps) == 0 {
@@ -361,6 +367,62 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
return &result, &mergedTagType, nil
}
+func renameConflictColumns(b *block, conflictColumns
map[string]map[string]struct{}) {
+ if len(conflictColumns) == 0 {
+ return
+ }
+ for i := range b.tagFamilies {
+ columns := conflictColumns[b.tagFamilies[i].name]
+ if columns == nil {
+ continue
+ }
+ cc := b.tagFamilies[i].columns
+ for j := range cc {
+ if _, ok := columns[cc[j].name]; ok {
+ cc[j].name = encodeTypedColumn(cc[j].name,
cc[j].valueType)
+ }
+ }
+ }
+}
+
+func collectConflictColumns(parts []*partWrapper)
map[string]map[string]struct{} {
+ familyColumnTypes :=
make(map[string]map[string]map[pbv1.ValueType]struct{})
+ for _, pw := range parts {
+ for cf, cc := range pw.p.tagType {
+ columnTypes := familyColumnTypes[cf]
+ if columnTypes == nil {
+ columnTypes =
make(map[string]map[pbv1.ValueType]struct{})
+ familyColumnTypes[cf] = columnTypes
+ }
+ for name, vt := range cc {
+ decoded := decodeTypedColumn(name)
+ valueTypes := columnTypes[decoded]
+ if valueTypes == nil {
+ valueTypes =
make(map[pbv1.ValueType]struct{})
+ columnTypes[decoded] = valueTypes
+ }
+ valueTypes[vt] = struct{}{}
+ }
+ }
+ }
+ var conflictColumns map[string]map[string]struct{}
+ for cf, columnTypes := range familyColumnTypes {
+ for name, valueTypes := range columnTypes {
+ if len(valueTypes) <= 1 {
+ continue
+ }
+ if conflictColumns == nil {
+ conflictColumns =
make(map[string]map[string]struct{})
+ }
+ if conflictColumns[cf] == nil {
+ conflictColumns[cf] = make(map[string]struct{})
+ }
+ conflictColumns[cf][name] = struct{}{}
+ }
+ }
+ return conflictColumns
+}
+
func mergeTwoBlocks(target, left, right *blockPointer) {
appendIfEmpty := func(ib1, ib2 *blockPointer) bool {
if ib1.idx >= len(ib1.timestamps) {
diff --git a/banyand/measure/merger_test.go b/banyand/measure/merger_test.go
index 39d92142c..9ded3ac64 100644
--- a/banyand/measure/merger_test.go
+++ b/banyand/measure/merger_test.go
@@ -1523,3 +1523,191 @@ func Test_mergeParts_fileBased(t *testing.T) {
"should have at least one block per seriesID")
})
}
+
+func Test_mergePartsWithConflictingTagTypes(t *testing.T) {
+ type wantColumn struct {
+ value []byte
+ valueType pbv1.ValueType
+ }
+ type wantBlock struct {
+ columns map[string]wantColumn
+ seriesID common.SeriesID
+ }
+ tests := []struct {
+ name string
+ dpsList []*dataPoints
+ wantBlocks []wantBlock
+ }{
+ {
+ name: "two parts with same tag name and conflicting
valueTypes",
+ dpsList: []*dataPoints{
+ {
+ seriesIDs: []common.SeriesID{1},
+ timestamps: []int64{1},
+ versions: []int64{1},
+ tagFamilies: [][]nameValues{
+ {
+ {name: "searchable",
values: []*nameValue{
+ {name:
"extra_tag", valueType: pbv1.ValueTypeInt64, value: convert.Int64ToBytes(100)},
+ }},
+ },
+ },
+ fields: []nameValues{{}},
+ },
+ {
+ seriesIDs: []common.SeriesID{2},
+ timestamps: []int64{2},
+ versions: []int64{1},
+ tagFamilies: [][]nameValues{
+ {
+ {name: "searchable",
values: []*nameValue{
+ {name:
"extra_tag", valueType: pbv1.ValueTypeStr, value: []byte("active")},
+ }},
+ },
+ },
+ fields: []nameValues{{}},
+ },
+ },
+ wantBlocks: []wantBlock{
+ {
+ seriesID: 1,
+ columns: map[string]wantColumn{
+ "searchable/extra_tag#int":
{valueType: pbv1.ValueTypeInt64, value: convert.Int64ToBytes(100)},
+ },
+ },
+ {
+ seriesID: 2,
+ columns: map[string]wantColumn{
+ "searchable/extra_tag#str":
{valueType: pbv1.ValueTypeStr, value: []byte("active")},
+ },
+ },
+ },
+ },
+ {
+ name: "merge typed-suffix part with plain part",
+ dpsList: []*dataPoints{
+ {
+ seriesIDs: []common.SeriesID{1},
+ timestamps: []int64{1},
+ versions: []int64{1},
+ tagFamilies: [][]nameValues{
+ {
+ {name: "searchable",
values: []*nameValue{
+ {name:
"extra_tag#int", valueType: pbv1.ValueTypeInt64, value:
convert.Int64ToBytes(100)},
+ }},
+ },
+ },
+ fields: []nameValues{{}},
+ },
+ {
+ seriesIDs: []common.SeriesID{2},
+ timestamps: []int64{2},
+ versions: []int64{1},
+ tagFamilies: [][]nameValues{
+ {
+ {name: "searchable",
values: []*nameValue{
+ {name:
"extra_tag", valueType: pbv1.ValueTypeStr, value: []byte("pending")},
+ }},
+ },
+ },
+ fields: []nameValues{{}},
+ },
+ },
+ wantBlocks: []wantBlock{
+ {
+ seriesID: 1,
+ columns: map[string]wantColumn{
+ "searchable/extra_tag#int":
{valueType: pbv1.ValueTypeInt64, value: convert.Int64ToBytes(100)},
+ },
+ },
+ {
+ seriesID: 2,
+ columns: map[string]wantColumn{
+ "searchable/extra_tag#str":
{valueType: pbv1.ValueTypeStr, value: []byte("pending")},
+ },
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpPath, defFn := test.Space(require.New(t))
+ defer defFn()
+ fileSystem := fs.NewLocalFileSystem()
+
+ var fpp []*partWrapper
+ defer func() {
+ for _, pw := range fpp {
+ pw.decRef()
+ }
+ }()
+ for i, dps := range tt.dpsList {
+ mp := generateMemPart()
+ mp.mustInitFromDataPoints(dps)
+ mp.mustFlush(fileSystem, partPath(tmpPath,
uint64(i+1)))
+ filePW := newPartWrapper(nil,
mustOpenFilePart(uint64(i+1), tmpPath, fileSystem))
+ filePW.p.partMetadata.ID = uint64(i + 1)
+ fpp = append(fpp, filePW)
+ releaseMemPart(mp)
+ }
+
+ conflictColumns := collectConflictColumns(fpp)
+ require.NotNil(t, conflictColumns, "expected conflict
columns to be collected")
+ require.Contains(t, conflictColumns, "searchable")
+ require.Contains(t, conflictColumns["searchable"],
"extra_tag")
+
+ closeCh := make(chan struct{})
+ defer close(closeCh)
+ tst := &tsTable{pm: protector.Nop{}}
+ merged, err := tst.mergeParts(fileSystem, closeCh, fpp,
uint64(len(tt.dpsList)+1), tmpPath)
+ require.NoError(t, err)
+ defer merged.decRef()
+
+ pmi := &partMergeIter{}
+ pmi.mustInitFromPart(merged.p)
+ reader := &blockReader{}
+ reader.init([]*partMergeIter{pmi})
+ decoder := generateColumnValuesDecoder()
+ defer releaseColumnValuesDecoder(decoder)
+
+ gotBlocks :=
make(map[common.SeriesID]map[string]wantColumn)
+ for reader.nextBlockMetadata() {
+ reader.loadBlockData(decoder)
+ bp := reader.block
+ cc := make(map[string]wantColumn)
+ for i := range bp.tagFamilies {
+ tf := &bp.tagFamilies[i]
+ for j := range tf.columns {
+ c := &tf.columns[j]
+ require.NotEmpty(t, c.values,
"column %q in family %q should have values", c.name, tf.name)
+ cc[tf.name+"/"+c.name] =
wantColumn{
+ valueType: c.valueType,
+ value: c.values[0],
+ }
+ }
+ }
+ gotBlocks[bp.bm.seriesID] = cc
+ }
+ require.NoError(t, reader.error())
+
+ require.Len(t, gotBlocks, len(tt.wantBlocks))
+ for _, wb := range tt.wantBlocks {
+ got, ok := gotBlocks[wb.seriesID]
+ require.True(t, ok, "missing block for seriesID
%d", wb.seriesID)
+ for key, want := range wb.columns {
+ gotColumn, exists := got[key]
+ require.True(t, exists, "seriesID %d:
missing column %q", wb.seriesID, key)
+ require.Equal(t, want.valueType,
gotColumn.valueType,
+ "seriesID %d: valueType
mismatch for %q", wb.seriesID, key)
+ require.True(t,
reflect.DeepEqual(want.value, gotColumn.value),
+ "seriesID %d: value mismatch
for %q: got %v, want %v",
+ wb.seriesID, key,
gotColumn.value, want.value)
+ }
+ _, hasPlain := got["searchable/extra_tag"]
+ require.False(t, hasPlain,
+ "seriesID %d: plain-named conflict
column should not exist after merge", wb.seriesID)
+ }
+ })
+ }
+}
diff --git a/banyand/measure/metadata_test.go b/banyand/measure/metadata_test.go
index 501580bbe..ef30f0c1a 100644
--- a/banyand/measure/metadata_test.go
+++ b/banyand/measure/metadata_test.go
@@ -20,6 +20,7 @@ package measure_test
import (
"context"
"errors"
+ "fmt"
"strconv"
"time"
@@ -408,7 +409,7 @@ var _ = Describe("Schema Change", func() {
for _, dp := range dataPoints {
for _, tf := range dp.TagFamilies {
- if tf.Name == "default" {
+ if tf.Name == defaultTagFamily {
for _, tag := range
tf.Tags {
innerGm.Expect(tag.Key).NotTo(Equal("extra_tag"),
"deleted tag should not be returned in query results")
@@ -441,7 +442,7 @@ var _ = Describe("Schema Change", func() {
newDataCount := 0
for _, dp := range dataPoints {
for _, tf := range dp.TagFamilies {
- if tf.Name == "default" {
+ if tf.Name == defaultTagFamily {
for _, tag := range
tf.Tags {
if tag.Key ==
"extra_tag" {
if
tag.Value.GetInt() != nil {
@@ -481,7 +482,7 @@ var _ = Describe("Schema Change", func() {
stringCount := 0
for _, dp := range dataPoints {
for _, tf := range dp.TagFamilies {
- if tf.Name == "default" {
+ if tf.Name == defaultTagFamily {
for _, tag := range
tf.Tags {
if tag.Key ==
"extra_tag" {
switch
tag.Value.GetValue().(type) {
@@ -503,6 +504,59 @@ var _ = Describe("Schema Change", func() {
})
})
+ Context("Measure schema with changed tag type after merge", func() {
+ It("querying data should return correct values after parts with
different types are merged", func() {
+ measureName := "schema_change_tag_type_merge"
+ now := timestamp.NowMilli()
+
+ env := setupSchemaChangeMeasure(svcs, measureName,
measureSetupOptions{withExtraTag: true})
+ writeSchemaChangeMeasureData(svcs, measureName,
now.Add(-2*time.Hour), 5,
+ measureWriteDataOptions{withExtraTag: true})
+ changeExtraMeasureTagType(svcs, measureName)
+ writeSchemaChangeMeasureData(svcs, measureName,
now.Add(-1*time.Hour), 3,
+ measureWriteDataOptions{withExtraTagString:
true, entityIDPrefix: "entity_new_"})
+ partCountBeforeMerge, partCountErr :=
getTotalMeasurePartCount(svcs, groupName)
+ Expect(partCountErr).ShouldNot(HaveOccurred())
+ Eventually(func(innerGm Gomega) int64 {
+ currentPartCount, currentPartCountErr :=
getTotalMeasurePartCount(svcs, groupName)
+
innerGm.Expect(currentPartCountErr).ShouldNot(HaveOccurred())
+ return currentPartCount
+ }, flags.EventuallyTimeout).Should(BeNumerically("<",
partCountBeforeMerge))
+
+ Eventually(func(innerGm Gomega) {
+ dataPoints :=
querySchemaChangeMeasureData(svcs, measureName,
+ now.Add(-3*time.Hour), now,
+ []string{"id", "entity_id",
"extra_tag"}, []string{"total"})
+ innerGm.Expect(dataPoints).To(HaveLen(8))
+
+ nullCount := 0
+ stringCount := 0
+ for _, dp := range dataPoints {
+ for _, tf := range dp.TagFamilies {
+ if tf.Name == defaultTagFamily {
+ for _, tag := range
tf.Tags {
+ if tag.Key ==
"extra_tag" {
+ switch
tag.Value.GetValue().(type) {
+ case
*modelv1.TagValue_Null:
+
nullCount++
+ case
*modelv1.TagValue_Str:
+
stringCount++
+ }
+ }
+ }
+ }
+ }
+ }
+ innerGm.Expect(nullCount).To(Equal(5),
+ "old data with int type should return
null after schema changed to STRING and parts merged")
+ innerGm.Expect(stringCount).To(Equal(3),
+ "new data should have string extra_tag
values after merge")
+ }, flags.EventuallyTimeout).Should(Succeed())
+
+ env.cleanup()
+ })
+ })
+
Context("Measure schema with deleted tag family", func() {
It("querying data should succeed after a tag family is
deleted", func() {
measureName := "schema_change_deleted_family"
@@ -1037,3 +1091,20 @@ func queryMeasureWithDeletedFieldProjection(svcs
*services, measureName string,
return errors.New("unexpected data type")
}
}
+
+func getTotalMeasurePartCount(svcs *services, group string) (int64, error) {
+ dataInfo, err := svcs.measure.CollectDataInfo(context.TODO(), group)
+ if err != nil {
+ return 0, fmt.Errorf("collect measure data info: %w", err)
+ }
+ if dataInfo == nil {
+ return 0, errors.New("measure data info is nil")
+ }
+ var total int64
+ for _, seg := range dataInfo.SegmentInfo {
+ for _, shard := range seg.ShardInfo {
+ total += shard.PartCount
+ }
+ }
+ return total, nil
+}
diff --git a/banyand/measure/migration_copy.go
b/banyand/measure/migration_copy.go
index df4c6b5bc..2ba4ae43b 100644
--- a/banyand/measure/migration_copy.go
+++ b/banyand/measure/migration_copy.go
@@ -829,7 +829,7 @@ func slowCopyOnePart(in processPartInput, srcPartID uint64)
(processPartResult,
bm := &bms[j]
bm.tagProjection = in.tagProjection
b := generateBlock()
- b.mustReadFrom(in.decoder, p, *bm)
+ b.mustReadFrom(in.decoder, p, *bm, nil)
liveBlocks = append(liveBlocks, b)
for k := uint64(0); k < bm.count; k++ {
diff --git a/banyand/measure/part.go b/banyand/measure/part.go
index d67488fe8..75cec6aed 100644
--- a/banyand/measure/part.go
+++ b/banyand/measure/part.go
@@ -62,6 +62,7 @@ type part struct {
fileSystem fs.FileSystem
tagFamilyMetadata map[string]fs.Reader
tagFamilies map[string]fs.Reader
+ tagType tagType
seriesMetadata fs.Reader // Optional: series metadata reader
cache storage.Cache
path string
@@ -92,6 +93,7 @@ func (p *part) String() string {
func openMemPart(mp *memPart) *part {
var p part
p.partMetadata = mp.partMetadata
+ p.tagType = mp.tagType
p.primaryBlockMetadata =
mustReadPrimaryBlockMetadata(p.primaryBlockMetadata[:0], &mp.meta)
@@ -325,6 +327,8 @@ func mustOpenFilePart(id uint64, root string, fileSystem
fs.FileSystem) *part {
fs.CleanupLeftoverTmp(fileSystem, partPath)
p.partMetadata.mustReadMetadata(fileSystem, partPath)
p.partMetadata.ID = id
+ p.tagType = make(tagType)
+ p.tagType.mustReadTagType(fileSystem, partPath)
metaPath := path.Join(partPath, metaFilename)
pr := mustOpenReader(metaPath, fileSystem)
diff --git a/banyand/measure/part_metadata.go b/banyand/measure/part_metadata.go
index cf6f0c661..fdad43001 100644
--- a/banyand/measure/part_metadata.go
+++ b/banyand/measure/part_metadata.go
@@ -186,6 +186,27 @@ func (tt tagType) unmarshal(src []byte) error {
return nil
}
+func (tt tagType) mustReadTagType(fileSystem fs.FileSystem, partPath string) {
+ tt.reset()
+ tagTypePath := filepath.Join(partPath, tagTypeFilename)
+ data, err := fileSystem.Read(tagTypePath)
+ if err != nil {
+ var fsErr *fs.FileSystemError
+ if errors.As(err, &fsErr) && fsErr.Code == fs.IsNotExistError {
+ return
+ }
+ logger.Panicf("cannot read %s: %s", tagTypePath, err)
+ return
+ }
+ if len(data) == 0 {
+ return
+ }
+ if err = tt.unmarshal(data); err != nil {
+ logger.Panicf("cannot parse %q: %s", tagTypePath, err)
+ return
+ }
+}
+
func (tt tagType) mustWriteTagType(fileSystem fs.FileSystem, partPath string) {
if len(tt) == 0 {
return
diff --git a/banyand/measure/svc_standalone.go
b/banyand/measure/svc_standalone.go
index 8be02a81c..54d145054 100644
--- a/banyand/measure/svc_standalone.go
+++ b/banyand/measure/svc_standalone.go
@@ -180,6 +180,7 @@ func (s *standalone) FlagSet() *run.FlagSet {
flagS.DurationVar(&s.option.memWaitTimeout,
"measure-lifecycle-receive-mem-wait-timeout", 5*time.Minute,
"max time the migration receiver waits for memory to recover
before introducing an external segment")
s.option.mergePolicy = newDefaultMergePolicy()
+ flagS.IntVar(&s.option.mergePolicy.maxParts, "measure-max-merge-parts",
s.option.mergePolicy.maxParts, "the maximum number of parts to merge at once")
flagS.VarP(&s.option.mergePolicy.maxFanOutSize,
"measure-max-fan-out-size", "", "the upper bound of a single file size after
merge of measure")
s.option.seriesCacheMaxSize = run.Bytes(32 << 20)
flagS.VarP(&s.option.seriesCacheMaxSize,
"measure-series-cache-max-size", "", "the max size of series cache in each
group")
diff --git a/banyand/stream/block.go b/banyand/stream/block.go
index 8c83f0d28..f51fcdefa 100644
--- a/banyand/stream/block.go
+++ b/banyand/stream/block.go
@@ -219,6 +219,7 @@ func (b *block) marshalTagFamily(tf tagFamily, bm
*blockMetadata, ww *writers) {
func (b *block) unmarshalTagFamily(decoder *encoding.BytesBlockDecoder,
tfIndex int, name string,
tagFamilyMetadataBlock *dataBlock, tagProjection []string, metaReader,
valueReader fs.Reader, count int,
+ schemaTagTypes map[string]pbv1.ValueType,
) {
if len(tagProjection) < 1 {
return
@@ -237,13 +238,28 @@ func (b *block) unmarshalTagFamily(decoder
*encoding.BytesBlockDecoder, tfIndex
cc := b.tagFamilies[tfIndex].resizeTags(len(tagProjection))
NEXT:
for j := range tagProjection {
+ tp := tagProjection[j]
+ schemaType, hasSchemaType := schemaTagTypes[tp]
+ if hasSchemaType {
+ typedName := encodeTypedTag(tp, schemaType)
+ for i := range tfm.tagMetadata {
+ if tfm.tagMetadata[i].name == typedName {
+ cc[j].mustReadValues(decoder,
valueReader, tfm.tagMetadata[i], uint64(b.Len()))
+ cc[j].name = tp
+ continue NEXT
+ }
+ }
+ }
for i := range tfm.tagMetadata {
- if tagProjection[j] == tfm.tagMetadata[i].name {
+ if tfm.tagMetadata[i].name == tp {
+ if hasSchemaType &&
tfm.tagMetadata[i].valueType != schemaType {
+ continue
+ }
cc[j].mustReadValues(decoder, valueReader,
tfm.tagMetadata[i], uint64(b.Len()))
continue NEXT
}
}
- cc[j].name = tagProjection[j]
+ cc[j].name = tp
cc[j].valueType = pbv1.ValueTypeUnknown
cc[j].resizeValues(count)
for k := range cc[j].values {
@@ -297,7 +313,7 @@ func (b *block) uncompressedSizeBytes() uint64 {
return n
}
-func (b *block) mustReadFrom(decoder *encoding.BytesBlockDecoder, p *part, bm
blockMetadata) {
+func (b *block) mustReadFrom(decoder *encoding.BytesBlockDecoder, p *part, bm
blockMetadata, schemaTagTypes map[string]pbv1.ValueType) {
b.reset()
b.timestamps, b.elementIDs = mustReadTimestampsFrom(b.timestamps,
b.elementIDs, &bm.timestamps, int(bm.count), p.timestamps)
@@ -321,7 +337,7 @@ func (b *block) mustReadFrom(decoder
*encoding.BytesBlockDecoder, p *part, bm bl
}
b.unmarshalTagFamily(decoder, i, name, block,
bm.tagProjection[i].Names, p.tagFamilyMetadata[name],
- p.tagFamilies[name], int(bm.count))
+ p.tagFamilies[name], int(bm.count), schemaTagTypes)
}
}
@@ -576,7 +592,7 @@ func (bc *blockCursor) loadData(tmpBlock *block) bool {
}
bc.bm.tagFamilies = tf
- tmpBlock.mustReadFrom(&bc.tagValuesDecoder, bc.p, bc.bm)
+ tmpBlock.mustReadFrom(&bc.tagValuesDecoder, bc.p, bc.bm,
bc.schemaTagTypes)
if len(tmpBlock.timestamps) == 0 {
return false
}
@@ -704,16 +720,20 @@ func fastTagAppend(bi, b *blockPointer, offset int) error
{
if len(bi.tagFamilies) != len(b.tagFamilies) {
return fmt.Errorf("unexpected number of tag families: got %d;
want %d", len(b.tagFamilies), len(bi.tagFamilies))
}
+ existingDataSize := len(bi.timestamps)
for i := range bi.tagFamilies {
if bi.tagFamilies[i].name != b.tagFamilies[i].name {
+ rollbackFastTagAppend(bi, existingDataSize)
return fmt.Errorf("unexpected tag family name: got %q;
want %q", b.tagFamilies[i].name, bi.tagFamilies[i].name)
}
if len(bi.tagFamilies[i].tags) != len(b.tagFamilies[i].tags) {
+ rollbackFastTagAppend(bi, existingDataSize)
return fmt.Errorf("unexpected number of tags for tag
family %q: got %d; want %d",
bi.tagFamilies[i].name,
len(b.tagFamilies[i].tags), len(bi.tagFamilies[i].tags))
}
for j := range bi.tagFamilies[i].tags {
if bi.tagFamilies[i].tags[j].name !=
b.tagFamilies[i].tags[j].name {
+ rollbackFastTagAppend(bi, existingDataSize)
return fmt.Errorf("unexpected tag name for tag
family %q: got %q; want %q",
bi.tagFamilies[i].name,
b.tagFamilies[i].tags[j].name, bi.tagFamilies[i].tags[j].name)
}
@@ -724,6 +744,16 @@ func fastTagAppend(bi, b *blockPointer, offset int) error {
return nil
}
+func rollbackFastTagAppend(bi *blockPointer, existingDataSize int) {
+ for i := range bi.tagFamilies {
+ for j := range bi.tagFamilies[i].tags {
+ if len(bi.tagFamilies[i].tags[j].values) >
existingDataSize {
+ bi.tagFamilies[i].tags[j].values =
bi.tagFamilies[i].tags[j].values[:existingDataSize]
+ }
+ }
+ }
+}
+
func fullTagAppend(bi, b *blockPointer, offset int) {
existDataSize := len(bi.timestamps)
appendTagFamilies := func(tf tagFamily) {
diff --git a/banyand/stream/block_test.go b/banyand/stream/block_test.go
index b5195d19e..5976fc004 100644
--- a/banyand/stream/block_test.go
+++ b/banyand/stream/block_test.go
@@ -288,7 +288,7 @@ func Test_marshalAndUnmarshalTagFamily(t *testing.T) {
unmarshaled.timestamps = make([]int64, len(b.timestamps))
unmarshaled.resizeTagFamilies(1)
- unmarshaled.unmarshalTagFamily(decoder, tfIndex, name,
bm.getTagFamilyMetadata(name), tagProjection[name], metaBuffer, dataBuffer, 1)
+ unmarshaled.unmarshalTagFamily(decoder, tfIndex, name,
bm.getTagFamilyMetadata(name), tagProjection[name], metaBuffer, dataBuffer, 1,
nil)
if diff := cmp.Diff(unmarshaled.tagFamilies[0], b.tagFamilies[0],
cmp.AllowUnexported(tagFamily{}, tag{}, tagFilter{}),
@@ -377,7 +377,7 @@ func Test_marshalAndUnmarshalBlock(t *testing.T) {
})
}
bm.tagProjection = tp
- unmarshaled.mustReadFrom(decoder, p, bm)
+ unmarshaled.mustReadFrom(decoder, p, bm, nil)
// blockMetadata is using a map, so the order of tag families is not
guaranteed
unmarshaled.sortTagFamilies()
diff --git a/banyand/stream/merger.go b/banyand/stream/merger.go
index 8d64fa7d8..320308a90 100644
--- a/banyand/stream/merger.go
+++ b/banyand/stream/merger.go
@@ -28,6 +28,7 @@ import (
"github.com/apache/skywalking-banyandb/pkg/cgroups"
"github.com/apache/skywalking-banyandb/pkg/encoding"
"github.com/apache/skywalking-banyandb/pkg/fs"
+ pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
"github.com/apache/skywalking-banyandb/pkg/watcher"
)
@@ -235,6 +236,7 @@ func (tst *tsTable) mergeParts(fileSystem fs.FileSystem,
closeCh <-chan struct{}
return nil, errNoPartToMerge
}
dstPath := partPath(root, partID)
+ conflictTags := collectConflictTags(parts)
var totalSize int64
pii := make([]*partMergeIter, 0, len(parts))
for i := range parts {
@@ -249,7 +251,7 @@ func (tst *tsTable) mergeParts(fileSystem fs.FileSystem,
closeCh <-chan struct{}
bw := generateBlockWriter()
bw.mustInitForFilePart(fileSystem, dstPath, shouldCache)
- pm, mergedTagType, err := mergeBlocks(closeCh, bw, br)
+ pm, mergedTagType, err := mergeBlocks(closeCh, bw, br, conflictTags)
releaseBlockWriter(bw)
releaseBlockReader(br)
for i := range pii {
@@ -269,7 +271,7 @@ func (tst *tsTable) mergeParts(fileSystem fs.FileSystem,
closeCh <-chan struct{}
var errClosed = fmt.Errorf("the merger is closed")
-func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter, br *blockReader)
(*partMetadata, *tagType, error) {
+func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter, br *blockReader,
conflictTags map[string]map[string]struct{}) (*partMetadata, *tagType, error) {
pendingBlockIsEmpty := true
pendingBlock := generateBlockPointer()
defer releaseBlockPointer(pendingBlock)
@@ -287,6 +289,10 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
decoder = nil
}
}
+ loadAndRename := func() {
+ br.loadBlockData(getDecoder())
+ renameConflictTags(&br.block.block, conflictTags)
+ }
for br.nextBlockMetadata() {
select {
case <-closeCh:
@@ -296,7 +302,7 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
b := br.block
if pendingBlockIsEmpty {
- br.loadBlockData(getDecoder())
+ loadAndRename()
pendingBlock.copyFrom(b)
pendingBlockIsEmpty = false
continue
@@ -307,7 +313,7 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
bw.mustWriteBlock(pendingBlock.bm.seriesID,
&pendingBlock.block)
releaseDecoder()
pendingBlock.reset()
- br.loadBlockData(getDecoder())
+ loadAndRename()
pendingBlock.copyFrom(b)
continue
}
@@ -318,7 +324,7 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
}
tmpBlock.reset()
tmpBlock.bm.seriesID = b.bm.seriesID
- br.loadBlockData(getDecoder())
+ loadAndRename()
mergeTwoBlocks(tmpBlock, pendingBlock, b)
if tmpBlock.uncompressedSizeBytes() <= maxUncompressedBlockSize
{
if len(tmpBlock.timestamps) == 0 {
@@ -346,6 +352,62 @@ func mergeBlocks(closeCh <-chan struct{}, bw *blockWriter,
br *blockReader) (*pa
return &result, &mergedTagType, nil
}
+func renameConflictTags(b *block, conflictTags map[string]map[string]struct{})
{
+ if len(conflictTags) == 0 {
+ return
+ }
+ for i := range b.tagFamilies {
+ tags := conflictTags[b.tagFamilies[i].name]
+ if tags == nil {
+ continue
+ }
+ tt := b.tagFamilies[i].tags
+ for j := range tt {
+ if _, ok := tags[tt[j].name]; ok {
+ tt[j].name = encodeTypedTag(tt[j].name,
tt[j].valueType)
+ }
+ }
+ }
+}
+
+func collectConflictTags(parts []*partWrapper) map[string]map[string]struct{} {
+ familyTagTypes :=
make(map[string]map[string]map[pbv1.ValueType]struct{})
+ for _, pw := range parts {
+ for tf, tt := range pw.p.tagType {
+ tagTypes := familyTagTypes[tf]
+ if tagTypes == nil {
+ tagTypes =
make(map[string]map[pbv1.ValueType]struct{})
+ familyTagTypes[tf] = tagTypes
+ }
+ for name, vt := range tt {
+ decoded := decodeTypedTag(name)
+ valueTypes := tagTypes[decoded]
+ if valueTypes == nil {
+ valueTypes =
make(map[pbv1.ValueType]struct{})
+ tagTypes[decoded] = valueTypes
+ }
+ valueTypes[vt] = struct{}{}
+ }
+ }
+ }
+ var conflictTags map[string]map[string]struct{}
+ for tf, tagTypes := range familyTagTypes {
+ for name, valueTypes := range tagTypes {
+ if len(valueTypes) <= 1 {
+ continue
+ }
+ if conflictTags == nil {
+ conflictTags =
make(map[string]map[string]struct{})
+ }
+ if conflictTags[tf] == nil {
+ conflictTags[tf] = make(map[string]struct{})
+ }
+ conflictTags[tf][name] = struct{}{}
+ }
+ }
+ return conflictTags
+}
+
func mergeTwoBlocks(target, left, right *blockPointer) {
appendIfEmpty := func(ib1, ib2 *blockPointer) bool {
if ib1.idx >= len(ib1.timestamps) {
diff --git a/banyand/stream/merger_test.go b/banyand/stream/merger_test.go
index 5062f8f03..627dcee7f 100644
--- a/banyand/stream/merger_test.go
+++ b/banyand/stream/merger_test.go
@@ -27,7 +27,9 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require"
+ "github.com/apache/skywalking-banyandb/api/common"
"github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/convert"
"github.com/apache/skywalking-banyandb/pkg/fs"
pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
"github.com/apache/skywalking-banyandb/pkg/test"
@@ -368,3 +370,187 @@ func Test_mergeParts(t *testing.T) {
})
}
}
+
+func Test_mergePartsWithConflictingTagTypes(t *testing.T) {
+ type wantTag struct {
+ value []byte
+ valueType pbv1.ValueType
+ }
+ type wantBlock struct {
+ tags map[string]wantTag
+ seriesID common.SeriesID
+ }
+ tests := []struct {
+ name string
+ esList []*elements
+ wantBlocks []wantBlock
+ }{
+ {
+ name: "two parts with same tag name and conflicting
valueTypes",
+ esList: []*elements{
+ {
+ seriesIDs: []common.SeriesID{1},
+ timestamps: []int64{1},
+ elementIDs: []uint64{1},
+ tagFamilies: [][]tagValues{
+ {
+ {tag: "searchable",
values: []*tagValue{
+ {tag:
"extra_tag", valueType: pbv1.ValueTypeInt64, value: convert.Int64ToBytes(100)},
+ }},
+ },
+ },
+ },
+ {
+ seriesIDs: []common.SeriesID{2},
+ timestamps: []int64{2},
+ elementIDs: []uint64{2},
+ tagFamilies: [][]tagValues{
+ {
+ {tag: "searchable",
values: []*tagValue{
+ {tag:
"extra_tag", valueType: pbv1.ValueTypeStr, value: []byte("active")},
+ }},
+ },
+ },
+ },
+ },
+ wantBlocks: []wantBlock{
+ {
+ seriesID: 1,
+ tags: map[string]wantTag{
+ "searchable/extra_tag#int":
{valueType: pbv1.ValueTypeInt64, value: convert.Int64ToBytes(100)},
+ },
+ },
+ {
+ seriesID: 2,
+ tags: map[string]wantTag{
+ "searchable/extra_tag#str":
{valueType: pbv1.ValueTypeStr, value: []byte("active")},
+ },
+ },
+ },
+ },
+ {
+ name: "merge typed-suffix part with plain part",
+ esList: []*elements{
+ {
+ seriesIDs: []common.SeriesID{1},
+ timestamps: []int64{1},
+ elementIDs: []uint64{1},
+ tagFamilies: [][]tagValues{
+ {
+ {tag: "searchable",
values: []*tagValue{
+ {tag:
"extra_tag#int", valueType: pbv1.ValueTypeInt64, value:
convert.Int64ToBytes(100)},
+ }},
+ },
+ },
+ },
+ {
+ seriesIDs: []common.SeriesID{2},
+ timestamps: []int64{2},
+ elementIDs: []uint64{2},
+ tagFamilies: [][]tagValues{
+ {
+ {tag: "searchable",
values: []*tagValue{
+ {tag:
"extra_tag", valueType: pbv1.ValueTypeStr, value: []byte("pending")},
+ }},
+ },
+ },
+ },
+ },
+ wantBlocks: []wantBlock{
+ {
+ seriesID: 1,
+ tags: map[string]wantTag{
+ "searchable/extra_tag#int":
{valueType: pbv1.ValueTypeInt64, value: convert.Int64ToBytes(100)},
+ },
+ },
+ {
+ seriesID: 2,
+ tags: map[string]wantTag{
+ "searchable/extra_tag#str":
{valueType: pbv1.ValueTypeStr, value: []byte("pending")},
+ },
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpPath, defFn := test.Space(require.New(t))
+ defer defFn()
+ fileSystem := fs.NewLocalFileSystem()
+
+ var fpp []*partWrapper
+ defer func() {
+ for _, pw := range fpp {
+ pw.decRef()
+ }
+ }()
+ for i, es := range tt.esList {
+ mp := generateMemPart()
+ mp.mustInitFromElements(es)
+ mp.mustFlush(fileSystem, partPath(tmpPath,
uint64(i+1)))
+ filePW := newPartWrapper(nil,
mustOpenFilePart(uint64(i+1), tmpPath, fileSystem))
+ filePW.p.partMetadata.ID = uint64(i + 1)
+ fpp = append(fpp, filePW)
+ releaseMemPart(mp)
+ }
+
+ conflictTags := collectConflictTags(fpp)
+ require.NotNil(t, conflictTags, "expected conflict tags
to be collected")
+ require.Contains(t, conflictTags, "searchable")
+ require.Contains(t, conflictTags["searchable"],
"extra_tag")
+
+ closeCh := make(chan struct{})
+ defer close(closeCh)
+ tst := &tsTable{pm: protector.Nop{}}
+ merged, err := tst.mergeParts(fileSystem, closeCh, fpp,
uint64(len(tt.esList)+1), tmpPath)
+ require.NoError(t, err)
+ defer merged.decRef()
+
+ pmi := &partMergeIter{}
+ pmi.mustInitFromPart(merged.p)
+ reader := &blockReader{}
+ reader.init([]*partMergeIter{pmi})
+ decoder := generateColumnValuesDecoder()
+ defer releaseColumnValuesDecoder(decoder)
+
+ gotBlocks :=
make(map[common.SeriesID]map[string]wantTag)
+ for reader.nextBlockMetadata() {
+ reader.loadBlockData(decoder)
+ bp := reader.block
+ tt := make(map[string]wantTag)
+ for i := range bp.tagFamilies {
+ tf := &bp.tagFamilies[i]
+ for j := range tf.tags {
+ tg := &tf.tags[j]
+ require.NotEmpty(t, tg.values,
"tag %q in family %q should have values", tg.name, tf.name)
+ tt[tf.name+"/"+tg.name] =
wantTag{
+ valueType: tg.valueType,
+ value: tg.values[0],
+ }
+ }
+ }
+ gotBlocks[bp.bm.seriesID] = tt
+ }
+ require.NoError(t, reader.error())
+
+ require.Len(t, gotBlocks, len(tt.wantBlocks))
+ for _, wb := range tt.wantBlocks {
+ got, ok := gotBlocks[wb.seriesID]
+ require.True(t, ok, "missing block for seriesID
%d", wb.seriesID)
+ for key, want := range wb.tags {
+ gotTag, exists := got[key]
+ require.True(t, exists, "seriesID %d:
missing tag %q", wb.seriesID, key)
+ require.Equal(t, want.valueType,
gotTag.valueType,
+ "seriesID %d: valueType
mismatch for %q", wb.seriesID, key)
+ require.True(t,
reflect.DeepEqual(want.value, gotTag.value),
+ "seriesID %d: value mismatch
for %q: got %v, want %v",
+ wb.seriesID, key, gotTag.value,
want.value)
+ }
+ _, hasPlain := got["searchable/extra_tag"]
+ require.False(t, hasPlain,
+ "seriesID %d: plain-named conflict tag
should not exist after merge", wb.seriesID)
+ }
+ })
+ }
+}
diff --git a/banyand/stream/metadata_test.go b/banyand/stream/metadata_test.go
index f813df7b2..87b9bb0a8 100644
--- a/banyand/stream/metadata_test.go
+++ b/banyand/stream/metadata_test.go
@@ -19,6 +19,8 @@ package stream_test
import (
"context"
+ "errors"
+ "fmt"
"strconv"
"time"
@@ -403,7 +405,7 @@ var _ = Describe("Schema Change", func() {
for _, elem := range elements {
for _, tf := range elem.TagFamilies {
- if tf.Name == "searchable" {
+ if tf.Name ==
searchableTagFamily {
for _, tag := range
tf.Tags {
innerGm.Expect(tag.Key).NotTo(Equal("extra_tag"),
"deleted tag should not be returned in query results")
@@ -436,7 +438,7 @@ var _ = Describe("Schema Change", func() {
newDataCount := 0
for _, elem := range elements {
for _, tf := range elem.TagFamilies {
- if tf.Name == "searchable" {
+ if tf.Name ==
searchableTagFamily {
for _, tag := range
tf.Tags {
if tag.Key ==
"extra_tag" {
if
tag.Value.GetInt() != nil {
@@ -476,7 +478,7 @@ var _ = Describe("Schema Change", func() {
stringCount := 0
for _, elem := range elements {
for _, tf := range elem.TagFamilies {
- if tf.Name == "searchable" {
+ if tf.Name ==
searchableTagFamily {
for _, tag := range
tf.Tags {
if tag.Key ==
"extra_tag" {
switch
tag.Value.GetValue().(type) {
@@ -498,6 +500,59 @@ var _ = Describe("Schema Change", func() {
})
})
+ Context("Stream schema with changed tag type after merge", func() {
+ It("querying data should return correct values after parts with
different types are merged", func() {
+ streamName := "schema_change_tag_type_merge"
+ now := timestamp.NowMilli()
+
+ env := setupSchemaChangeStream(svcs, streamName,
groupName, streamSetupOptions{withExtraTag: true})
+ writeSchemaChangeData(svcs, streamName, groupName,
now.Add(-2*time.Hour), 5,
+ writeDataOptions{extraTag: extraTagInt})
+ changeExtraTagType(svcs, streamName, groupName)
+ writeSchemaChangeData(svcs, streamName, groupName,
now.Add(-1*time.Hour), 3,
+ writeDataOptions{extraTag: extraTagString,
traceIDPrefix: "trace_new_", elementIDOffset: 5})
+ partCountBeforeMerge, partCountErr :=
getTotalStreamPartCount(svcs, groupName)
+ Expect(partCountErr).ShouldNot(HaveOccurred())
+ Eventually(func(innerGm Gomega) int64 {
+ currentPartCount, currentPartCountErr :=
getTotalStreamPartCount(svcs, groupName)
+
innerGm.Expect(currentPartCountErr).ShouldNot(HaveOccurred())
+ return currentPartCount
+ }, flags.EventuallyTimeout).Should(BeNumerically("<",
partCountBeforeMerge))
+
+ Eventually(func(innerGm Gomega) {
+ elements := querySchemaChangeData(svcs,
streamName, groupName,
+ now.Add(-3*time.Hour), now,
+ []string{"trace_id", "service_id",
"duration", "extra_tag"}, nil)
+ innerGm.Expect(elements).To(HaveLen(8))
+
+ nullCount := 0
+ stringCount := 0
+ for _, elem := range elements {
+ for _, tf := range elem.TagFamilies {
+ if tf.Name ==
searchableTagFamily {
+ for _, tag := range
tf.Tags {
+ if tag.Key ==
"extra_tag" {
+ switch
tag.Value.GetValue().(type) {
+ case
*modelv1.TagValue_Null:
+
nullCount++
+ case
*modelv1.TagValue_Str:
+
stringCount++
+ }
+ }
+ }
+ }
+ }
+ }
+ innerGm.Expect(nullCount).To(Equal(5),
+ "old data with int type should return
null after schema changed to STRING and parts merged")
+ innerGm.Expect(stringCount).To(Equal(3),
+ "new data should have string extra_tag
values after merge")
+ }, flags.EventuallyTimeout).Should(Succeed())
+
+ env.cleanup()
+ })
+ })
+
Context("Stream schema with deleted tag in query", func() {
It("querying data should fail if the condition includes a
deleted tag", func() {
streamName := "schema_change_filter_deleted"
@@ -609,6 +664,8 @@ var _ = Describe("Schema Change", func() {
})
})
+const searchableTagFamily = "searchable"
+
type extraTagType int
const (
@@ -909,3 +966,20 @@ func querySchemaChangeData(svcs *services, name, group
string, begin, end time.T
}).WithTimeout(flags.EventuallyTimeout).Should(BeTrue())
return resp.Elements
}
+
+func getTotalStreamPartCount(svcs *services, group string) (int64, error) {
+ dataInfo, err := svcs.stream.CollectDataInfo(context.TODO(), group)
+ if err != nil {
+ return 0, fmt.Errorf("collect stream data info: %w", err)
+ }
+ if dataInfo == nil {
+ return 0, errors.New("stream data info is nil")
+ }
+ var total int64
+ for _, seg := range dataInfo.SegmentInfo {
+ for _, shard := range seg.ShardInfo {
+ total += shard.PartCount
+ }
+ }
+ return total, nil
+}
diff --git a/banyand/stream/migration_copy.go b/banyand/stream/migration_copy.go
index f1c158e48..f85b43ba5 100644
--- a/banyand/stream/migration_copy.go
+++ b/banyand/stream/migration_copy.go
@@ -767,7 +767,7 @@ func slowCopyOneStreamPart(in streamProcessPartInput,
srcPartID uint64) (streamP
bm := &bms[j]
bm.tagProjection = in.tagProjection
b := generateBlock()
- b.mustReadFrom(in.decoder, p, *bm)
+ b.mustReadFrom(in.decoder, p, *bm, nil)
liveBlocks = append(liveBlocks, b)
for k := uint64(0); k < bm.count; k++ {
diff --git a/banyand/stream/migration_element_index.go
b/banyand/stream/migration_element_index.go
index 615d92b71..3a4d426ec 100644
--- a/banyand/stream/migration_element_index.go
+++ b/banyand/stream/migration_element_index.go
@@ -561,7 +561,7 @@ func rebuildOneStreamPartIntoDocs(r rebuildOnePartInput)
(missingRows, missingSe
}
bm.tagProjection = in.tagProjection
b := generateBlock()
- b.mustReadFrom(in.decoder, p, *bm)
+ b.mustReadFrom(in.decoder, p, *bm, nil)
for k := uint64(0); k < bm.count; k++ {
appendStreamElementIndexDoc(streamElementIndexRowInput{
in: in,
diff --git a/banyand/stream/part.go b/banyand/stream/part.go
index 7fac7392f..84b94c3a0 100644
--- a/banyand/stream/part.go
+++ b/banyand/stream/part.go
@@ -63,6 +63,7 @@ type part struct {
tagFamilyMetadata map[string]fs.Reader
tagFamilies map[string]fs.Reader
tagFamilyFilter map[string]fs.Reader
+ tagType tagType
seriesMetadata fs.Reader // Optional: series metadata reader
path string
primaryBlockMetadata []primaryBlockMetadata
@@ -93,6 +94,7 @@ func (p *part) String() string {
func openMemPart(mp *memPart) *part {
var p part
p.partMetadata = mp.partMetadata
+ p.tagType = mp.tagType
p.primaryBlockMetadata =
mustReadPrimaryBlockMetadata(p.primaryBlockMetadata[:0], &mp.meta)
@@ -326,6 +328,8 @@ func mustOpenFilePart(id uint64, root string, fileSystem
fs.FileSystem) *part {
fs.CleanupLeftoverTmp(fileSystem, partPath)
p.partMetadata.mustReadMetadata(fileSystem, partPath)
p.partMetadata.ID = id
+ p.tagType = make(tagType)
+ p.tagType.mustReadTagType(fileSystem, partPath)
metaPath := path.Join(partPath, metaFilename)
pr := mustOpenReader(metaPath, fileSystem)
diff --git a/banyand/stream/part_metadata.go b/banyand/stream/part_metadata.go
index c4edcfdd3..d6dde20e0 100644
--- a/banyand/stream/part_metadata.go
+++ b/banyand/stream/part_metadata.go
@@ -186,6 +186,27 @@ func (tt tagType) unmarshal(src []byte) error {
return nil
}
+func (tt tagType) mustReadTagType(fileSystem fs.FileSystem, partPath string) {
+ tt.reset()
+ tagTypePath := filepath.Join(partPath, tagTypeFilename)
+ data, err := fileSystem.Read(tagTypePath)
+ if err != nil {
+ var fsErr *fs.FileSystemError
+ if errors.As(err, &fsErr) && fsErr.Code == fs.IsNotExistError {
+ return
+ }
+ logger.Panicf("cannot read %s: %s", tagTypePath, err)
+ return
+ }
+ if len(data) == 0 {
+ return
+ }
+ if err = tt.unmarshal(data); err != nil {
+ logger.Panicf("cannot parse %q: %s", tagTypePath, err)
+ return
+ }
+}
+
func (tt tagType) mustWriteTagType(fileSystem fs.FileSystem, partPath string) {
if len(tt) == 0 {
return
diff --git a/banyand/stream/stream_suite_test.go
b/banyand/stream/stream_suite_test.go
index 5c02d073c..5875c4571 100644
--- a/banyand/stream/stream_suite_test.go
+++ b/banyand/stream/stream_suite_test.go
@@ -97,7 +97,7 @@ func setUp() (*services, func()) {
)
rootPath, deferFunc, err := test.NewSpace()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
- flags = append(flags, "--stream-root-path="+rootPath)
+ flags = append(flags, "--stream-root-path="+rootPath,
"--stream-max-merge-parts=2")
moduleDeferFunc := test.SetupModules(
flags,
pipeline,
diff --git a/banyand/stream/svc_standalone.go b/banyand/stream/svc_standalone.go
index 86172aa1d..4bf812554 100644
--- a/banyand/stream/svc_standalone.go
+++ b/banyand/stream/svc_standalone.go
@@ -180,6 +180,7 @@ func (s *standalone) FlagSet() *run.FlagSet {
flagS.DurationVar(&s.option.memWaitTimeout,
"stream-lifecycle-receive-mem-wait-timeout", 5*time.Minute,
"max time the migration receiver waits for memory to recover
before introducing an external segment")
s.option.mergePolicy = newDefaultMergePolicy()
+ flagS.IntVar(&s.option.mergePolicy.maxParts, "stream-max-merge-parts",
s.option.mergePolicy.maxParts, "the maximum number of parts to merge at once")
flagS.VarP(&s.option.mergePolicy.maxFanOutSize,
"stream-max-fan-out-size", "", "the upper bound of a single file size after
merge of stream")
s.option.seriesCacheMaxSize = run.Bytes(32 << 20)
flagS.VarP(&s.option.seriesCacheMaxSize,
"stream-series-cache-max-size", "", "the max size of series cache in each
group")
diff --git a/banyand/stream/tag.go b/banyand/stream/tag.go
index 769c67c99..31a3a9e4f 100644
--- a/banyand/stream/tag.go
+++ b/banyand/stream/tag.go
@@ -18,6 +18,8 @@
package stream
import (
+ "strings"
+
internalencoding
"github.com/apache/skywalking-banyandb/banyand/internal/encoding"
"github.com/apache/skywalking-banyandb/pkg/bytes"
"github.com/apache/skywalking-banyandb/pkg/convert"
@@ -28,6 +30,48 @@ import (
pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
)
+const typedTagSeparator = "#"
+
+var (
+ valueTypeToSuffix = map[pbv1.ValueType]string{
+ pbv1.ValueTypeStr: "str",
+ pbv1.ValueTypeInt64: "int",
+ pbv1.ValueTypeFloat64: "float",
+ pbv1.ValueTypeBinaryData: "bin",
+ pbv1.ValueTypeStrArr: "str_arr",
+ pbv1.ValueTypeInt64Arr: "int_arr",
+ pbv1.ValueTypeTimestamp: "ts",
+ pbv1.ValueTypeUnknown: "",
+ }
+ suffixToValueType = map[string]pbv1.ValueType{
+ "str": pbv1.ValueTypeStr,
+ "int": pbv1.ValueTypeInt64,
+ "float": pbv1.ValueTypeFloat64,
+ "bin": pbv1.ValueTypeBinaryData,
+ "str_arr": pbv1.ValueTypeStrArr,
+ "int_arr": pbv1.ValueTypeInt64Arr,
+ "ts": pbv1.ValueTypeTimestamp,
+ }
+)
+
+func encodeTypedTag(name string, vt pbv1.ValueType) string {
+ suffix, ok := valueTypeToSuffix[vt]
+ if !ok || suffix == "" {
+ return name
+ }
+ return name + typedTagSeparator + suffix
+}
+
+func decodeTypedTag(key string) string {
+ for suffix := range suffixToValueType {
+ sepSuffix := typedTagSeparator + suffix
+ if strings.HasSuffix(key, sepSuffix) {
+ return key[:len(key)-len(sepSuffix)]
+ }
+ }
+ return key
+}
+
type tag struct {
uniqueValues map[string]struct{}
min []byte
diff --git a/banyand/stream/tag_test.go b/banyand/stream/tag_test.go
index acb0e08f2..1f2e544a2 100644
--- a/banyand/stream/tag_test.go
+++ b/banyand/stream/tag_test.go
@@ -261,3 +261,22 @@ func TestTag_HighCardinalityStringEncoding(t *testing.T) {
})
}
}
+
+func TestEncodeTypedTag(t *testing.T) {
+ assert.Equal(t, "state#int", encodeTypedTag("state",
pbv1.ValueTypeInt64))
+ assert.Equal(t, "state#str", encodeTypedTag("state", pbv1.ValueTypeStr))
+ assert.Equal(t, "http.status#int", encodeTypedTag("http.status",
pbv1.ValueTypeInt64))
+ assert.Equal(t, "data#bin", encodeTypedTag("data",
pbv1.ValueTypeBinaryData))
+ assert.Equal(t, "unknown", encodeTypedTag("unknown",
pbv1.ValueTypeUnknown))
+}
+
+func TestDecodeTypedTag(t *testing.T) {
+ assert.Equal(t, "state", decodeTypedTag("state#int"))
+ assert.Equal(t, "state", decodeTypedTag("state#str"))
+ assert.Equal(t, "http.status", decodeTypedTag("http.status#int"))
+ assert.Equal(t, "http.status", decodeTypedTag("http.status"))
+ assert.Equal(t, "simple", decodeTypedTag("simple"))
+ assert.Equal(t, "my.str", decodeTypedTag("my.str"))
+ assert.Equal(t, "arr.int", decodeTypedTag("arr.int"))
+ assert.Equal(t, "data.bin", decodeTypedTag("data.bin"))
+}