diff --git a/cockroachkvs/blockproperties.go b/cockroachkvs/blockproperties.go index 08ca0e7914d..bb75c41b3bf 100644 --- a/cockroachkvs/blockproperties.go +++ b/cockroachkvs/blockproperties.go @@ -7,6 +7,7 @@ package cockroachkvs import ( "encoding/binary" "math" + "unsafe" "github.com/cockroachdb/errors" "github.com/cockroachdb/pebble/sstable" @@ -144,6 +145,82 @@ func mapSuffixToInterval(b []byte) (sstable.BlockInterval, error) { return sstable.BlockInterval{}, nil } +// MakeSuffixMaskBlockPropertyFilter creates a block property filter for use +// with the SuffixMask transform. It skips blocks where all MVCC wall times +// are strictly greater than the bound's wall time. +func MakeSuffixMaskBlockPropertyFilter(suffixUpperBound []byte) sstable.BlockPropertyFilter { + wall, _, err := DecodeMVCCTimestampSuffix(suffixUpperBound) + if err != nil || wall == 0 { + return nil + } + return NewMVCCTimeIntervalFilter(0, wall) +} + +// SuffixRangeIntersectsTable reports whether a table's MVCCTimeInterval +// block-property aggregate could intersect a DeleteSuffixRange's suffix +// range [lower, upper). Lower and upper are MVCC-encoded suffixes; per +// the suffix mask convention, lower is suffix-order inclusive (the newer +// wall time) and upper is suffix-order exclusive (the older wall time). +// +// userProperties is the table-level UserProperties map (as exposed by +// sstable.Reader.UserProperties). If the MVCCTimeInterval property is +// missing (the collector was not configured when the table was written), +// the function returns true: the optimization fails open and the table +// is processed as usual. +// +// The function also returns true if lower or upper cannot be decoded as +// MVCC suffixes — DeleteSuffixRange would still apply the mask in that +// case, and the optimization conservatively assumes intersection rather +// than skipping a file that may legitimately need the mask. +func SuffixRangeIntersectsTable(userProperties map[string]string, lower, upper []byte) bool { + lowerWall, _, err := DecodeMVCCTimestampSuffix(lower) + if err != nil || lowerWall == 0 { + // A wall-time of zero (or a non-MVCC suffix) cannot be reasoned + // about by the wall-only aggregate. Fail open. + return true + } + upperWall, _, err := DecodeMVCCTimestampSuffix(upper) + if err != nil { + return true + } + // The mask matches keys with wall times in (upperWall, lowerWall]. + // As a half-open uint64 interval that is [upperWall+1, lowerWall+1). + filterLower := upperWall + 1 + filterUpper := lowerWall + 1 + if filterLower >= filterUpper { + // Inverted/empty range; conservatively assume intersection so + // DeleteSuffixRange retains its existing behavior. + return true + } + prop, ok := userProperties[mvccWallTimeIntervalCollector] + if !ok { + // Collector was not configured when the table was written. Fail + // open: the caller must process the table. + return true + } + if len(prop) < 1 { + // Defensive: a present-but-empty entry would corrupt our shortID + // stripping. Fail open. + return true + } + // First byte is the shortID; the remainder is the encoded interval. + // Use unsafe.Slice to avoid allocating: DecodeBlockInterval does not + // modify the buffer. + propBytes := unsafe.Slice(unsafe.StringData(prop), len(prop)) + interval, err := sstable.DecodeBlockInterval(propBytes[1:]) + if err != nil { + // Corrupt aggregate: fail open rather than skipping a file that + // may legitimately need the mask. + return true + } + if interval.IsEmpty() { + // The collector ran but recorded no MVCC keys. No key in the + // file can possibly fall in the mask range. + return false + } + return interval.Upper > filterLower && interval.Lower < filterUpper +} + type MaxMVCCTimestampProperty struct{} // Name implements pebble.MaximumSuffixProperty interface. diff --git a/cockroachkvs/cockroachkvs.go b/cockroachkvs/cockroachkvs.go index b0c1111d2e9..918e12208d9 100644 --- a/cockroachkvs/cockroachkvs.go +++ b/cockroachkvs/cockroachkvs.go @@ -1087,6 +1087,60 @@ func (ks *cockroachKeySeeker) MaterializeUserKeyWithSyntheticSuffix( return res } +// IsMaskedBySuffixMask implements colblk.KeySeeker +// interface. The bounds are encoded MVCC suffixes; we inline the decode to +// avoid per-row function call overhead. +// +// Fail-open on malformed bounds is intentional: bounds originate inside +// Pebble (DSR's validation at the API boundary rejects empty/zero-length +// bounds, and the manifest decoder rejects them on load), so a bound that +// is too short to decode here is a programming bug, not user input. +// Returning false leaves the row visible — strictly safer than the +// alternative of silently hiding rows due to corrupt internal state. +// Compare with `defaultKeySeeker.IsMaskedBySuffixMask` which panics on +// missing `ComparePointSuffixes`; both share the principle that an +// internal-invariant violation should not silently mask data. +func (ks *cockroachKeySeeker) IsMaskedBySuffixMask(row int, lower, upper []byte) bool { + rowWall := ks.mvccWallTimes.At(row) + rowLogical := uint32(ks.mvccLogical.At(row)) + if rowWall == 0 && rowLogical == 0 { + return false + } + // Decode lower bound. + if len(lower) < suffixLenWithWall { + return false + } + lowerWall := binary.BigEndian.Uint64(lower[:8]) + var lowerLogical uint32 + if len(lower) >= suffixLenWithLogical { + lowerLogical = binary.BigEndian.Uint32(lower[8:12]) + } + // Decode upper bound. + if len(upper) < suffixLenWithWall { + return false + } + upperWall := binary.BigEndian.Uint64(upper[:8]) + var upperLogical uint32 + if len(upper) >= suffixLenWithLogical { + upperLogical = binary.BigEndian.Uint32(upper[8:12]) + } + // Mask range is [lower, upper) in comparer order. The comparer sorts + // newer (larger wall time) first, so lower has the larger wall time. + // row >= lower in comparer: row wall time <= lower wall time. + // row < upper in comparer: row wall time > upper wall time. + geLower := rowWall < lowerWall || (rowWall == lowerWall && rowLogical <= lowerLogical) + ltUpper := rowWall > upperWall || (rowWall == upperWall && rowLogical > upperLogical) + return geLower && ltUpper +} + +// HasNonEmptySuffix implements colblk.KeySeeker. A row has a non-empty +// MVCC suffix iff either the wall time or the logical timestamp is +// non-zero (matching the "suffixless" sentinel used by +// IsMaskedBySuffixMask). +func (ks *cockroachKeySeeker) HasNonEmptySuffix(row int) bool { + return ks.mvccWallTimes.At(row) != 0 || uint32(ks.mvccLogical.At(row)) != 0 +} + //go:linkname memmove runtime.memmove func memmove(to, from unsafe.Pointer, n uintptr) diff --git a/cockroachkvs/suffix_mask_test.go b/cockroachkvs/suffix_mask_test.go new file mode 100644 index 00000000000..6e313fa8c00 --- /dev/null +++ b/cockroachkvs/suffix_mask_test.go @@ -0,0 +1,359 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +package cockroachkvs + +import ( + "encoding/binary" + "testing" + "unsafe" + + "github.com/cockroachdb/crlib/testutils/leaktest" + "github.com/cockroachdb/crlib/testutils/require" + "github.com/cockroachdb/pebble/internal/base" + "github.com/cockroachdb/pebble/sstable/block" + "github.com/cockroachdb/pebble/sstable/colblk" +) + +func testEncodeMVCCSuffix(wallTime uint64, logical uint32) []byte { + if wallTime == 0 && logical == 0 { + return nil + } + if logical == 0 { + buf := make([]byte, suffixLenWithWall) + binary.BigEndian.PutUint64(buf, wallTime) + buf[len(buf)-1] = suffixLenWithWall + return buf + } + buf := make([]byte, suffixLenWithLogical) + binary.BigEndian.PutUint64(buf, wallTime) + binary.BigEndian.PutUint32(buf[8:], logical) + buf[len(buf)-1] = suffixLenWithLogical + return buf +} + +func TestSuffixMaskBlockPropertyFilter(t *testing.T) { + defer leaktest.AfterTest(t)() + + // MakeSuffixMaskBlockPropertyFilter creates a filter that skips blocks + // where all MVCC wall times are strictly greater than the bound's wall + // time. Internally it calls NewMVCCTimeIntervalFilter(0, wall). + + bound := testEncodeMVCCSuffix(100, 0) + filter := MakeSuffixMaskBlockPropertyFilter(bound) + require.True(t, filter != nil) + + // The filter should have the MVCCTimeInterval collector name. + require.Equal(t, "MVCCTimeInterval", filter.Name()) + + // Encode a block property interval representing a block with wall times + // in [50, 80). This should intersect a filter with range [0, 101). + prop := encodeTestBlockInterval(50, 80) + intersects, err := filter.Intersects(prop) + require.NoError(t, err) + require.True(t, intersects) + + // A block with wall times in [150, 200) does not intersect the filter + // range [0, 101) — the block is entirely above the bound. + prop2 := encodeTestBlockInterval(150, 200) + intersects2, err := filter.Intersects(prop2) + require.NoError(t, err) + require.False(t, intersects2) + + // A block with wall times in [90, 110) partially overlaps [0, 101). + prop3 := encodeTestBlockInterval(90, 110) + intersects3, err := filter.Intersects(prop3) + require.NoError(t, err) + require.True(t, intersects3) + + // Empty bound should return nil filter. + require.True(t, MakeSuffixMaskBlockPropertyFilter(nil) == nil) + + // A suffix with wall=0 should return nil (no meaningful timestamp). + require.True(t, MakeSuffixMaskBlockPropertyFilter(testEncodeMVCCSuffix(0, 0)) == nil) +} + +// encodeTestBlockInterval encodes a block property interval [lower, upper) in +// the same format used by BlockIntervalCollector: two uvarints, the first +// being Lower and the second being (Upper - Lower). +func encodeTestBlockInterval(lower, upper uint64) []byte { + buf := binary.AppendUvarint(nil, lower) + buf = binary.AppendUvarint(buf, upper-lower) + return buf +} + +// makeUserProps wraps an encoded block interval in a single-entry user- +// properties map keyed by the MVCCTimeInterval collector name. The leading +// byte (the collector's shortID) is arbitrary; SuffixRangeIntersectsTable +// only skips it. +func makeUserProps(interval []byte) map[string]string { + buf := make([]byte, 0, len(interval)+1) + buf = append(buf, 0) // shortID + buf = append(buf, interval...) + return map[string]string{mvccWallTimeIntervalCollector: string(buf)} +} + +func TestSuffixRangeIntersectsTable(t *testing.T) { + defer leaktest.AfterTest(t)() + + // Helper: shorthand for the [lower, upper) inputs to + // SuffixRangeIntersectsTable. Lower has the newer (larger) wall; + // upper has the older (smaller) wall. + mkRange := func(lowerWall, upperWall uint64) ([]byte, []byte) { + return testEncodeMVCCSuffix(lowerWall, 0), testEncodeMVCCSuffix(upperWall, 0) + } + + tests := []struct { + name string + fileLower uint64 + fileUpper uint64 + lowerWall uint64 + upperWall uint64 + want bool + }{ + { + // File walls [50, 80); mask (200, 400]. File band is entirely + // older than the mask range. No intersection. + name: "file entirely below mask", + fileLower: 50, fileUpper: 80, + lowerWall: 400, upperWall: 200, + want: false, + }, + { + // File walls [500, 600); mask (200, 400]. File is entirely + // newer. No intersection. + name: "file entirely above mask", + fileLower: 500, fileUpper: 600, + lowerWall: 400, upperWall: 200, + want: false, + }, + { + // File walls [150, 250); mask (200, 400]. Overlap at walls + // 201..249. Intersection. + name: "file straddles mask lower", + fileLower: 150, fileUpper: 250, + lowerWall: 400, upperWall: 200, + want: true, + }, + { + // File walls [350, 500); mask (200, 400]. Overlap at walls + // 350..400. Intersection. + name: "file straddles mask upper", + fileLower: 350, fileUpper: 500, + lowerWall: 400, upperWall: 200, + want: true, + }, + { + // File maxWall == upperWall exactly. fileUpper = upperWall+1. + // Mask (upperWall, lowerWall] excludes upperWall. No + // intersection. + name: "file ends exactly at mask exclusive lower", + fileLower: 100, fileUpper: 201, // maxWall = 200 + lowerWall: 400, upperWall: 200, + want: false, + }, + { + // File maxWall == upperWall+1. fileUpper = upperWall+2. + // Mask includes upperWall+1. Intersection. + name: "file just above mask exclusive lower", + fileLower: 100, fileUpper: 202, // maxWall = 201 + lowerWall: 400, upperWall: 200, + want: true, + }, + { + // File minWall == lowerWall. Mask includes lowerWall. Intersection. + name: "file starts at mask inclusive upper", + fileLower: 400, fileUpper: 500, + lowerWall: 400, upperWall: 200, + want: true, + }, + { + // File minWall == lowerWall+1. Mask excludes anything > lowerWall. + // No intersection. + name: "file starts just above mask inclusive upper", + fileLower: 401, fileUpper: 500, + lowerWall: 400, upperWall: 200, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lower, upper := mkRange(tt.lowerWall, tt.upperWall) + interval := encodeTestBlockInterval(tt.fileLower, tt.fileUpper) + props := makeUserProps(interval) + got := SuffixRangeIntersectsTable(props, lower, upper) + require.Equal(t, tt.want, got) + }) + } + + t.Run("missing property fails open", func(t *testing.T) { + lower, upper := mkRange(400, 200) + got := SuffixRangeIntersectsTable(map[string]string{}, lower, upper) + require.True(t, got) + }) + + t.Run("nil bounds fail open", func(t *testing.T) { + props := makeUserProps(encodeTestBlockInterval(50, 80)) + require.True(t, SuffixRangeIntersectsTable(props, nil, testEncodeMVCCSuffix(200, 0))) + require.True(t, SuffixRangeIntersectsTable(props, testEncodeMVCCSuffix(400, 0), nil)) + }) + + t.Run("inverted bounds fail open", func(t *testing.T) { + // lowerWall < upperWall — degenerate. Should fail open. + lower, upper := mkRange(100, 400) // lowerWall=100, upperWall=400 + props := makeUserProps(encodeTestBlockInterval(50, 80)) + require.True(t, SuffixRangeIntersectsTable(props, lower, upper)) + }) + + t.Run("empty file interval", func(t *testing.T) { + // An empty file interval (e.g., a file with no MVCC keys) cannot + // intersect any range. + lower, upper := mkRange(400, 200) + // Empty interval has Lower == Upper; encoded as two uvarints + // where the second is 0. The aggregate is empty. + props := makeUserProps(nil) + got := SuffixRangeIntersectsTable(props, lower, upper) + // Aggregate decodes as empty BlockInterval; we treat empty as no + // intersection (the file has no MVCC keys whose wall time the + // mask could match). + require.False(t, got) + }) +} + +// initKeySeekerWithRow builds a data block containing a single key with the +// given roach key, wall time, and logical time, then initializes and returns a +// cockroachKeySeeker over that block. +func initKeySeekerWithRow( + t *testing.T, roachKey []byte, wallTime uint64, logical uint32, +) *cockroachKeySeeker { + t.Helper() + var enc colblk.DataBlockEncoder + enc.Init(&KeySchema, colblk.NoTieringColumns()) + k := makeMVCCKey(roachKey, wallTime, logical) + kcmp := enc.KeyWriter.ComparePrev(k) + ikey := base.MakeInternalKey(k, 0, base.InternalKeyKindSet) + enc.Add(ikey, k, block.InPlaceValuePrefix(false), kcmp, false /* isObsolete */, base.KVMeta{}) + blk, _ := enc.Finish(1, enc.Size()) + + var dec colblk.DataBlockDecoder + bd := dec.Init(&KeySchema, blk) + ks := &cockroachKeySeeker{} + KeySchema.InitKeySeekerMetadata( + (*colblk.KeySeekerMetadata)(unsafe.Pointer(ks)), &dec, bd, + ) + return ks +} + +func TestIsMaskedBySuffixMask(t *testing.T) { + defer leaktest.AfterTest(t)() + + tests := []struct { + name string + rowWall uint64 + rowLogic uint32 + lower []byte + upper []byte + want bool + }{ + { + name: "row in mask range", + rowWall: 50, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), // inclusive + upper: testEncodeMVCCSuffix(10, 0), // exclusive + want: true, + }, + { + name: "row at upper (exclusive, not masked)", + rowWall: 10, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 0), + want: false, + }, + { + name: "row at lower (inclusive, masked)", + rowWall: 100, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 0), + want: true, + }, + { + name: "row older than upper", + rowWall: 5, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 0), + want: false, + }, + { + name: "row newer than lower", + rowWall: 200, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 0), + want: false, + }, + { + name: "suffixless row is never masked", + rowWall: 0, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 0), + want: false, + }, + { + name: "wall-only bounds, 9-byte suffix", + rowWall: 50, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 0), + want: true, + }, + { + name: "wall+logical bounds, 13-byte suffix", + rowWall: 50, rowLogic: 5, + lower: testEncodeMVCCSuffix(100, 1), + upper: testEncodeMVCCSuffix(10, 1), + want: true, + }, + { + name: "equal wall at upper, row logical > upper logical", + rowWall: 10, rowLogic: 5, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 3), + want: true, // row logical 5 > upper logical 3, so row > upper → masked + }, + { + name: "equal wall at upper, row logical == upper logical", + rowWall: 10, rowLogic: 3, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 3), + want: false, // upper is exclusive + }, + { + name: "equal wall at upper, row logical < upper logical", + rowWall: 10, rowLogic: 1, + lower: testEncodeMVCCSuffix(100, 0), + upper: testEncodeMVCCSuffix(10, 3), + want: false, + }, + { + name: "short lower bound fails open", + rowWall: 50, rowLogic: 0, + lower: []byte{0x01, 0x02}, + upper: testEncodeMVCCSuffix(10, 0), + want: false, + }, + { + name: "short upper bound fails open", + rowWall: 50, rowLogic: 0, + lower: testEncodeMVCCSuffix(100, 0), + upper: []byte{0x01, 0x02}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ks := initKeySeekerWithRow(t, []byte("key"), tt.rowWall, tt.rowLogic) + got := ks.IsMaskedBySuffixMask(0, tt.lower, tt.upper) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/compaction.go b/compaction.go index 426a4eaec82..2494b9ab6cc 100644 --- a/compaction.go +++ b/compaction.go @@ -2522,6 +2522,11 @@ func (d *DB) runCopyCompaction( LargestSeqNumAbsolute: inputMeta.LargestSeqNumAbsolute, Virtual: inputMeta.Virtual, SyntheticPrefixAndSuffix: inputMeta.SyntheticPrefixAndSuffix, + // Copy compactions don't iterate over the input through the per-row + // SuffixMask filter — they're a byte-for-byte copy of the backing + // file. The output is read with the source's mask semantics, so the + // mask must be propagated to the new file's metadata. + SuffixMasks: slices.Clone(inputMeta.SuffixMasks), } if inputStats, ok := inputMeta.Stats(); ok { newMeta.PopulateStats(inputStats) @@ -2725,6 +2730,9 @@ func (d *DB) runDefaultTableCompaction( if result.Err == nil { ve, result.Err = c.makeVersionEdit(result) } + if hook := d.opts.private.testingDuringCompactionIOFunc; hook != nil && !c.IsFlush() { + hook() + } if result.Err != nil { // Delete any created tables or blob files. obsoleteFiles := manifest.ObsoleteFiles{ diff --git a/db.go b/db.go index 90ec217044f..4abb590a006 100644 --- a/db.go +++ b/db.go @@ -2037,6 +2037,75 @@ func (d *DB) Flush() error { return nil } +// flushIfOverlapping flushes the memtable if any flushable in the queue +// possibly overlaps with `span` (or with any of the additional bounds +// returned by `extraBoundsLocked` if non-nil), and waits for that flush +// to complete. If no flushable overlaps, it is a no-op. +// +// On successful return, any data written before this call that intersects +// the considered bounds is durably stored in sstables. Concurrent writes +// that race with this call are not guaranteed to be flushed; if the +// caller needs that guarantee, it must serialize with its own writers. +// +// `extraBoundsLocked` (if non-nil) is invoked with `d.mu` held and may +// read from `DB.mu`-protected state — e.g., the snapshot list — to +// produce additional overlap bounds. The classic use case is a file- +// metadata mutator (DeleteSuffixRange, cloning-with-prefix-replacements, +// etc.) that needs the memtable flushed before it rewrites the metadata +// of the SSTs that span overlaps; a caller may extend the overlap check +// with snapshot-protected ranges so that a snapshot reader's view of +// the LSM is preserved across the mutation. +func (d *DB) flushIfOverlapping(span KeyRange, extraBoundsLocked func() []bounded) error { + if err := d.closed.Load(); err != nil { + panic(err) + } + if d.opts.ReadOnly { + return ErrReadOnly + } + if !span.Valid() { + return errors.Errorf("pebble: flushIfOverlapping called with invalid KeyRange") + } + + // Hold commit.mu across the overlap check and the memtable rotation so + // that no new writes can land between the two: any write committed before + // this call has released commit.mu and is visible in d.mu.mem.queue, and + // no new write can enter the commit pipeline until we release commit.mu. + d.commit.mu.Lock() + d.mu.Lock() + bounds := []bounded{span} + if extraBoundsLocked != nil { + bounds = append(bounds, extraBoundsLocked()...) + } + overlaps := false + record := func(b bounded) shouldContinue { + overlaps = true + return stopIteration + } + for i := range d.mu.mem.queue { + // stopIteration short-circuits within a single flushable but not + // across queue entries; the outer `if overlaps` does cross-entry + // short-circuit. + d.mu.mem.queue[i].computePossibleOverlaps(record, bounds...) + if overlaps { + break + } + } + if !overlaps { + d.mu.Unlock() + d.commit.mu.Unlock() + return nil + } + flushed := d.mu.mem.queue[len(d.mu.mem.queue)-1].flushed + err := d.makeRoomForWrite(nil) + d.mu.Unlock() + d.commit.mu.Unlock() + if err != nil { + return err + } + <-flushed + return nil +} + // AsyncFlush asynchronously flushes the memtable to stable storage. // // If no error is returned, the caller can receive from the returned channel in diff --git a/excise.go b/excise.go index 7ff1a0be5b8..177a0933213 100644 --- a/excise.go +++ b/excise.go @@ -145,6 +145,7 @@ func (d *DB) exciseTable( SeqNums: m.SeqNums, LargestSeqNumAbsolute: m.LargestSeqNumAbsolute, SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix, + SuffixMasks: m.SuffixMasks, BlobReferenceDepth: m.BlobReferenceDepth, } if looseBounds { @@ -184,6 +185,7 @@ func (d *DB) exciseTable( SeqNums: m.SeqNums, LargestSeqNumAbsolute: m.LargestSeqNumAbsolute, SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix, + SuffixMasks: m.SuffixMasks, BlobReferenceDepth: m.BlobReferenceDepth, } if looseBounds { @@ -262,17 +264,27 @@ func exciseOverlapBounds( // // Sets the smallest and largest keys, as well as HasPointKeys/HasRangeKeys in // the leftFile. +// +// Per-key-type contribution is gated on whether that key type actually extends +// to the left of exciseSpanStart in the original table; if all keys of that +// type fall at or after exciseSpanStart, that type contributes nothing to the +// left table. Without the gate, the synthesized largest (clamped to a sentinel +// at exciseSpanStart) would sort before the original's smallest user key, +// producing a table with `smallest > largest` and failing +// `TableMetadata.Validate`. func looseLeftTableBounds( cmp Compare, originalTable, leftTable *manifest.TableMetadata, exciseSpanStart []byte, ) { - if originalTable.HasPointKeys { + if originalTable.HasPointKeys && + cmp(originalTable.PointKeyBounds.Smallest().UserKey, exciseSpanStart) < 0 { largestPointKey := originalTable.PointKeyBounds.Largest() if largestPointKey.IsUpperBoundFor(cmp, exciseSpanStart) { largestPointKey = base.MakeRangeDeleteSentinelKey(exciseSpanStart) } leftTable.ExtendPointKeyBounds(cmp, originalTable.PointKeyBounds.Smallest(), largestPointKey) } - if originalTable.HasRangeKeys { + if originalTable.HasRangeKeys && + cmp(originalTable.RangeKeyBounds.Smallest().UserKey, exciseSpanStart) < 0 { largestRangeKey := originalTable.RangeKeyBounds.Largest() if largestRangeKey.IsUpperBoundFor(cmp, exciseSpanStart) { largestRangeKey = base.MakeExclusiveSentinelKey(InternalKeyKindRangeKeyMin, exciseSpanStart) @@ -290,21 +302,42 @@ func looseLeftTableBounds( // // The excise span end bound is assumed to be exclusive; this function cannot be // used with an inclusive end bound. +// +// Per-key-type contribution is gated on whether that key type actually extends +// to the right of exciseSpanEnd in the original table; if all keys of that +// type fall before exciseSpanEnd, that type contributes nothing to the right +// table. Without the gate, the synthesized smallest (clamped to exciseSpanEnd) +// would sort after the original's largest user key, producing a table with +// `smallest > largest` and failing `TableMetadata.Validate`. func looseRightTableBounds( cmp Compare, originalTable, rightTable *manifest.TableMetadata, exciseSpanEnd []byte, ) { - if originalTable.HasPointKeys { - smallestPointKey := originalTable.PointKeyBounds.Smallest() - if !smallestPointKey.IsUpperBoundFor(cmp, exciseSpanEnd) { - smallestPointKey = base.MakeInternalKey(exciseSpanEnd, 0, base.InternalKeyKindMaxForSSTable) + exciseSpanEndBoundary := base.UserKeyExclusive(exciseSpanEnd) + // Always synthesize the smallest with the maximum non-sentinel trailer + // rather than copying the originalTable's smallest as-is. The + // originalTable's smallest may have a small trailer (e.g. seqnum=0, + // kind=DELSIZED), and a real range tombstone in the file at the same + // user key with a larger trailer would violate the rangeDelIter's + // lower-bound assertion in invariants builds. The bound is loose + // either way; using the largest trailer is strictly safer. + if originalTable.HasPointKeys && + !exciseSpanEndBoundary.IsUpperBoundForInternalKey(cmp, originalTable.PointKeyBounds.Largest()) { + smallestUserKey := originalTable.PointKeyBounds.Smallest().UserKey + if cmp(smallestUserKey, exciseSpanEnd) < 0 { + smallestUserKey = exciseSpanEnd } + smallestPointKey := base.MakeInternalKey( + smallestUserKey, base.SeqNumMax-1, base.InternalKeyKindMaxForSSTable) rightTable.ExtendPointKeyBounds(cmp, smallestPointKey, originalTable.PointKeyBounds.Largest()) } - if originalTable.HasRangeKeys { - smallestRangeKey := originalTable.RangeKeyBounds.Smallest() - if !smallestRangeKey.IsUpperBoundFor(cmp, exciseSpanEnd) { - smallestRangeKey = base.MakeInternalKey(exciseSpanEnd, 0, base.InternalKeyKindRangeKeyMax) + if originalTable.HasRangeKeys && + !exciseSpanEndBoundary.IsUpperBoundForInternalKey(cmp, originalTable.RangeKeyBounds.Largest()) { + smallestUserKey := originalTable.RangeKeyBounds.Smallest().UserKey + if cmp(smallestUserKey, exciseSpanEnd) < 0 { + smallestUserKey = exciseSpanEnd } + smallestRangeKey := base.MakeInternalKey( + smallestUserKey, base.SeqNumMax-1, base.InternalKeyKindRangeKeyMax) rightTable.ExtendRangeKeyBounds(cmp, originalTable.RangeKeyKinds, smallestRangeKey, originalTable.RangeKeyBounds.Largest()) } } @@ -448,6 +481,15 @@ func determineExcisedTableSize( // table to the excised table, scaling each blob reference's value size // proportionally based on the ratio of the excised table's size to the original // table's size. +// +// The excised table is a subset of the original (it covers a sub-range of the +// original's user-key space), so its share of any referenced blob file cannot +// exceed the original's share. The scaling is therefore capped at the +// original blob reference's value size; without the cap, size-estimate noise +// (e.g., `excisedTable.Size > originalSize` due to per-key estimates or the +// `determineExcisedTableSize` zero-clamp) could produce a `ValueSize` greater +// than the physical blob file's `ValueSize`, tripping the invariant check in +// `MakeBlobReference` on manifest replay. func determineExcisedTableBlobReferences( originalBlobReferences manifest.BlobReferences, originalSize uint64, @@ -459,7 +501,8 @@ func determineExcisedTableBlobReferences( } newBlobReferences := make(manifest.BlobReferences, len(originalBlobReferences)) for i, bf := range originalBlobReferences { - bf.ValueSize = max(bf.ValueSize*excisedTable.Size/originalSize, 1) + scaled := bf.ValueSize * excisedTable.Size / originalSize + bf.ValueSize = max(min(scaled, bf.ValueSize), 1) if fmv < FormatBackingValueSize { bf.BackingValueSize = 0 } diff --git a/file_cache.go b/file_cache.go index 4bebc58ed2c..7e91b6329b5 100644 --- a/file_cache.go +++ b/file_cache.go @@ -680,6 +680,26 @@ func (h *fileCacheHandle) newPointIter( r.TryAddBlockPropertyFilterForHideObsoletePoints( opts.snapshotForHideObsoletePoints, file.SeqNums.High, opts.PointKeyFilters) + // NB: We intentionally do NOT add a block property filter for + // SuffixMask here. The block property collector tracks the range of + // suffixes present but does not track whether a block contains + // suffixless keys, so a filter based on suffix range alone could + // incorrectly skip blocks (and even entire tables) that contain + // suffixless keys which should pass through unfiltered. Row-level + // filtering in DataBlockIter handles SuffixMask correctly for all + // key types. + // + // If the block property collector were extended to also indicate + // whether suffixless keys are present, block- and file-level + // filtering would become possible here. This could also benefit + // callers that currently pair a filtered iterator with an unfiltered + // one stepped in lockstep to observe suffixless keys. In the initial + // use-case for suffix-masked files, the majority of blocks and files + // are expected to not be masked, so pessimistically including every + // file and block is unlikely to leave significant performance on the + // table. If a mask were to exclude entire blocks or files, this cost + // would grow and extending the collector would become worthwhile. + var ok bool var err error ok, filterer, err = checkAndIntersectFilters(r, pointKeyFilters, @@ -891,6 +911,7 @@ func newRangeKeyIter( internalOpts internalIterOpts, ) (keyspan.FragmentIterator, error) { transforms := file.FragmentIterTransforms() + // Don't filter a table's range keys if the file contains RANGEKEYDELs. // The RANGEKEYDELs may delete range keys in other levels. Skipping the // file's range key blocks may surface deleted range keys below. This is diff --git a/flush_test.go b/flush_test.go index 976d7f79a09..8c1a464fde5 100644 --- a/flush_test.go +++ b/flush_test.go @@ -119,3 +119,127 @@ func TestFlushEmptyKey(t *testing.T) { require.NoError(t, closer.Close()) require.NoError(t, d.Close()) } + +// TestFlushIfOverlapping verifies that `flushIfOverlapping` flushes iff some +// memtable entry intersects the requested span (or the caller-supplied extra +// bounds), and that point keys, range deletions, and range keys all +// participate in the overlap check. +func TestFlushIfOverlapping(t *testing.T) { + defer leaktest.AfterTest(t)() + + open := func(t *testing.T, opts *Options) *DB { + if opts == nil { + opts = &Options{} + } + if opts.FS == nil { + opts.FS = vfs.NewMem() + } + if opts.FormatMajorVersion == 0 { + opts.FormatMajorVersion = FormatNewest + } + d, err := Open("", opts) + require.NoError(t, err) + return d + } + span := func(s, e string) KeyRange { return KeyRange{Start: []byte(s), End: []byte(e)} } + abz := span("a", "z") + + t.Run("overlap", func(t *testing.T) { + type populate func(d *DB) error + point := func(k string) populate { + return func(d *DB) error { return d.Set([]byte(k), nil, nil) } + } + rangeDel := func(s, e string) populate { + return func(d *DB) error { return d.DeleteRange([]byte(s), []byte(e), nil) } + } + rangeKey := func(s, e, suffix string) populate { + return func(d *DB) error { + return d.RangeKeySet([]byte(s), []byte(e), []byte(suffix), nil, nil) + } + } + + for _, tc := range []struct { + name string + populate populate + span KeyRange + wantFlush bool + }{ + {"empty memtable", nil, abz, false}, + {"point not in span", point("a"), span("x", "z"), false}, + {"point in span", point("m"), abz, true}, + {"point at span start (inclusive)", point("m"), span("m", "n"), true}, + {"point at span end (exclusive)", point("m"), span("a", "m"), false}, + {"range del intersects", rangeDel("m", "n"), abz, true}, + {"range del disjoint", rangeDel("m", "n"), span("x", "z"), false}, + {"range key intersects", rangeKey("m", "n", "@1"), abz, true}, + {"range key disjoint", rangeKey("m", "n", "@1"), span("x", "z"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + d := open(t, nil) + defer func() { require.NoError(t, d.Close()) }() + if tc.populate != nil { + require.NoError(t, tc.populate(d)) + } + before := d.Metrics().Flush.Count + require.NoError(t, d.flushIfOverlapping(tc.span, nil)) + after := d.Metrics().Flush.Count + if tc.wantFlush { + require.Greater(t, after, before, "expected flush") + } else { + require.Equal(t, before, after, "expected no flush") + } + }) + } + }) + + t.Run("extra bounds callback", func(t *testing.T) { + // The DSR-style use case: the caller's span is disjoint from the + // memtable's content, but the callback supplies an extra range that + // does overlap, and we expect a flush. + d := open(t, nil) + defer func() { require.NoError(t, d.Close()) }() + require.NoError(t, d.Set([]byte("m"), nil, nil)) + extra := KeyRange{Start: []byte("l"), End: []byte("n")} + before := d.Metrics().Flush.Count + require.NoError(t, d.flushIfOverlapping( + span("x", "z"), + func() []bounded { return []bounded{extra} }, + )) + require.Greater(t, d.Metrics().Flush.Count, before, + "expected flush triggered by extra bounds") + + // Callback returning no extra bounds is equivalent to nil callback. + d2 := open(t, nil) + defer func() { require.NoError(t, d2.Close()) }() + require.NoError(t, d2.Set([]byte("m"), nil, nil)) + before2 := d2.Metrics().Flush.Count + require.NoError(t, d2.flushIfOverlapping( + span("x", "z"), + func() []bounded { return nil }, + )) + require.Equal(t, before2, d2.Metrics().Flush.Count, + "expected no flush when neither span nor extra bounds overlap") + }) + + t.Run("closed DB panics", func(t *testing.T) { + d := open(t, nil) + require.NoError(t, d.Close()) + require.Panics(t, func() { _ = d.flushIfOverlapping(abz, nil) }) + }) + + t.Run("read-only returns ErrReadOnly", func(t *testing.T) { + fs := vfs.NewMem() + require.NoError(t, open(t, &Options{FS: fs}).Close()) + d := open(t, &Options{FS: fs, ReadOnly: true}) + defer func() { require.NoError(t, d.Close()) }() + require.ErrorIs(t, d.flushIfOverlapping(abz, nil), ErrReadOnly) + }) + + t.Run("invalid KeyRange", func(t *testing.T) { + d := open(t, nil) + defer func() { require.NoError(t, d.Close()) }() + require.Error(t, d.flushIfOverlapping(KeyRange{}, nil)) + require.Error(t, d.flushIfOverlapping(KeyRange{Start: []byte("a")}, nil)) + require.Error(t, d.flushIfOverlapping(KeyRange{End: []byte("z")}, nil)) + }) +} diff --git a/format_major_version.go b/format_major_version.go index 97a5d4f3c08..a2b94c3826f 100644 --- a/format_major_version.go +++ b/format_major_version.go @@ -267,6 +267,12 @@ const ( // such marked tables to have been compacted. FormatRowblkMarkedForCompaction + // FormatSuffixMask is a format major version that adds support for the + // customTagSuffixMask manifest tag, used by DeleteSuffixRange to attach + // per-file suffix masks. Older versions of Pebble cannot read manifests + // containing this tag. + FormatSuffixMask + // -- Add new versions here -- // FormatNewest is the most recent format major version. @@ -421,6 +427,9 @@ var formatMajorVersionMigrations = map[FormatMajorVersion]func(*DB) error{ } return d.finalizeFormatVersUpgrade(FormatRowblkMarkedForCompaction) }, + FormatSuffixMask: func(d *DB) error { + return d.finalizeFormatVersUpgrade(FormatSuffixMask) + }, } const formatVersionMarkerName = `format-version` diff --git a/format_major_version_test.go b/format_major_version_test.go index 1ac55090b1f..55801cbb5bd 100644 --- a/format_major_version_test.go +++ b/format_major_version_test.go @@ -42,11 +42,12 @@ func TestFormatMajorVersionStableValues(t *testing.T) { require.Equal(t, FormatMarkForCompactionInVersionEdit, FormatMajorVersion(28)) require.Equal(t, FormatIngestBlobFiles, FormatMajorVersion(29)) require.Equal(t, FormatRowblkMarkedForCompaction, FormatMajorVersion(30)) + require.Equal(t, FormatSuffixMask, FormatMajorVersion(31)) // When we add a new version, we should add a check for the new version above // in addition to updating the expected values below. - require.Equal(t, FormatNewest, FormatMajorVersion(30)) - require.Equal(t, internalFormatNewest, FormatMajorVersion(30)) + require.Equal(t, FormatNewest, FormatMajorVersion(31)) + require.Equal(t, internalFormatNewest, FormatMajorVersion(31)) } func TestFormatMajorVersion_MigrationDefined(t *testing.T) { @@ -243,6 +244,7 @@ func TestFormatMajorVersions_TableFormat(t *testing.T) { FormatMarkForCompactionInVersionEdit: {sstable.TableFormatPebblev1, sstable.TableFormatPebblev7}, FormatIngestBlobFiles: {sstable.TableFormatPebblev1, sstable.TableFormatPebblev7}, FormatRowblkMarkedForCompaction: {sstable.TableFormatPebblev1, sstable.TableFormatPebblev7}, + FormatSuffixMask: {sstable.TableFormatPebblev1, sstable.TableFormatPebblev7}, } // Valid versions. @@ -273,6 +275,7 @@ func TestFormatMajorVersions_BlobFileFormat(t *testing.T) { FormatMarkForCompactionInVersionEdit: blob.FileFormatV2, FormatIngestBlobFiles: blob.FileFormatV2, FormatRowblkMarkedForCompaction: blob.FileFormatV2, + FormatSuffixMask: blob.FileFormatV2, } // Valid versions. diff --git a/ingest.go b/ingest.go index b6cb772f74e..ef43da0f12d 100644 --- a/ingest.go +++ b/ingest.go @@ -117,6 +117,8 @@ func ingestSynthesizeShared( meta.ExtendRangeKeyBounds(opts.Comparer.Compare, manifest.AnyRangeKeys, smallestRangeKey, largestRangeKey) } + meta.SuffixMasks = cloneSuffixMasks(sm.SuffixMasks) + // For simplicity, we use the same number for both the FileNum and the // DiskFileNum (even though this is a virtual sstable). Pass the underlying // TableBacking's size to the same size as the virtualized view of the sstable. @@ -195,6 +197,7 @@ func ingestLoad1External( } meta.SyntheticPrefixAndSuffix = sstable.MakeSyntheticPrefixAndSuffix(e.SyntheticPrefix, e.SyntheticSuffix) + meta.SuffixMasks = cloneSuffixMasks(e.SuffixMasks) return meta, nil } @@ -1432,6 +1435,19 @@ type ExternalFile struct { // - the backing sst must not contain multiple keys with the same prefix. SyntheticSuffix []byte + // SuffixMasks are read-time suffix-mask filters that the destination + // must apply on top of the backing file (typically reproducing masks + // attached to the source vsst by `DB.DeleteSuffixRange`). Like + // SyntheticSuffix, masks are a presentation choice on the vsst, not a + // property of the backing file: every key returned by an iterator + // whose suffix falls in any mask's `[Lower, Upper)` (per + // `ComparePointSuffixes`) is hidden; keys with no suffix are never + // hidden. Multiple masks form an unordered union. + // + // When non-empty, the destination must be at `FormatSuffixMask` or + // higher. + SuffixMasks []sstable.SuffixMask + // Level denotes the level at which this file was present at read time // if the external file was returned by a scan of an existing Pebble // instance. If Level is 0, this field is ignored. @@ -1758,6 +1774,22 @@ func (d *DB) ingest(ctx context.Context, args ingestArgs) (IngestOperationStats, } } } + if d.FormatMajorVersion() < FormatSuffixMask { + for i := range external { + if len(external[i].SuffixMasks) > 0 { + return IngestOperationStats{}, errors.Newf( + "pebble: external file ingestion with SuffixMasks requires at least format major version %d", + FormatSuffixMask) + } + } + for i := range shared { + if len(shared[i].SuffixMasks) > 0 { + return IngestOperationStats{}, errors.Newf( + "pebble: shared file ingestion with SuffixMasks requires at least format major version %d", + FormatSuffixMask) + } + } + } jobID := d.newJobID() // Load the metadata for all the files being ingested. This step detects diff --git a/internal/manifest/table_metadata.go b/internal/manifest/table_metadata.go index 666e512daf1..e9471390489 100644 --- a/internal/manifest/table_metadata.go +++ b/internal/manifest/table_metadata.go @@ -7,6 +7,7 @@ package manifest import ( "bytes" stdcmp "cmp" + "encoding/hex" "fmt" "sync/atomic" @@ -202,6 +203,15 @@ type TableMetadata struct { // SyntheticPrefix is used to prepend a prefix to all keys and/or override all // suffixes in a table; used for some virtual tables. SyntheticPrefixAndSuffix sstable.SyntheticPrefixAndSuffix + + // SuffixMasks, if non-empty, masks point keys and range key entries whose + // suffix falls within any of the configured mask ranges [Lower, Upper). + // Used for MVCC revert. Repeated DeleteSuffixRange calls may accumulate + // additional masks on a file. + // + // The slice and its byte contents are immutable by convention; mutators + // must clone before appending. See `DeleteSuffixRange`. + SuffixMasks []sstable.SuffixMask } // RangeKeyKinds describes which kinds of range keys may be present in a table. @@ -303,6 +313,7 @@ func (m *TableMetadata) IterTransforms() sstable.IterTransforms { return sstable.IterTransforms{ SyntheticSeqNum: m.SyntheticSeqNum(), SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix, + SuffixMasks: m.SuffixMasks, } } @@ -312,6 +323,7 @@ func (m *TableMetadata) FragmentIterTransforms() sstable.FragmentIterTransforms return sstable.FragmentIterTransforms{ SyntheticSeqNum: m.SyntheticSeqNum(), SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix, + SuffixMasks: m.SuffixMasks, } } @@ -899,6 +911,14 @@ func (m *TableMetadata) DebugString(format base.FormatKey, verbose bool) string fmt.Fprintf(&b, "(%d)", m.TableBacking.Size) } } + for _, mask := range m.SuffixMasks { + // "suffixmask" is one token because '-' is a parser separator (see + // debugParserSeparators); using "suffix-mask" would tokenize into + // three tokens and ParseTableMetadataDebug would not roundtrip. + // Each mask emits its own "suffixmask:[...)" token; the parser loop + // appends each occurrence to SuffixMasks. + fmt.Fprintf(&b, " suffixmask:[%x-%x)", mask.Lower, mask.Upper) + } if len(m.BlobReferences) > 0 { fmt.Fprint(&b, " blobrefs:[") for i, r := range m.BlobReferences { @@ -1017,6 +1037,21 @@ func ParseTableMetadataDebug(s string) (_ *TableMetadata, err error) { m.BlobReferenceDepth = BlobReferenceDepth(p.Uint64()) p.Expect("]") + case "suffixmask": + // Multiple "suffixmask:[lo-hi)" tokens accumulate into the + // SuffixMasks slice in order. + p.Expect("[") + lowerHex := p.Next() + p.Expect("-") + upperHex := p.Next() + p.Expect(")") + lower, errL := hex.DecodeString(lowerHex) + upper, errU := hex.DecodeString(upperHex) + if errL != nil || errU != nil { + p.Errf("bad suffixmask hex: lower=%v upper=%v", errL, errU) + } + m.SuffixMasks = append(m.SuffixMasks, sstable.SuffixMask{Lower: lower, Upper: upper}) + default: p.Errf("unknown field %q", field) } diff --git a/internal/manifest/table_metadata_test.go b/internal/manifest/table_metadata_test.go index 5b8cebab7c0..883e1bc5004 100644 --- a/internal/manifest/table_metadata_test.go +++ b/internal/manifest/table_metadata_test.go @@ -121,6 +121,14 @@ func TestTableMetadata_ParseRoundTrip(t *testing.T) { name: "blobrefs", input: "000196:[bar#0,SET-foo#0,SET] seqnums:[#0-#0] points:[bar#0,SET-foo#0,SET] blobrefs:[(B000191: 2952), (B000075: 108520); depth:2]", }, + { + name: "suffixmask", + input: "000001:[a#0,SET-z#0,DEL] seqnums:[#0-#0] points:[a#0,SET-z#0,DEL] suffixmask:[deadbeef-cafef00d)", + }, + { + name: "multiple suffixmask entries", + input: "000001:[a#0,SET-z#0,DEL] seqnums:[#0-#0] points:[a#0,SET-z#0,DEL] suffixmask:[20-10) suffixmask:[0900-0500)", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -192,7 +200,7 @@ func TestTableMetadataSize(t *testing.T) { t.Skip("Test only supported on amd64 and arm64 architectures") } - const tableMetadataSize = 216 + const tableMetadataSize = 240 if structSize := unsafe.Sizeof(TableMetadata{}); structSize != tableMetadataSize { t.Errorf("TableMetadata struct size (%d bytes) is not expected size (%d bytes)", structSize, tableMetadataSize) diff --git a/internal/manifest/version_edit.go b/internal/manifest/version_edit.go index 8dd9a042593..d6eeab10ace 100644 --- a/internal/manifest/version_edit.go +++ b/internal/manifest/version_edit.go @@ -92,6 +92,9 @@ const ( customTagBlobReferences = 69 // customTagBlobReferences2 contains BackingValueSize for each BlobReference. customTagBlobReferences2 = 70 + // customTagSuffixMask encodes the lower and upper bounds of the suffix + // mask as two consecutive length-prefixed byte slices. + customTagSuffixMask = 71 ) // DeletedTableEntry holds the state for a sstable deletion from a level. The @@ -423,6 +426,7 @@ func (v *VersionEdit) Decode(r io.Reader) error { var noRangeKeySets bool var syntheticPrefix sstable.SyntheticPrefix var syntheticSuffix sstable.SyntheticSuffix + var suffixMasks []sstable.SuffixMask var blobReferences BlobReferences var blobReferenceDepth BlobReferenceDepth if tag == tagNewFile4 || tag == tagNewFile5 { @@ -493,6 +497,36 @@ func (v *VersionEdit) Decode(r io.Reader) error { return err } + case customTagSuffixMask: + // The payload is a single length-prefixed bytes field + // containing two consecutive length-prefixed byte + // slices: the lower bound followed by the upper bound. + // + // The customTagSuffixMask tag may repeat: each + // occurrence appends an additional mask to + // SuffixMasks. + payload, err := d.readBytes() + if err != nil { + return err + } + reader := bytes.NewReader(payload) + sub := versionEditDecoder{reader} + lower, err := sub.readBytes() + if err != nil { + return base.CorruptionErrorf("new-file4: suffix mask: %v", err) + } + upper, err := sub.readBytes() + if err != nil { + return base.CorruptionErrorf("new-file4: suffix mask: %v", err) + } + if len(lower) == 0 || len(upper) == 0 { + return base.CorruptionErrorf("new-file4: suffix mask: bound is empty") + } + if reader.Len() != 0 { + return base.CorruptionErrorf("new-file4: suffix mask: %d trailing bytes", reader.Len()) + } + suffixMasks = append(suffixMasks, sstable.SuffixMask{Lower: lower, Upper: upper}) + case customTagBlobReferences, customTagBlobReferences2: // The first varint encodes the 'blob reference depth' // of the table. @@ -550,6 +584,7 @@ func (v *VersionEdit) Decode(r io.Reader) error { BlobReferenceDepth: blobReferenceDepth, Virtual: virtualState.virtual, SyntheticPrefixAndSuffix: sstable.MakeSyntheticPrefixAndSuffix(syntheticPrefix, syntheticSuffix), + SuffixMasks: suffixMasks, } if tag != tagNewFile5 { // no range keys present @@ -918,7 +953,7 @@ func (v *VersionEdit) Encode(w io.Writer) error { e.writeUvarint(uint64(x.FileNum)) } for _, x := range v.NewTables { - customFields := x.Meta.CreationTime != 0 || x.Meta.Virtual || len(x.Meta.BlobReferences) > 0 || x.Meta.RangeKeyKinds == OnlyRangeKeyUnsetAndDelete + customFields := x.Meta.CreationTime != 0 || x.Meta.Virtual || len(x.Meta.BlobReferences) > 0 || x.Meta.RangeKeyKinds == OnlyRangeKeyUnsetAndDelete || len(x.Meta.SuffixMasks) > 0 var tag uint64 switch { case x.Meta.HasRangeKeys: @@ -985,6 +1020,25 @@ func (v *VersionEdit) Encode(w io.Writer) error { e.writeUvarint(customTagSyntheticSuffix) e.writeBytes(x.Meta.SyntheticPrefixAndSuffix.Suffix()) } + for _, mask := range x.Meta.SuffixMasks { + // Encode both bounds inside a single tag's payload as two + // consecutive length-prefixed byte slices. See customTagSuffixMask. + // + // REQUIRES: the database's FormatMajorVersion is at least + // FormatSuffixMask. The encoder cannot enforce this directly, + // so callers that attach SuffixMasks to a TableMetadata (today, + // only DeleteSuffixRange) must verify the gate. + // + // The customTagSuffixMask tag may repeat; the decoder accumulates + // each occurrence into SuffixMasks. + e.writeUvarint(customTagSuffixMask) + var buf []byte + buf = binary.AppendUvarint(buf, uint64(len(mask.Lower))) + buf = append(buf, mask.Lower...) + buf = binary.AppendUvarint(buf, uint64(len(mask.Upper))) + buf = append(buf, mask.Upper...) + e.writeBytes(buf) + } if len(x.Meta.BlobReferences) > 0 { writeBackingValueSize := false if x.Meta.Virtual { diff --git a/internal/manifest/version_edit_test.go b/internal/manifest/version_edit_test.go index 455de3c121c..cc8a4fe863e 100644 --- a/internal/manifest/version_edit_test.go +++ b/internal/manifest/version_edit_test.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "fmt" "io" + "math/rand/v2" "slices" "strconv" "strings" @@ -600,6 +601,91 @@ func TestVersionEditApply(t *testing.T) { }) } +func TestVersionEditRoundTripSuffixMask(t *testing.T) { + cmp := base.DefaultComparer.Compare + // makeTable builds a TableMetadata for the suffix-mask roundtrip test. + // Every case uses identical key bounds so the meaningful axis varies only + // in the masks field. + makeTable := func(tableNum base.TableNum, masks []sstable.SuffixMask) *TableMetadata { + m := (&TableMetadata{ + TableNum: tableNum, + Size: 1000, + SuffixMasks: masks, + }).ExtendPointKeyBounds( + cmp, + base.MakeInternalKey([]byte("a"), 0, base.InternalKeyKindSet), + base.MakeInternalKey([]byte("z"), 0, base.InternalKeyKindSet), + ) + m.InitPhysicalBacking() + return m + } + + t.Run("hand-picked cases", func(t *testing.T) { + // Anchor cases for regressions. checkRoundTrip walks the full + // VersionEdit via pretty.Diff, which covers SuffixMasks end-to-end. + cases := []struct { + name string + masks []sstable.SuffixMask + }{ + {"unset", nil}, + {"single equal-length bounds", []sstable.SuffixMask{{ + Lower: []byte{0, 0, 0, 0, 0, 0, 0, 0, 5}, + Upper: []byte{0, 0, 0, 0, 0, 0, 0, 0, 10}, + }}}, + {"single different-length bounds (wall-only / wall+logical)", []sstable.SuffixMask{{ + Lower: []byte{0, 0, 0, 0, 0, 0, 0, 0, 5}, // 9 bytes + Upper: []byte{0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 1}, // 13 bytes + }}}, + {"two disjoint masks", []sstable.SuffixMask{ + {Lower: []byte{0x10}, Upper: []byte{0x05}}, + {Lower: []byte{0x30}, Upper: []byte{0x20}}, + }}, + {"two contiguous masks", []sstable.SuffixMask{ + {Lower: []byte{0x20}, Upper: []byte{0x10}}, + {Lower: []byte{0x10}, Upper: []byte{0x05}}, + }}, + } + var tables []NewTableEntry + for i, tc := range cases { + tables = append(tables, NewTableEntry{ + Level: 6, + Meta: makeTable(base.TableNum(820+i), tc.masks), + }) + } + require.NoError(t, checkRoundTrip(VersionEdit{NewTables: tables})) + }) + + t.Run("randomized", func(t *testing.T) { + // Randomized roundtrip covers length-prefix arithmetic, asymmetric + // bound sizes, varying counts of masks per file, and pathological byte + // patterns that hand-picked cases can't enumerate exhaustively. + rng := rand.New(rand.NewPCG(1, 2)) + randBytes := func(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(rng.IntN(256)) + } + return b + } + const iterations = 200 + for i := 0; i < iterations; i++ { + numMasks := rng.IntN(4) // 0..3 masks per file + var masks []sstable.SuffixMask + for j := 0; j < numMasks; j++ { + lenL := 1 + rng.IntN(64) + lenU := 1 + rng.IntN(64) + masks = append(masks, sstable.SuffixMask{Lower: randBytes(lenL), Upper: randBytes(lenU)}) + } + ve := VersionEdit{NewTables: []NewTableEntry{{ + Level: 6, + Meta: makeTable(base.TableNum(i+1), masks), + }}} + require.NoError(t, checkRoundTrip(ve), "iteration %d masks=%d", i, numMasks) + } + }) + +} + func TestParseVersionEditDebugRoundTrip(t *testing.T) { testCases := []struct { input string diff --git a/metamorphic/config.go b/metamorphic/config.go index a74179a5696..10e3fe91fad 100644 --- a/metamorphic/config.go +++ b/metamorphic/config.go @@ -24,6 +24,7 @@ const ( OpDBCheckpoint OpDBClose OpDBCompact + OpDBDeleteSuffixRange OpDBDownload OpDBFlush OpDBRatchetFormatMajorVersion @@ -151,10 +152,22 @@ func DefaultOpConfig() OpConfig { return OpConfig{ // dbClose is not in this list since it is deterministically generated once, at the end of the test. ops: [NumOpTypes]int{ - OpBatchAbort: 5, - OpBatchCommit: 5, - OpDBCheckpoint: 1, - OpDBCompact: 1, + OpBatchAbort: 5, + OpBatchCommit: 5, + OpDBCheckpoint: 1, + OpDBCompact: 1, + // OpDBDeleteSuffixRange is wired up (generator, parser, FMV + // gating) and ratchets the FMV up to `FormatSuffixMask` before + // executing. Because DSR mutates the LSM non-additively + // (attaches per-file masks that subsequent compactions apply), + // classic `Snapshot` observers are unsafe in its presence — the + // same documented hazard that excise has. `newSnapshotOp.run` + // upgrades every snapshot to an EFOS when DSR ops are in the + // stream (`Test.dsrInOps`), and DSR itself (`DB.DeleteSuffixRange`) + // flushes overlapping memtables for any EFOS protected ranges, + // so each affected EFOS transitions to file-only before DSR + // runs and pins a pre-DSR `*Version`. + OpDBDeleteSuffixRange: 5, OpDBDownload: 1, OpDBFlush: 2, OpDBRatchetFormatMajorVersion: 1, @@ -222,6 +235,7 @@ func ReadOpConfig() OpConfig { OpBatchCommit: 0, OpDBCheckpoint: 0, OpDBCompact: 0, + OpDBDeleteSuffixRange: 5, OpDBFlush: 0, OpDBRatchetFormatMajorVersion: 0, OpDBRestart: 0, @@ -281,6 +295,7 @@ func WriteOpConfig() OpConfig { OpBatchCommit: 5, OpDBCheckpoint: 0, OpDBCompact: 1, + OpDBDeleteSuffixRange: 5, // see DefaultOpConfig comment OpDBFlush: 2, OpDBRatchetFormatMajorVersion: 1, OpDBRestart: 2, diff --git a/metamorphic/delete_suffix_range_test.go b/metamorphic/delete_suffix_range_test.go new file mode 100644 index 00000000000..f6e94a4084d --- /dev/null +++ b/metamorphic/delete_suffix_range_test.go @@ -0,0 +1,133 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +package metamorphic + +import ( + "bytes" + "io" + "math/rand/v2" + "strings" + "testing" + + "github.com/cockroachdb/pebble" + "github.com/cockroachdb/pebble/objstorage/remote" + "github.com/stretchr/testify/require" +) + +// TestDeleteSuffixRangeOpRoundTrip verifies that hand-constructed +// deleteSuffixRangeOps survive formatOps -> parse -> formatOps. +func TestDeleteSuffixRangeOpRoundTrip(t *testing.T) { + kf := TestkeysKeyFormat + ops := []op{ + &initOp{dbSlots: 1}, + &deleteSuffixRangeOp{ + dbID: makeObjID(dbTag, 1), + start: []byte("a"), + end: []byte("z"), + lower: []byte("@5"), + upper: []byte("@9"), + }, + } + src := formatOps(kf, ops) + parsed, err := parse([]byte(src), parserOpts{ + parseFormattedUserKey: kf.ParseFormattedKey, + parseFormattedUserKeySuffix: kf.ParseFormattedKeySuffix, + }) + require.NoError(t, err) + require.Equal(t, ops, parsed) +} + +// TestDeleteSuffixRangeOpGeneration verifies that a generator configured to +// produce DeleteSuffixRange ops actually produces them, and that the lower +// and upper suffix bounds are non-empty and well-ordered per the comparer's +// suffix ordering. The default config currently has DSR weight 0 (see the +// comment on OpDBDeleteSuffixRange in config.go), so we explicitly bump the +// weight here to exercise the generator path. +func TestDeleteSuffixRangeOpGeneration(t *testing.T) { + cfg := DefaultOpConfig().WithOpWeight(OpDBDeleteSuffixRange, 50) + rng := rand.New(rand.NewPCG(0, 42)) + km := newKeyManager(1 /* numInstances */, TestkeysKeyFormat) + g := newGenerator(rng, cfg, km) + ops := g.generate(2000) + + cmp := TestkeysKeyFormat.Comparer.ComparePointSuffixes + var dsrCount int + for _, o := range ops { + dsr, ok := o.(*deleteSuffixRangeOp) + if !ok { + continue + } + dsrCount++ + require.NotEmpty(t, dsr.lower, "DeleteSuffixRange.lower must be non-empty") + require.NotEmpty(t, dsr.upper, "DeleteSuffixRange.upper must be non-empty") + require.Less(t, cmp(dsr.lower, dsr.upper), 0, + "DeleteSuffixRange suffix bounds must satisfy lower < upper per ComparePointSuffixes") + require.Less(t, TestkeysKeyFormat.Comparer.Compare(dsr.start, dsr.end), 0, + "DeleteSuffixRange span must satisfy start < end") + } + require.NotZero(t, dsrCount, "expected non-zero DSR weight to generate DeleteSuffixRange ops") +} + +// TestDeleteSuffixRangeOpExecute runs a small hand-built op stream containing +// DeleteSuffixRange against a real database. The database is started at a +// format major version below FormatSuffixMask, ratcheted up to it, and then +// the DSR op is exercised. This serves as a smoke test that the op runs +// end-to-end through the metamorphic framework. +func TestDeleteSuffixRangeOpExecute(t *testing.T) { + kf := TestkeysKeyFormat + dbID := makeObjID(dbTag, 1) + ops := Ops{ + &initOp{dbSlots: 1}, + // Establish a non-trivial state: write a handful of versioned keys + // and flush so they reside in an sstable. + &setOp{writerID: dbID, key: []byte("a@5"), value: []byte("v1")}, + &setOp{writerID: dbID, key: []byte("a@7"), value: []byte("v2")}, + &setOp{writerID: dbID, key: []byte("b@5"), value: []byte("v3")}, + &setOp{writerID: dbID, key: []byte("b@9"), value: []byte("v4")}, + &setOp{writerID: dbID, key: []byte("c"), value: []byte("v5")}, // no suffix + &flushOp{db: dbID}, + &dbRatchetFormatMajorVersionOp{dbID: dbID, vers: pebble.FormatSuffixMask}, + &deleteSuffixRangeOp{ + dbID: dbID, + start: []byte("a"), + end: []byte("z"), + lower: []byte("@6"), + upper: []byte("@10"), + }, + &flushOp{db: dbID}, + &deleteSuffixRangeOp{ + dbID: dbID, + start: []byte("a"), + end: []byte("z"), + lower: []byte("@1"), + upper: []byte("@2"), + }, + &closeOp{objID: dbID}, + } + + rng := rand.New(rand.NewPCG(0, 1)) + testOpts := RandomOptions(rng, kf, RandomOptionsCfg{}) + // Force the starting FMV below FormatSuffixMask so we exercise the + // in-stream ratchet path. (RandomOptions can pick any FMV in range.) + // Also disable shared storage / external storage / WAL failover / and + // any other option that requires a higher FMV than FormatMinSupported; + // RandomOptions may have enabled them at higher FMVs and they'd fail + // `Options.Validate` once we ratchet the FMV back down. + testOpts.Opts.FormatMajorVersion = pebble.FormatMinSupported + testOpts.Opts.CreateOnShared = remote.CreateOnSharedNone + testOpts.sharedStorageEnabled = false + testOpts.externalStorageEnabled = false + testOpts.useSharedReplicate = false + testOpts.useExternalReplicate = false + + var historyBuf bytes.Buffer + test, err := New(ops, testOpts, "" /* dir */, io.MultiWriter(&historyBuf)) + require.NoError(t, err) + require.NoError(t, Execute(test)) + + // Sanity: the op should appear in the recorded history as DeleteSuffixRange. + require.True(t, strings.Contains(historyBuf.String(), "DeleteSuffixRange"), + "expected DeleteSuffixRange to appear in history; got:\n%s", historyBuf.String()) +} diff --git a/metamorphic/generator.go b/metamorphic/generator.go index 6b10010a697..6d1043c5d35 100644 --- a/metamorphic/generator.go +++ b/metamorphic/generator.go @@ -187,6 +187,7 @@ func (g *generator) generate(count uint64) []op { OpBatchCommit: g.batchCommit, OpDBCheckpoint: g.dbCheckpoint, OpDBCompact: g.dbCompact, + OpDBDeleteSuffixRange: g.dbDeleteSuffixRange, OpDBDownload: g.dbDownload, OpDBFlush: g.dbFlush, OpDBRatchetFormatMajorVersion: g.dbRatchetFormatMajorVersion, @@ -1229,6 +1230,58 @@ func (g *generator) writerDeleteRange() { }) } +// dbDeleteSuffixRange generates a DeleteSuffixRange operation against a +// randomly chosen database. The user-key span is built from two prefix keys +// (matching the convention used by other span-shaped ops); the suffix range +// is generated by the key generator's SuffixRange, with retries to avoid the +// nil-high case (DeleteSuffixRange requires a non-empty upper bound). +// +// A RatchetFormatMajorVersion(FormatSuffixMask) op is emitted immediately +// before the DSR — for every DB instance, not just the one DSR runs on. +// Configs vary in their starting FMV, so without the ratchet some configs +// would error on DSR (FMV too low) while others would succeed — the +// histories would diverge and the cross-config compare would fail. The +// ratchet is a no-op on configs already at FormatSuffixMask or above. +// +// We ratchet every DB (not just the DSR target) because DSR masks can be +// shipped across DB instances via Replicate (shared/external ingest carries +// SuffixMasks on ExternalFile/SharedSSTMeta). A destination DB at FMV < +// FormatSuffixMask cannot ingest masked vssts (the ingest path enforces an +// FMV check), so any DB that might serve as a Replicate destination must +// also be at FormatSuffixMask. The simplest safe answer is to ratchet +// every DB. +func (g *generator) dbDeleteSuffixRange() { + dbID := g.dbs.rand(g.rng) + start, end := g.prefixKeyRange() + + // SuffixRange may return a nil high suffix to represent an unbounded + // upper. DeleteSuffixRange requires a concrete non-empty upper bound, so + // retry a bounded number of times. If every attempt returns nil high, + // skip generation rather than synthesize an arbitrary upper bound. + var lower, upper []byte + for attempt := 0; attempt < 8; attempt++ { + lower, upper = g.keyGenerator.SuffixRange() + if len(lower) > 0 && len(upper) > 0 { + break + } + lower, upper = nil, nil + } + if len(lower) == 0 || len(upper) == 0 { + return + } + + for _, id := range g.dbs { + g.add(&dbRatchetFormatMajorVersionOp{dbID: id, vers: pebble.FormatSuffixMask}) + } + g.add(&deleteSuffixRangeOp{ + dbID: dbID, + start: start, + end: end, + lower: lower, + upper: upper, + }) +} + func (g *generator) writerRangeKeyDelete() { if len(g.liveWriters) == 0 { return diff --git a/metamorphic/key_manager.go b/metamorphic/key_manager.go index 98e91e6fb27..63625f4f458 100644 --- a/metamorphic/key_manager.go +++ b/metamorphic/key_manager.go @@ -637,6 +637,15 @@ func (k *keyManager) update(o op) { k.expandBounds(s.writerID, k.makeEndExclusiveBounds(s.start, s.end)) k.objKeyMeta(s.writerID).hasRangeDels = true + case *deleteSuffixRangeOp: + // DeleteSuffixRange hides keys whose suffixes fall in [lower, upper), + // but the underlying internal records remain in place; subsequent + // writes are unaffected. The key manager tracks writer-side history + // for SingleDelete eligibility, which DSR does not affect: it neither + // adds a SET nor performs a delete that resets the per-key history. + // Bounds are not expanded either because DSR neither writes records + // the underlying object nor adds keys to the global set. + case *singleDeleteOp: meta := k.getOrInit(s.writerID, s.key) meta.history = append(meta.history, keyHistoryItem{ @@ -887,6 +896,14 @@ func opWrittenKeys(untypedOp op) [][]byte { return [][]byte{t.key} case *deleteRangeOp: return [][]byte{t.start, t.end} + case *deleteSuffixRangeOp: + // DSR's start and end come from prefixKeyRange and may be bare + // prefixes that the key manager never recorded as user keys. + // Adding them to globalKeys during loadPrecedingKeys would inject + // keys the original generation run never saw, breaking the + // loadPrecedingKeys round-trip invariant. Range-key ops handle this + // the same way (their bounds are also tracked separately, not as + // user keys). case *flushOp: case *getOp: case *ingestOp: diff --git a/metamorphic/ops.go b/metamorphic/ops.go index 8202bda76d0..4b6d652dc3d 100644 --- a/metamorphic/ops.go +++ b/metamorphic/ops.go @@ -439,6 +439,140 @@ func (o *deleteRangeOp) diagramKeyRanges() []pebble.KeyRange { return []pebble.KeyRange{{Start: o.start, End: o.end}} } +// deleteSuffixRangeOp models a DB.DeleteSuffixRange operation. The operation +// hides all point keys and range key entries within the user-key span +// [start, end) whose suffixes fall in [lower, upper). +// +// DeleteSuffixRange is a DB-only API and requires FormatSuffixMask. The +// generator pairs each DSR op with a preceding RatchetFormatMajorVersion to +// FormatSuffixMask, so by the time this op runs every config has reached +// the required FMV and a real call is always made — no per-run no-op +// trickery is needed to keep histories aligned across configs. +type deleteSuffixRangeOp struct { + dbID objID + start UserKey + end UserKey + lower UserKeySuffix + upper UserKeySuffix +} + +func (o *deleteSuffixRangeOp) run(t *Test, h historyRecorder) { + db := t.getDB(o.dbID) + span := pebble.KeyRange{Start: o.start, End: o.end} + var err error + if t.testOpts.useScanDeleteForDSR { + err = scanDeleteEquivalentOfDSR(db, t, span, o.lower, o.upper) + } else { + err = db.DeleteSuffixRange(context.Background(), span, o.lower, o.upper) + } + h.Recordf("%s // %v", o.formattedString(t.testOpts.KeyFormat), err) +} + +// scanDeleteEquivalentOfDSR implements the user-visible semantics of +// `DB.DeleteSuffixRange` via explicit per-key Delete / RangeKeyUnset +// calls. It exists so that metamorphic configs running this path can be +// cross-compared against configs running real DSR: any divergence in +// subsequent reads is a bug in one of the implementations. +// +// The equivalence holds at the observable-read level, not at the LSM- +// state level: real DSR attaches metadata; this path writes physical +// tombstones at a new seqnum. Both should hide the same set of rows +// from all subsequent reads. +func scanDeleteEquivalentOfDSR( + db *pebble.DB, t *Test, span pebble.KeyRange, lower, upper []byte, +) error { + cmp := t.opts.Comparer + suffixCmp := cmp.ComparePointSuffixes + // inMask reports whether a key's suffix falls in [lower, upper). + // Empty (suffixless) keys are never masked. + inMask := func(suffix []byte) bool { + if len(suffix) == 0 { + return false + } + return suffixCmp(suffix, lower) >= 0 && suffixCmp(suffix, upper) < 0 + } + iter, err := db.NewIter(&pebble.IterOptions{ + LowerBound: span.Start, + UpperBound: span.End, + KeyTypes: pebble.IterKeyTypePointsAndRanges, + }) + if err != nil { + return err + } + defer func() { _ = iter.Close() }() + + // Collect the work first so that the Delete/RangeKeyUnset calls don't + // invalidate the iterator's view mid-scan. + type rkUnset struct { + start, end, suffix []byte + } + var pointDeletes [][]byte + var rkUnsets []rkUnset + seenRangeKeys := map[string]struct{}{} // dedupe (start|end|suffix) tuples + + for valid := iter.First(); valid && iter.Error() == nil; valid = iter.Next() { + hasPoint, hasRange := iter.HasPointAndRange() + if hasPoint { + k := iter.Key() + si := cmp.Split(k) + if inMask(k[si:]) { + pointDeletes = append(pointDeletes, append([]byte(nil), k...)) + } + } + if hasRange && iter.RangeKeyChanged() { + rkStart, rkEnd := iter.RangeBounds() + for _, rk := range iter.RangeKeys() { + if !inMask(rk.Suffix) { + continue + } + key := string(rkStart) + "\x00" + string(rkEnd) + "\x00" + string(rk.Suffix) + if _, ok := seenRangeKeys[key]; ok { + continue + } + seenRangeKeys[key] = struct{}{} + rkUnsets = append(rkUnsets, rkUnset{ + start: append([]byte(nil), rkStart...), + end: append([]byte(nil), rkEnd...), + suffix: append([]byte(nil), rk.Suffix...), + }) + } + } + } + if err := iter.Error(); err != nil { + return err + } + for _, k := range pointDeletes { + if err := db.Delete(k, t.writeOpts); err != nil { + return err + } + } + for _, u := range rkUnsets { + if err := db.RangeKeyUnset(u.start, u.end, u.suffix, t.writeOpts); err != nil { + return err + } + } + return nil +} + +func (o *deleteSuffixRangeOp) formattedString(kf KeyFormat) string { + return fmt.Sprintf("%s.DeleteSuffixRange(%q, %q, %q, %q)", + o.dbID, + kf.FormatKey(o.start), kf.FormatKey(o.end), + kf.FormatKeySuffix(o.lower), kf.FormatKeySuffix(o.upper)) +} + +func (o *deleteSuffixRangeOp) receiver() objID { return o.dbID } +func (o *deleteSuffixRangeOp) syncObjs() objIDSlice { return nil } + +func (o *deleteSuffixRangeOp) rewriteKeys(fn func(UserKey) UserKey) { + o.start = fn(o.start) + o.end = fn(o.end) +} + +func (o *deleteSuffixRangeOp) diagramKeyRanges() []pebble.KeyRange { + return []pebble.KeyRange{{Start: o.start, End: o.end}} +} + // flushOp models a DB.Flush operation. type flushOp struct { db objID @@ -1867,9 +2001,13 @@ func (o *newSnapshotOp) run(t *Test, h historyRecorder) { // Fibonacci hash https://probablydance.com/2018/06/16/fibonacci-hashing-the-optimization-that-the-world-forgot-or-a-better-alternative-to-integer-modulo/ createEfos := ((11400714819323198485 * uint64(t.idx) * t.testOpts.seedEFOS) >> 63) == 1 // If either of these options is true, an EFOS _must_ be created, regardless - // of what the fibonacci hash returned. + // of what the fibonacci hash returned. DSR is treated the same as excise: + // it mutates the LSM non-additively, classic Snapshots cannot pin a + // pre-DSR Version, and the only safe option for an overlapping snapshot + // is an EFOS that can transition to file-only before DSR runs (see + // `flushIfOverlappingDSR`). excisePossible := t.testOpts.useSharedReplicate || t.testOpts.useExternalReplicate || t.testOpts.useExcise - if createEfos || excisePossible { + if createEfos || excisePossible || t.dsrInOps { s := t.getDB(o.dbID).NewEventuallyFileOnlySnapshot(bounds) t.setSnapshot(o.snapID, s) } else { diff --git a/metamorphic/options.go b/metamorphic/options.go index e7de66218dd..39cdf0473a1 100644 --- a/metamorphic/options.go +++ b/metamorphic/options.go @@ -162,6 +162,8 @@ func parseOptions( case "TestOptions.use_excise": // TODO(radu): this should be on by default. opts.useExcise = true + case "TestOptions.use_scan_delete_for_dsr": + opts.useScanDeleteForDSR = true case "TestOptions.use_delete_only_compaction_excises": opts.useDeleteOnlyCompactionExcises = true opts.Opts.EnableDeleteOnlyCompactionExcises = func() bool { @@ -264,6 +266,9 @@ func optionsToString(opts *TestOptions) string { if opts.useExcise { fmt.Fprintf(&buf, " use_excise=%v\n", opts.useExcise) } + if opts.useScanDeleteForDSR { + fmt.Fprintf(&buf, " use_scan_delete_for_dsr=%v\n", opts.useScanDeleteForDSR) + } if opts.useDeleteOnlyCompactionExcises { fmt.Fprintf(&buf, " use_delete_only_compaction_excises=%v\n", opts.useDeleteOnlyCompactionExcises) } @@ -434,6 +439,14 @@ type TestOptions struct { // useDeleteOnlyCompactionExcises turns on the ability for delete-only compactions // to do excises. Note that this can be true even when useExcise is false. useDeleteOnlyCompactionExcises bool + // useScanDeleteForDSR routes each `deleteSuffixRangeOp` through an + // equivalent scan-and-delete code path instead of calling + // `DB.DeleteSuffixRange`. The recorded history is identical (the op + // records as a DSR regardless), so cross-config compare with another + // config running real DSR exercises the equivalence as a property + // test: any divergence in subsequent reads catches a bug in either + // implementation. + useScanDeleteForDSR bool // disableDownloads, if true, makes downloadOp a no-op. disableDownloads bool // treeSteps enables treesteps visualizations for each iterator operation. @@ -935,6 +948,9 @@ func RandomOptions(rng *rand.Rand, kf KeyFormat, cfg RandomOptionsCfg) *TestOpti opts.EnableDeleteOnlyCompactionExcises = func() bool { return testOpts.useDeleteOnlyCompactionExcises } + // Half of random configs route DSR through scan+delete. The cross-config + // compare with configs running real DSR is the equivalence check. + testOpts.useScanDeleteForDSR = rng.IntN(2) == 0 testOpts.disableDownloads = rng.IntN(2) == 0 testOpts.Opts.EnsureDefaults() return testOpts diff --git a/metamorphic/parser.go b/metamorphic/parser.go index 8333a5776cb..f70d6b96611 100644 --- a/metamorphic/parser.go +++ b/metamorphic/parser.go @@ -66,6 +66,8 @@ func opArgs(op op) (receiverID *objID, targetID *objID, args []interface{}) { return &t.writerID, nil, []interface{}{&t.key} case *deleteRangeOp: return &t.writerID, nil, []interface{}{&t.start, &t.end} + case *deleteSuffixRangeOp: + return &t.dbID, nil, []interface{}{&t.start, &t.end, &t.lower, &t.upper} case *downloadOp: return &t.dbID, nil, []interface{}{&t.spans} case *iterFirstOp: @@ -151,6 +153,7 @@ var methods = map[string]*methodInfo{ "EstimateDiskUsage": makeMethod(estimateDiskUsageOp{}, dbTag), "Delete": makeMethod(deleteOp{}, dbTag, batchTag), "DeleteRange": makeMethod(deleteRangeOp{}, dbTag, batchTag), + "DeleteSuffixRange": makeMethod(deleteSuffixRangeOp{}, dbTag), "Download": makeMethod(downloadOp{}, dbTag), "First": makeMethod(iterFirstOp{}, iterTag), "Flush": makeMethod(flushOp{}, dbTag), diff --git a/metamorphic/test.go b/metamorphic/test.go index ffdc7133d0c..7c843b88580 100644 --- a/metamorphic/test.go +++ b/metamorphic/test.go @@ -58,6 +58,13 @@ type Test struct { testOpts *TestOptions writeOpts *pebble.WriteOptions tmpDir string + // dsrInOps is true if any DeleteSuffixRange op exists in t.ops. When + // true, every classic-snapshot op is upgraded to an EFOS in + // newSnapshotOp.run, mirroring the way useExcise forces EFOS. Classic + // Snapshots are unsafe alongside DSR for the same reason they are + // unsafe alongside excise: DSR mutates the LSM non-additively and + // classic Snapshots cannot pin a pre-DSR Version. + dsrInOps bool // The DBs the test is run on. dbs []*pebble.DB // The slots for the batches, iterators, and snapshots. These are read and @@ -114,6 +121,12 @@ func (t *Test) init( numInstances = 1 } t.opsWaitOn, t.opsDone = computeSynchronizationPoints(t.ops) + for _, op := range t.ops { + if _, ok := op.(*deleteSuffixRangeOp); ok { + t.dsrInOps = true + break + } + } if t.opts.Cache != nil { defer t.opts.Cache.Unref() diff --git a/metamorphic/testdata/parser b/metamorphic/testdata/parser index bd9dfe5b928..35d5b05b784 100644 --- a/metamorphic/testdata/parser +++ b/metamorphic/testdata/parser @@ -38,3 +38,14 @@ batch0 = db.NewBatch() batch0.First() ---- metamorphic test internal error: 2:1: batch0.First: First is not a method on batch0 + +parse +db.DeleteSuffixRange("a", "z", "@5", "@9") +---- +db1.DeleteSuffixRange("a", "z", "@5", "@9") + +parse +batch0 = db.NewBatch() +batch0.DeleteSuffixRange("a", "z", "@5", "@9") +---- +metamorphic test internal error: 2:1: batch0.DeleteSuffixRange: DeleteSuffixRange is not a method on batch0 diff --git a/options.go b/options.go index 4fedeebbceb..16215ef9b30 100644 --- a/options.go +++ b/options.go @@ -1113,6 +1113,24 @@ type Options struct { // built and lives for the lifetime of writing that table. BlockPropertyCollectors []func() BlockPropertyCollector + // SuffixRangeIntersects, if non-nil, is consulted by DeleteSuffixRange + // to skip files whose block-property aggregates indicate no suffix in + // the file falls within the mask's range. The userProperties argument + // is the table-level UserProperties map (as exposed by + // sstable.Reader.UserProperties); lower and upper are the suffix + // bounds of the DeleteSuffixRange call (lower inclusive, upper + // exclusive in suffix-compare order). The hook must return true if + // any key in the file could be within the suffix range, and false + // only when it can prove no key intersects. + // + // Implementations typically wrap a BlockIntervalCollector aggregate + // (e.g., cockroachkvs.SuffixRangeIntersectsTable). If nil, + // DeleteSuffixRange processes every overlapping file conservatively + // (the previous behavior). + // + // Experimental. + SuffixRangeIntersects func(userProperties map[string]string, lower, upper []byte) bool + // WALBytesPerSync sets the number of bytes to write to a WAL before calling // Sync on it in the background. Just like with BytesPerSync above, this // helps smooth out disk write latencies, and avoids cases where the OS @@ -1228,6 +1246,15 @@ type Options struct { // before calling DB.ingestApply. testingBeforeIngestApplyFunc func() + // testingDuringCompactionIOFunc when non-nil, is called from the I/O + // phase of a default table compaction, after the compaction has written + // its outputs and constructed its version edit, but before re-acquiring + // DB.mu to apply it. The hook is invoked without DB.mu held, providing + // a window in which tests can inject concurrent operations (e.g., + // DeleteSuffixRange) that race with the in-progress compaction's + // version-edit application. + testingDuringCompactionIOFunc func() + // timeNow returns the current time. It defaults to time.Now. It's // configurable here so that tests can mock the current time. timeNow func() time.Time diff --git a/scan_internal.go b/scan_internal.go index 09d95547583..368b074c2d5 100644 --- a/scan_internal.go +++ b/scan_internal.go @@ -87,6 +87,13 @@ type SharedSSTMeta struct { // Size contains an estimate of the size of this sstable. Size uint64 + // SuffixMasks are the suffix masks attached to the source vsst (typically + // via `DB.DeleteSuffixRange`). They are a read-time presentation of the + // file, not a property of the backing bytes — the destination must + // reconstruct the same masks on its vsst to observe the source's view. + // When non-empty, the destination must be at `FormatSuffixMask` or higher. + SuffixMasks []sstable.SuffixMask + // tableNum at time of creation in the creator instance. Only used for // debugging/tests. tableNum base.TableNum @@ -99,6 +106,7 @@ func (s *SharedSSTMeta) cloneFromFileMeta(f *manifest.TableMetadata) { SmallestPointKey: f.PointKeyBounds.Smallest().Clone(), LargestPointKey: f.PointKeyBounds.Largest().Clone(), Size: f.Size, + SuffixMasks: cloneSuffixMasks(f.SuffixMasks), tableNum: f.TableNum, } if f.HasRangeKeys { @@ -107,6 +115,23 @@ func (s *SharedSSTMeta) cloneFromFileMeta(f *manifest.TableMetadata) { } } +// cloneSuffixMasks returns a deep copy of masks suitable for shipping across +// the Replicate boundary (or any other context where the source and +// destination must not alias mask byte slices). +func cloneSuffixMasks(masks []sstable.SuffixMask) []sstable.SuffixMask { + if len(masks) == 0 { + return nil + } + out := make([]sstable.SuffixMask, len(masks)) + for i, m := range masks { + out[i] = sstable.SuffixMask{ + Lower: slices.Clone(m.Lower), + Upper: slices.Clone(m.Upper), + } + } + return out +} + // ScanInternal scans all internal keys within the specified bounds, truncating // any rangedels and rangekeys to those bounds if they span past them. For use // when an external user needs to be aware of all internal keys that make up a @@ -653,6 +678,7 @@ func (d *DB) truncateExternalFile( Size: file.Size, SyntheticPrefix: slices.Clone(file.SyntheticPrefixAndSuffix.Prefix()), SyntheticSuffix: slices.Clone(file.SyntheticPrefixAndSuffix.Suffix()), + SuffixMasks: cloneSuffixMasks(file.SuffixMasks), } needsLowerTruncate := cmp(lower, file.Smallest().UserKey) > 0 diff --git a/sstable/blockiter/transforms.go b/sstable/blockiter/transforms.go index 62df229f0d3..5360926bdbd 100644 --- a/sstable/blockiter/transforms.go +++ b/sstable/blockiter/transforms.go @@ -12,6 +12,47 @@ import ( "github.com/cockroachdb/pebble/internal/base" ) +// SuffixMask defines a range of suffixes [Lower, Upper) to mask during +// iteration. Lower and Upper are ordered according to ComparePointSuffixes +// (i.e. Lower <= Upper per the comparer). Keys whose suffix falls within +// the range are hidden. Suffixless keys are never hidden. Both bounds +// must be set. Lower is inclusive; Upper is exclusive. +// +// NB: "Lower" and "Upper" refer to the comparer's ordering, not wall-time +// magnitude. For a comparer like CockroachDB's that sorts newest-first, +// Lower is the newest timestamp and Upper is the oldest. To revert to a +// timestamp T (hiding all keys newer than T), Lower would be the newest +// timestamp to hide (e.g. infinity) and Upper would be T (exclusive, so +// keys at T remain visible). +// +// Interaction with `SyntheticSuffix`: under a `SyntheticSuffix` +// transform, every non-empty stored suffix has the same effective +// suffix (the synthetic one), so the per-row check operates on the +// synth — see `colblk/data_block.go::isSuffixMasked` and +// `rowblk/rowblk_iter.go`. Empty stored suffixes retain empty effective +// suffix per the `SyntheticSuffix` contract and are never masked. +// `DeleteSuffixRange` optimizes the common case (synth-in-mask + no +// range keys) by excising the file's overlap rather than attaching a +// per-row mask; synth-in-mask files WITH range keys fall through to +// per-row attachment so that `RangeKeyDelete` entries and empty-suffix +// `RangeKeySet` entries (both never-masked) are preserved. +// +// TODO(dt): compact Lower and Upper into a single allocation like +// SyntheticPrefixAndSuffix to reduce TableMetadata size. +type SuffixMask struct { + Lower []byte + Upper []byte +} + +// IsSet returns true if the suffix mask is configured. A mask is considered +// configured only when both bounds are non-empty — `DeleteSuffixRange`'s +// validation and the manifest decoder both reject empty-bound masks, so an +// asymmetric (Lower set, Upper unset) value indicates a programming bug +// elsewhere. +func (m SuffixMask) IsSet() bool { + return len(m.Lower) > 0 && len(m.Upper) > 0 +} + // Transforms allow on-the-fly transformation of data at iteration time. // // These transformations could in principle be implemented as block transforms @@ -25,6 +66,13 @@ type Transforms struct { // This is the norm when the sstable is foreign or the largest sequence number // of the sstable is below the one we are reading. HideObsoletePoints bool + // SuffixMasks, if non-empty, hides point keys whose suffix falls within + // any of the configured mask ranges [Lower, Upper) as determined by the + // key schema. Keys with no suffix are never hidden. + // + // The slice and its byte contents are immutable by convention; mutators + // must clone before modifying. + SuffixMasks []SuffixMask SyntheticPrefixAndSuffix SyntheticPrefixAndSuffix } @@ -36,6 +84,7 @@ var NoTransforms = Transforms{} func (t *Transforms) NoTransforms() bool { return t.SyntheticSeqNum == 0 && !t.HideObsoletePoints && + len(t.SuffixMasks) == 0 && t.SyntheticPrefixAndSuffix.IsUnset() } @@ -60,12 +109,20 @@ func (t *Transforms) SyntheticSuffix() []byte { type FragmentTransforms struct { SyntheticSeqNum SyntheticSeqNum SyntheticPrefixAndSuffix SyntheticPrefixAndSuffix + // SuffixMasks, if non-empty, filters out range key entries whose suffix + // falls within any of the configured mask ranges [Lower, Upper). Entries + // with no suffix pass through unfiltered. + // + // The slice and its byte contents are immutable by convention; mutators + // must clone before modifying. + SuffixMasks []SuffixMask } // NoTransforms returns true if there are no transforms enabled. func (t *FragmentTransforms) NoTransforms() bool { - // NoTransforms returns true if there are no transforms enabled. - return t.SyntheticSeqNum == 0 && t.SyntheticPrefixAndSuffix.IsUnset() + return t.SyntheticSeqNum == 0 && + t.SyntheticPrefixAndSuffix.IsUnset() && + len(t.SuffixMasks) == 0 } func (t *FragmentTransforms) HasSyntheticPrefix() bool { diff --git a/sstable/colblk/data_block.go b/sstable/colblk/data_block.go index b8f2730ea56..caaa9df194c 100644 --- a/sstable/colblk/data_block.go +++ b/sstable/colblk/data_block.go @@ -190,6 +190,23 @@ type KeySeeker interface { MaterializeUserKeyWithSyntheticSuffix( keyIter *PrefixBytesIter, syntheticSuffix []byte, prevRow, row int, ) []byte + + // IsMaskedBySuffixMask returns true if the key at the given row + // should be hidden because its suffix falls within the mask range + // [lower, upper) according to ComparePointSuffixes. Suffixless keys + // must return false. Implementations may use direct column access + // for performance (e.g., reading MVCC timestamps from columnar + // storage) rather than materializing the full key. + IsMaskedBySuffixMask(row int, lower, upper []byte) bool + + // HasNonEmptySuffix returns true if the row's stored suffix is + // non-empty. Used by the SuffixMask path on iterators with a + // SyntheticSuffix transform: every row with a non-empty stored + // suffix has the same effective suffix (the synthetic one), so the + // mask answer is uniform — once the iterator has determined that + // the synthetic suffix is in the mask range, the per-row decision + // reduces to "is the stored suffix non-empty". + HasNonEmptySuffix(row int) bool } const ( @@ -459,6 +476,28 @@ func (ks *defaultKeySeeker) MaterializeUserKeyWithSyntheticSuffix( return res } +// IsMaskedBySuffixMask implements KeySeeker. +func (ks *defaultKeySeeker) IsMaskedBySuffixMask(row int, lower, upper []byte) bool { + suffix := ks.suffixes.At(row) + if len(suffix) == 0 { + return false + } + suffixCmp := ks.comparer.ComparePointSuffixes + if suffixCmp == nil { + // This panic protects against silently leaking keys that should be + // masked. A SuffixMask requires a comparer that knows how to order + // suffixes; configuring one without the other is a programming bug. + panic(errors.AssertionFailedf( + "SuffixMask requires Comparer.ComparePointSuffixes (comparer %q)", ks.comparer.Name)) + } + return suffixCmp(suffix, lower) >= 0 && suffixCmp(suffix, upper) < 0 +} + +// HasNonEmptySuffix implements KeySeeker. +func (ks *defaultKeySeeker) HasNonEmptySuffix(row int) bool { + return len(ks.suffixes.At(row)) > 0 +} + // DataBlockEncoder encodes columnar data blocks using a user-defined schema. type DataBlockEncoder struct { Schema *KeySchema @@ -1478,12 +1517,10 @@ func (i *DataBlockIter) SeekGE(key []byte, flags base.SeekGEFlags) *base.Interna i.row, _ = i.seekGEInternal(key, i.row, searchDir) if i.transforms.HideObsoletePoints { i.nextObsoletePoint = i.d.isObsolete.SeekSetBitGE(i.row) - if i.atObsoletePointForward() { - i.skipObsoletePointsForward() - if i.row > i.maxRow { - return nil - } - } + } + i.skipHiddenForward() + if i.row > i.maxRow { + return nil } return i.decodeRow() } @@ -1509,13 +1546,13 @@ func (i *DataBlockIter) SeekPrefixGE( i.row, equalPrefix = i.keySeeker.SeekGE(key, i.row, searchDir) } else { i.row, equalPrefix = i.seekGEInternal(key, i.row, searchDir) - if i.transforms.HideObsoletePoints { + if i.transforms.HideObsoletePoints || len(i.transforms.SuffixMasks) > 0 { startRow := i.row - i.nextObsoletePoint = i.d.isObsolete.SeekSetBitGE(i.row) - if i.atObsoletePointForward() { - i.skipObsoletePointsForward() + if i.transforms.HideObsoletePoints { + i.nextObsoletePoint = i.d.isObsolete.SeekSetBitGE(i.row) } - // If skipping obsolete points crossed a prefix boundary, the + i.skipHiddenForward() + // If skipping hidden points crossed a prefix boundary, the // resulting row has a different prefix from the seek key. if equalPrefix && i.row > startRow && i.d.prefixChanged.SeekSetBitGE(startRow+1) <= i.row { @@ -1542,12 +1579,10 @@ func (i *DataBlockIter) SeekLT(key []byte, _ base.SeekLTFlags) *base.InternalKV i.row = row - 1 if i.transforms.HideObsoletePoints { i.nextObsoletePoint = i.d.isObsolete.SeekSetBitGE(max(i.row, 0)) - if i.atObsoletePointBackward() { - i.skipObsoletePointsBackward() - if i.row < 0 { - return nil - } - } + } + i.skipHiddenBackward() + if i.row < 0 { + return nil } return i.decodeRow() } @@ -1560,12 +1595,10 @@ func (i *DataBlockIter) First() *base.InternalKV { i.row = 0 if i.transforms.HideObsoletePoints { i.nextObsoletePoint = i.d.isObsolete.SeekSetBitGE(0) - if i.atObsoletePointForward() { - i.skipObsoletePointsForward() - if i.row > i.maxRow { - return nil - } - } + } + i.skipHiddenForward() + if i.row > i.maxRow { + return nil } return i.decodeRow() } @@ -1648,12 +1681,10 @@ func (i *DataBlockIter) Last() *base.InternalKV { i.row = i.maxRow if i.transforms.HideObsoletePoints { i.nextObsoletePoint = i.maxRow + 1 - if i.atObsoletePointBackward() { - i.skipObsoletePointsBackward() - if i.row < 0 { - return nil - } - } + } + i.skipHiddenBackward() + if i.row < 0 { + return nil } return i.decodeRow() } @@ -1677,11 +1708,9 @@ func (i *DataBlockIter) Next() *base.InternalKV { Trailer: base.InternalKeyTrailer(i.d.trailers.At(i.row)), } } else { - if i.transforms.HideObsoletePoints && i.atObsoletePointForward() { - i.skipObsoletePointsForward() - if i.row > i.maxRow { - return nil - } + i.skipHiddenForward() + if i.row > i.maxRow { + return nil } if i.transforms.HasSyntheticSuffix() { i.kv.K.UserKey = i.keySeeker.MaterializeUserKeyWithSyntheticSuffix( @@ -1749,9 +1778,7 @@ func (i *DataBlockIter) NextWithSamePrefix() (kv *base.InternalKV, prefixExhaust } } else { // Transforms branch (cold). - if i.transforms.HideObsoletePoints && i.atObsoletePointForward() { - i.skipObsoletePointsForward() - } + i.skipHiddenForward() if i.row > i.maxRow { return nil, false } @@ -1813,11 +1840,11 @@ func (i *DataBlockIter) NextPrefix(_ []byte) *base.InternalKV { i.row = i.d.prefixChanged.SeekSetBitGE(i.row + 1) if i.transforms.HideObsoletePoints { i.nextObsoletePoint = i.d.isObsolete.SeekSetBitGE(i.row) - if i.atObsoletePointForward() { - i.skipObsoletePointsForward() - } } - + i.skipHiddenForward() + if i.row > i.maxRow { + return nil + } return i.decodeRow() } @@ -1827,11 +1854,9 @@ func (i *DataBlockIter) Prev() *base.InternalKV { return nil } i.row-- - if i.transforms.HideObsoletePoints && i.atObsoletePointBackward() { - i.skipObsoletePointsBackward() - if i.row < 0 { - return nil - } + i.skipHiddenBackward() + if i.row < 0 { + return nil } return i.decodeRow() } @@ -1884,6 +1909,115 @@ func (i *DataBlockIter) atObsoletePointCheck() { } } +// isSuffixMasked returns true if the key at i.row is masked by any of the +// configured SuffixMasks. The key seeker fast-paths each check when possible +// and falls back to materializing the suffix and comparing. +// +// Under SyntheticSuffix, every non-empty stored suffix has the same effective +// suffix (the synthetic one); empty stored suffixes retain empty effective +// suffix per the SyntheticSuffix contract and are never masked. We compute +// synth-in-mask once and reduce the per-row check to HasNonEmptySuffix. +// +// TODO(dt): a synth-suffix file's mask answer is uniform across all rows +// with non-empty stored suffix. DSR installs per-row masks on such files +// only when (synth-in-mask AND HasRangeKeys) — see the synth-suffix branch +// in `suffix_mask.go::DeleteSuffixRange`. An iter-init-time cache of +// synth-in-mask would collapse this further (no per-call mask scan), and +// in many cases (no empty-suffix RangeKeySet, no RangeKeyDelete) we could +// avoid the per-row mask path entirely if we tracked richer metadata. +func (i *DataBlockIter) isSuffixMasked() bool { + if i.transforms.HasSyntheticSuffix() { + synth := i.transforms.SyntheticSuffix() + if i.suffixCmp == nil { + // Mirror the assertion in defaultKeySeeker.IsMaskedBySuffixMask: + // SuffixMasks require a suffix comparer. + panic(errors.AssertionFailedf( + "SuffixMask requires Comparer.ComparePointSuffixes")) + } + synthInMask := false + for _, m := range i.transforms.SuffixMasks { + if i.suffixCmp(synth, m.Lower) >= 0 && i.suffixCmp(synth, m.Upper) < 0 { + synthInMask = true + break + } + } + if !synthInMask { + return false + } + return i.keySeeker.HasNonEmptySuffix(i.row) + } + for _, m := range i.transforms.SuffixMasks { + if i.keySeeker.IsMaskedBySuffixMask(i.row, m.Lower, m.Upper) { + return true + } + } + return false +} + +// skipHiddenForward advances i.row past any rows that should be hidden by +// HideObsoletePoints or SuffixMasks, interleaving the two predicates so that +// a mask-skip landing on an obsolete row (or vice versa) is handled +// correctly. If HideObsoletePoints is enabled, the caller must initialize +// nextObsoletePoint before the call (and i.row must satisfy the +// atObsoletePointForward invariant: i.row <= nextObsoletePoint). +func (i *DataBlockIter) skipHiddenForward() { + hideObsolete := i.transforms.HideObsoletePoints + hasMask := len(i.transforms.SuffixMasks) > 0 + if !hideObsolete && !hasMask { + return + } + maskSkipped := false + for i.row <= i.maxRow { + if hideObsolete && i.atObsoletePointForward() { + i.skipObsoletePointsForward() + continue + } + if hasMask && i.isSuffixMasked() { + i.row++ + maskSkipped = true + // After advancing past a masked row we may now be at or beyond + // nextObsoletePoint; re-sync so the next obsolete check has a + // valid invariant and skipObsoletePointsForward can run. + if hideObsolete && i.row > i.nextObsoletePoint { + i.nextObsoletePoint = i.d.isObsolete.SeekSetBitGE(i.row) + } + continue + } + break + } + if maskSkipped { + // Invalidate kvRow so decodeRow performs a full decode of the + // unmasked row. The mask check may have advanced i.row past one or + // more rows whose materialized state would otherwise be reused. + i.kvRow = math.MinInt + } +} + +// skipHiddenBackward is the backward analog of skipHiddenForward. +func (i *DataBlockIter) skipHiddenBackward() { + hideObsolete := i.transforms.HideObsoletePoints + hasMask := len(i.transforms.SuffixMasks) > 0 + if !hideObsolete && !hasMask { + return + } + maskSkipped := false + for i.row >= 0 { + if hideObsolete && i.atObsoletePointBackward() { + i.skipObsoletePointsBackward() + continue + } + if hasMask && i.isSuffixMasked() { + i.row-- + maskSkipped = true + continue + } + break + } + if maskSkipped { + i.kvRow = math.MinInt + } +} + // Error implements the base.InternalIterator interface. A DataBlockIter is // infallible and always returns a nil error. func (i *DataBlockIter) Error() error { diff --git a/sstable/colblk/data_block_test.go b/sstable/colblk/data_block_test.go index a3a232530cd..99299248f96 100644 --- a/sstable/colblk/data_block_test.go +++ b/sstable/colblk/data_block_test.go @@ -145,10 +145,12 @@ func TestDataBlock(t *testing.T) { td.MaybeScanArgs(t, "synthetic-seq-num", &seqNum) td.MaybeScanArgs(t, "synthetic-prefix", &syntheticPrefix) td.MaybeScanArgs(t, "synthetic-suffix", &syntheticSuffix) + masks := parseSuffixMaskArgs(t, td) transforms := blockiter.Transforms{ SyntheticSeqNum: blockiter.SyntheticSeqNum(seqNum), HideObsoletePoints: td.HasArg("hide-obsolete-points"), SyntheticPrefixAndSuffix: blockiter.MakeSyntheticPrefixAndSuffix([]byte(syntheticPrefix), []byte(syntheticSuffix)), + SuffixMasks: masks, } if err := it.Init(&r, bd, transforms, tieringConfig); err != nil { return err.Error() @@ -552,3 +554,458 @@ func BenchmarkDataBlockDecoderInit(b *testing.B) { InitDataBlockMetadata(&testKeysSchema, &md, finished) } } + +// TestDataBlockIterSuffixMaskOracle exercises every DataBlockIter positioning +// method under random combinations of SuffixMask and HideObsoletePoints, +// comparing against an oracle that filters the raw key list in user space. +// +// The oracle is the natural specification for "hide keys whose suffix is in +// [Lower, Upper)" — anything more clever would just reimplement the iterator +// (and likely the same bug). This test would have caught the obsolete-then- +// mask interleave bug, would catch any positioning method that forgets to +// call the hidden-row skip, and would catch any divergence between the row- +// level mask predicate and ComparePointSuffixes. +func TestDataBlockIterSuffixMaskOracle(t *testing.T) { + const targetBlockSize = 4 << 10 + seed := uint64(time.Now().UnixNano()) + t.Logf("seed: %d", seed) + rng := rand.New(rand.NewPCG(0, seed)) + + // Generate keys with a small prefix space so many keys share prefixes + // (exercises NextPrefix/SeekPrefixGE more thoroughly). + keys, values := makeTestKeyRandomKVs(rng, 2, 8, targetBlockSize) + slices.SortFunc(keys, testkeys.Comparer.Compare) + + // Build a block where ~1/4 of rows are marked obsolete. + var w DataBlockEncoder + w.Init(&testKeysSchema, NoTieringColumns()) + var blockKeys [][]byte + var blockObsolete []bool + for j := 0; w.Size() < targetBlockSize && j < len(keys); j++ { + ik := base.MakeInternalKey(keys[j], base.SeqNum(j+1), base.InternalKeyKindSet) + kcmp := w.KeyWriter.ComparePrev(ik.UserKey) + vp := block.InPlaceValuePrefix(kcmp.PrefixEqual()) + isObsolete := rng.IntN(4) == 0 + w.Add(ik, values[j], vp, kcmp, isObsolete, base.KVMeta{}) + blockKeys = append(blockKeys, append([]byte(nil), ik.UserKey...)) + blockObsolete = append(blockObsolete, isObsolete) + } + blockData, _ := w.Finish(w.Rows(), w.Size()) + t.Logf("rows: %d", len(blockKeys)) + + var r DataBlockDecoder + bd := r.Init(&testKeysSchema, blockData) + split := testkeys.Comparer.Split + cmp := testkeys.Comparer.Compare + suffixCmp := testkeys.Comparer.ComparePointSuffixes + + // suffixOf returns the suffix bytes of a key (empty if suffixless). + suffixOf := func(k []byte) []byte { + n := split(k) + return k[n:] + } + + // randMask returns a random non-zero SuffixMask. Returns the zero value + // if there are not enough distinct suffixes to construct one. + randMask := func() (blockiter.SuffixMask, bool) { + // Pick two suffixes from existing keys' suffixes so the mask is + // likely to actually filter some rows. The testkeys comparer orders + // larger numeric suffixes first, so Lower must compare <= Upper per + // the comparer (i.e. Lower's numeric suffix is larger). + var suffixes [][]byte + for _, k := range blockKeys { + if s := suffixOf(k); len(s) > 0 { + suffixes = append(suffixes, s) + } + } + if len(suffixes) < 2 { + return blockiter.SuffixMask{}, false + } + a := suffixes[rng.IntN(len(suffixes))] + b := suffixes[rng.IntN(len(suffixes))] + if suffixCmp(a, b) > 0 { + a, b = b, a + } + return blockiter.SuffixMask{Lower: a, Upper: b}, true + } + + // randMasks returns 0..3 random SuffixMasks. + randMasks := func() []blockiter.SuffixMask { + n := rng.IntN(4) // 0..3 + var out []blockiter.SuffixMask + for j := 0; j < n; j++ { + if m, ok := randMask(); ok { + out = append(out, m) + } + } + return out + } + + // visibleRows returns the indices of rows visible under transforms, in + // block order. This is the oracle. + visibleRows := func(tr blockiter.Transforms) []int { + var out []int + for i, k := range blockKeys { + if tr.HideObsoletePoints && blockObsolete[i] { + continue + } + if masked := func() bool { + if len(tr.SuffixMasks) == 0 { + return false + } + s := suffixOf(k) + if len(s) == 0 { + return false + } + for _, m := range tr.SuffixMasks { + if suffixCmp(s, m.Lower) >= 0 && suffixCmp(s, m.Upper) < 0 { + return true + } + } + return false + }(); masked { + continue + } + out = append(out, i) + } + return out + } + + const trials = 25 + for trial := 0; trial < trials; trial++ { + tr := blockiter.Transforms{ + HideObsoletePoints: rng.IntN(2) == 0, + SuffixMasks: randMasks(), + } + t.Run(fmt.Sprintf("trial%d", trial), func(t *testing.T) { + t.Logf("hideObsolete=%v numMasks=%d", tr.HideObsoletePoints, len(tr.SuffixMasks)) + for i, m := range tr.SuffixMasks { + t.Logf(" mask[%d]=[%x,%x)", i, m.Lower, m.Upper) + } + + visible := visibleRows(tr) + t.Logf("visible rows: %d / %d", len(visible), len(blockKeys)) + + newIter := func() *DataBlockIter { + it := &DataBlockIter{} + it.InitOnce(&testKeysSchema, testkeys.Comparer, + getInternalValuer(func([]byte) base.InternalValue { + return base.MakeInPlaceValue(nil) + }), NoTieringColumns()) + if err := it.Init(&r, bd, tr, NoTieringColumns()); err != nil { + t.Fatal(err) + } + return it + } + + // Forward traversal: First/Next must yield exactly the visible rows + // in order. + t.Run("forward", func(t *testing.T) { + it := newIter() + defer it.Close() + var got []int + for kv := it.First(); kv != nil; kv = it.Next() { + got = append(got, it.row) + } + if !slices.Equal(got, visible) { + t.Fatalf("forward got rows %v, want %v", got, visible) + } + }) + + // Backward traversal: Last/Prev must yield visible rows in reverse. + t.Run("backward", func(t *testing.T) { + it := newIter() + defer it.Close() + var got []int + for kv := it.Last(); kv != nil; kv = it.Prev() { + got = append(got, it.row) + } + want := slices.Clone(visible) + slices.Reverse(want) + if !slices.Equal(got, want) { + t.Fatalf("backward got rows %v, want %v", got, want) + } + }) + + // SeekGE then Next: for each block key, SeekGE(k) must land at the + // first visible row whose key is >= k. + t.Run("seek-ge", func(t *testing.T) { + it := newIter() + defer it.Close() + for _, seekKey := range blockKeys { + var want []int + for _, r := range visible { + if cmp(blockKeys[r], seekKey) >= 0 { + want = append(want, r) + } + } + var got []int + for kv := it.SeekGE(seekKey, base.SeekGEFlagsNone); kv != nil; kv = it.Next() { + got = append(got, it.row) + } + if !slices.Equal(got, want) { + t.Fatalf("SeekGE(%q): got %v, want %v", seekKey, got, want) + } + } + }) + + // SeekLT then Prev: for each block key, SeekLT(k) must land at the + // last visible row whose key is < k. + t.Run("seek-lt", func(t *testing.T) { + it := newIter() + defer it.Close() + for _, seekKey := range blockKeys { + var want []int + for _, r := range visible { + if cmp(blockKeys[r], seekKey) < 0 { + want = append([]int{r}, want...) // reverse order + } + } + var got []int + for kv := it.SeekLT(seekKey, base.SeekLTFlagsNone); kv != nil; kv = it.Prev() { + got = append(got, it.row) + } + if !slices.Equal(got, want) { + t.Fatalf("SeekLT(%q): got %v, want %v", seekKey, got, want) + } + } + }) + + // NextPrefix: First then NextPrefix repeatedly must yield the first + // visible row of each distinct prefix in block order. + t.Run("next-prefix", func(t *testing.T) { + it := newIter() + defer it.Close() + var want []int + var lastPrefix []byte + for _, r := range visible { + p := blockKeys[r][:split(blockKeys[r])] + if lastPrefix == nil || !bytes.Equal(p, lastPrefix) { + want = append(want, r) + lastPrefix = p + } + } + var got []int + kv := it.First() + for kv != nil { + got = append(got, it.row) + kv = it.NextPrefix(nil) + } + if !slices.Equal(got, want) { + t.Fatalf("NextPrefix got %v, want %v", got, want) + } + }) + }) + } +} + +// TestDataBlockIterSuffixMaskSyntheticOracle exercises the synth-aware per-row +// mask check in `isSuffixMasked`: under a SyntheticSuffix transform, the +// effective suffix of any row with a non-empty stored suffix is the synthetic +// one (empty stored suffixes retain empty and are never masked, per the DSR +// contract). +// +// The block under test mixes suffixless rows with rows at known suffix values. +// For each trial the test picks a random synthetic suffix and a random mask +// set, computes the visible rows with the oracle, and verifies First/Next and +// Last/Prev produce exactly those rows. Seek positioning under synth +// substitution is intentionally not exercised here — the synth substitution +// changes the user-visible keys' sort order in ways that make a row-index- +// based oracle awkward; correctness of the per-row mask predicate is the +// concern this test pins, and forward/backward suffice. +func TestDataBlockIterSuffixMaskSyntheticOracle(t *testing.T) { + seed := uint64(time.Now().UnixNano()) + t.Logf("seed: %d", seed) + rng := rand.New(rand.NewPCG(0, seed)) + + // Build a block by hand: a few rows per prefix, mixing suffixless rows + // and rows at @10, @50, @99. Some rows are obsolete. + type kv struct { + key []byte + obsolete bool + } + mk := func(prefix string, suffix int64) []byte { + buf := make([]byte, len(prefix)+testkeys.MaxSuffixLen) + copy(buf, prefix) + if suffix < 0 { + return buf[:len(prefix)] + } + n := testkeys.WriteSuffix(buf[len(prefix):], suffix) + return buf[:len(prefix)+n] + } + rows := []kv{ + {mk("aa", -1), false}, // suffixless + {mk("aa", 99), true}, // obsolete + {mk("aa", 50), false}, + {mk("aa", 10), false}, + {mk("bb", -1), false}, // suffixless + {mk("bb", 99), false}, + {mk("bb", 10), true}, // obsolete + {mk("cc", 50), false}, + {mk("cc", 10), false}, + {mk("dd", -1), false}, // suffixless + } + // Sort by key per the testkeys comparer (larger numeric suffix sorts + // first within a prefix; suffixless sorts last). + slices.SortFunc(rows, func(a, b kv) int { return testkeys.Comparer.Compare(a.key, b.key) }) + + var w DataBlockEncoder + w.Init(&testKeysSchema, NoTieringColumns()) + var blockKeys [][]byte + var blockObsolete []bool + for j, r := range rows { + ik := base.MakeInternalKey(r.key, base.SeqNum(j+1), base.InternalKeyKindSet) + kcmp := w.KeyWriter.ComparePrev(ik.UserKey) + vp := block.InPlaceValuePrefix(kcmp.PrefixEqual()) + w.Add(ik, []byte("v"), vp, kcmp, r.obsolete, base.KVMeta{}) + blockKeys = append(blockKeys, append([]byte(nil), ik.UserKey...)) + blockObsolete = append(blockObsolete, r.obsolete) + } + blockData, _ := w.Finish(w.Rows(), w.Size()) + + var r DataBlockDecoder + bd := r.Init(&testKeysSchema, blockData) + split := testkeys.Comparer.Split + suffixCmp := testkeys.Comparer.ComparePointSuffixes + + suffixOf := func(k []byte) []byte { + n := split(k) + return k[n:] + } + + // All stored non-empty suffixes are @1..@99 (well, @10/@50/@99 here). + // A valid synthetic suffix must sort STRICTLY BEFORE every stored + // non-empty suffix per the comparer. testkeys orders larger numeric + // suffix first, so @100..@200 all qualify. Sample within that range. + randSynth := func() []byte { + n := int64(100) + rng.Int64N(101) // @100..@200 + buf := make([]byte, testkeys.SuffixLen(n)) + testkeys.WriteSuffix(buf, n) + return buf + } + + // Mask construction reuses the existing pattern: pick two suffixes from + // the universe of {stored suffixes, the chosen synth}. Lower must + // compare <= Upper per the comparer. + randMask := func(synth []byte) (blockiter.SuffixMask, bool) { + var suffixes [][]byte + for _, k := range blockKeys { + if s := suffixOf(k); len(s) > 0 { + suffixes = append(suffixes, s) + } + } + if len(synth) > 0 { + suffixes = append(suffixes, synth) + } + if len(suffixes) < 2 { + return blockiter.SuffixMask{}, false + } + a := suffixes[rng.IntN(len(suffixes))] + b := suffixes[rng.IntN(len(suffixes))] + if suffixCmp(a, b) > 0 { + a, b = b, a + } + return blockiter.SuffixMask{Lower: a, Upper: b}, true + } + + randMasks := func(synth []byte) []blockiter.SuffixMask { + n := rng.IntN(3) + 1 // 1..3 masks (at least one to actually filter) + var out []blockiter.SuffixMask + for j := 0; j < n; j++ { + if m, ok := randMask(synth); ok { + out = append(out, m) + } + } + return out + } + + // visibleRows: the oracle. A row is visible iff: + // - not hidden by HideObsoletePoints, AND + // - its EFFECTIVE suffix is either empty or not covered by any mask. + // Effective suffix is the synth iff stored is non-empty; otherwise empty. + visibleRows := func(tr blockiter.Transforms) []int { + synth := tr.SyntheticSuffix() + var out []int + for i, k := range blockKeys { + if tr.HideObsoletePoints && blockObsolete[i] { + continue + } + stored := suffixOf(k) + effective := stored + if tr.HasSyntheticSuffix() && len(stored) > 0 { + effective = synth + } + masked := false + if len(effective) > 0 { + for _, m := range tr.SuffixMasks { + if suffixCmp(effective, m.Lower) >= 0 && suffixCmp(effective, m.Upper) < 0 { + masked = true + break + } + } + } + if !masked { + out = append(out, i) + } + } + return out + } + + const trials = 25 + for trial := 0; trial < trials; trial++ { + synth := randSynth() + tr := blockiter.Transforms{ + HideObsoletePoints: rng.IntN(2) == 0, + SyntheticPrefixAndSuffix: blockiter.MakeSyntheticPrefixAndSuffix(nil, synth), + SuffixMasks: randMasks(synth), + } + t.Run(fmt.Sprintf("trial%d", trial), func(t *testing.T) { + t.Logf("synth=%s hideObsolete=%v numMasks=%d", + synth, tr.HideObsoletePoints, len(tr.SuffixMasks)) + for i, m := range tr.SuffixMasks { + t.Logf(" mask[%d]=[%s,%s)", i, m.Lower, m.Upper) + } + + visible := visibleRows(tr) + t.Logf("visible rows: %d / %d", len(visible), len(blockKeys)) + + newIter := func() *DataBlockIter { + it := &DataBlockIter{} + it.InitOnce(&testKeysSchema, testkeys.Comparer, + getInternalValuer(func([]byte) base.InternalValue { + return base.MakeInPlaceValue(nil) + }), NoTieringColumns()) + if err := it.Init(&r, bd, tr, NoTieringColumns()); err != nil { + t.Fatal(err) + } + return it + } + + t.Run("forward", func(t *testing.T) { + it := newIter() + defer it.Close() + var got []int + for kv := it.First(); kv != nil; kv = it.Next() { + got = append(got, it.row) + } + if !slices.Equal(got, visible) { + t.Fatalf("forward got rows %v, want %v", got, visible) + } + }) + + t.Run("backward", func(t *testing.T) { + it := newIter() + defer it.Close() + var got []int + for kv := it.Last(); kv != nil; kv = it.Prev() { + got = append(got, it.row) + } + want := slices.Clone(visible) + slices.Reverse(want) + if !slices.Equal(got, want) { + t.Fatalf("backward got rows %v, want %v", got, want) + } + }) + }) + } +} diff --git a/sstable/colblk/keyspan.go b/sstable/colblk/keyspan.go index 53d3aa3b982..1ba0ecea2c9 100644 --- a/sstable/colblk/keyspan.go +++ b/sstable/colblk/keyspan.go @@ -325,13 +325,13 @@ func (d *KeyspanDecoder) searchBoundaryKeysWithSyntheticPrefix( // NewKeyspanIter constructs a new iterator over a keyspan columnar block. func NewKeyspanIter( - cmp base.Compare, h block.BufferHandle, transforms blockiter.FragmentTransforms, + comparer *base.Comparer, h block.BufferHandle, transforms blockiter.FragmentTransforms, ) *KeyspanIter { i := keyspanIterPool.Get().(*KeyspanIter) i.closeCheck = invariants.CloseChecker{} i.handle = h d := (*KeyspanDecoder)(unsafe.Pointer(h.BlockMetadata())) - i.init(cmp, d, transforms) + i.init(comparer.Compare, comparer.ComparePointSuffixes, d, transforms) return i } @@ -381,6 +381,7 @@ func (i *KeyspanIter) Close() { type keyspanIter struct { r *KeyspanDecoder cmp base.Compare + suffixCmp base.ComparePointSuffixes transforms blockiter.FragmentTransforms noTransforms bool span keyspan.Span @@ -402,10 +403,20 @@ var _ keyspan.FragmentIterator = (*keyspanIter)(nil) // init initializes the iterator with the given comparison function and keyspan // decoder. func (i *keyspanIter) init( - cmp base.Compare, r *KeyspanDecoder, transforms blockiter.FragmentTransforms, + cmp base.Compare, + suffixCmp base.ComparePointSuffixes, + r *KeyspanDecoder, + transforms blockiter.FragmentTransforms, ) { + if len(transforms.SuffixMasks) > 0 && suffixCmp == nil { + // SuffixMasks require a comparer that knows how to order suffixes; + // configuring one without the other would silently leak entries that + // should be hidden. + panic(errors.AssertionFailedf("keyspanIter: SuffixMasks require non-nil ComparePointSuffixes")) + } i.r = r i.cmp = cmp + i.suffixCmp = suffixCmp i.transforms = transforms i.noTransforms = transforms.NoTransforms() i.span.Start, i.span.End = nil, nil @@ -526,20 +537,26 @@ func (i *keyspanIter) gatherKeysForward(startBoundIndex int) *keyspan.Span { panic(errors.AssertionFailedf("out of bounds: i.startBoundIndex=%d", errors.Safe(startBoundIndex))) } i.startBoundIndex = startBoundIndex - if i.startBoundIndex >= int(i.r.boundaryKeysCount)-1 { - return nil - } - if !i.isNonemptySpan(i.startBoundIndex) { - if i.startBoundIndex == int(i.r.boundaryKeysCount)-2 { - // Corruption error - panic(base.CorruptionErrorf("keyspan block has empty span at end")) + for { + if i.startBoundIndex >= int(i.r.boundaryKeysCount)-1 { + return nil } - i.startBoundIndex++ if !i.isNonemptySpan(i.startBoundIndex) { - panic(base.CorruptionErrorf("keyspan block has consecutive empty spans")) + if i.startBoundIndex == int(i.r.boundaryKeysCount)-2 { + // Corruption error + panic(base.CorruptionErrorf("keyspan block has empty span at end")) + } + i.startBoundIndex++ + if !i.isNonemptySpan(i.startBoundIndex) { + panic(base.CorruptionErrorf("keyspan block has consecutive empty spans")) + } + } + s := i.materializeSpan() + if len(s.Keys) > 0 { + return s } + i.startBoundIndex++ } - return i.materializeSpan() } // gatherKeysBackward returns the first non-empty Span in the backward direction, @@ -547,24 +564,30 @@ func (i *keyspanIter) gatherKeysForward(startBoundIndex int) *keyspan.Span { // [startBoundIndex] as the span's start boundary. func (i *keyspanIter) gatherKeysBackward(startBoundIndex int) *keyspan.Span { i.startBoundIndex = startBoundIndex - if i.startBoundIndex < 0 { - return nil - } - if invariants.Enabled && i.startBoundIndex >= int(i.r.boundaryKeysCount)-1 { - panic(errors.AssertionFailedf("out of bounds: i.startBoundIndex=%d, i.r.boundaryKeysCount=%d", - errors.Safe(i.startBoundIndex), errors.Safe(i.r.boundaryKeysCount))) - } - if !i.isNonemptySpan(i.startBoundIndex) { - if i.startBoundIndex == 0 { - // Corruption error - panic(base.CorruptionErrorf("keyspan block has empty span at beginning")) + for { + if i.startBoundIndex < 0 { + return nil + } + if invariants.Enabled && i.startBoundIndex >= int(i.r.boundaryKeysCount)-1 { + panic(errors.AssertionFailedf("out of bounds: i.startBoundIndex=%d, i.r.boundaryKeysCount=%d", + errors.Safe(i.startBoundIndex), errors.Safe(i.r.boundaryKeysCount))) } - i.startBoundIndex-- if !i.isNonemptySpan(i.startBoundIndex) { - panic(base.CorruptionErrorf("keyspan block has consecutive empty spans")) + if i.startBoundIndex == 0 { + // Corruption error + panic(base.CorruptionErrorf("keyspan block has empty span at beginning")) + } + i.startBoundIndex-- + if !i.isNonemptySpan(i.startBoundIndex) { + panic(base.CorruptionErrorf("keyspan block has consecutive empty spans")) + } + } + s := i.materializeSpan() + if len(s.Keys) > 0 { + return s } + i.startBoundIndex-- } - return i.materializeSpan() } // isNonemptySpan returns true if the span starting at i.startBoundIndex @@ -618,6 +641,42 @@ func (i *keyspanIter) materializeSpan() *keyspan.Span { } } } + if len(i.transforms.SuffixMasks) > 0 { + // `SuffixMasks` originate from a point-key `DeleteSuffixRange` call, + // so the comparison uses `ComparePointSuffixes` (matching the point + // path), not the range-suffix comparator. The synthetic-suffix + // substitution above has already mapped each `k.Suffix` to its + // effective value, so the mask check operates on the effective + // suffix here — `RangeKeySet` entries with empty original suffix + // retain the empty effective suffix and are skipped by the + // `len(k.Suffix) > 0` gate (never masked, per the DSR contract). + // `RangeKeyDelete` entries have no per-key suffix at all and are + // likewise skipped. See the symmetric block in + // `rowblk/rowblk_fragment_iter.go` for the same logic in the + // row-based path; see the TODOs in `rowblk_iter.go` and + // `colblk/data_block.go::isSuffixMasked` for why point keys need a + // different (synth-aware) treatment. + n := 0 + for j := range i.span.Keys { + k := &i.span.Keys[j] + masked := false + if len(k.Suffix) > 0 { + for _, m := range i.transforms.SuffixMasks { + if i.suffixCmp(k.Suffix, m.Lower) >= 0 && + i.suffixCmp(k.Suffix, m.Upper) < 0 { + masked = true + break + } + } + } + if masked { + continue + } + i.span.Keys[n] = i.span.Keys[j] + n++ + } + i.span.Keys = i.span.Keys[:n] + } if i.transforms.HasSyntheticPrefix() || invariants.Sometimes(10) { syntheticPrefix := i.transforms.SyntheticPrefix() i.startKeyBuf = i.startKeyBuf[:len(syntheticPrefix)] diff --git a/sstable/colblk/keyspan_test.go b/sstable/colblk/keyspan_test.go index cff1794d53f..6d308151856 100644 --- a/sstable/colblk/keyspan_test.go +++ b/sstable/colblk/keyspan_test.go @@ -60,11 +60,15 @@ func TestKeyspanBlock(t *testing.T) { td.MaybeScanArgs(t, "synthetic-seq-num", &syntheticSeqNum) td.MaybeScanArgs(t, "synthetic-prefix", &syntheticPrefix) td.MaybeScanArgs(t, "synthetic-suffix", &syntheticSuffix) + masks := parseSuffixMaskArgs(t, td) transforms := blockiter.FragmentTransforms{ SyntheticSeqNum: blockiter.SyntheticSeqNum(syntheticSeqNum), SyntheticPrefixAndSuffix: blockiter.MakeSyntheticPrefixAndSuffix([]byte(syntheticPrefix), []byte(syntheticSuffix)), + SuffixMasks: masks, } - iter.init(base.DefaultComparer.Compare, &kr, transforms) + // testkeys.Comparer provides ComparePointSuffixes; use its + // Compare for consistency with the suffix comparer. + iter.init(testkeys.Comparer.Compare, testkeys.Comparer.ComparePointSuffixes, &kr, transforms) return keyspan.RunFragmentIteratorCmd(&iter, td.Input, nil) default: return fmt.Sprintf("unknown command: %s", td.Cmd) @@ -96,7 +100,7 @@ func TestKeyspanBlockPooling(t *testing.T) { getBlockAndIterate := func() { cv := ch.Get(base.DiskFileNum(1), 0, base.MakeLevel(0), cache.CategorySSTableData) require.NotNil(t, cv) - it := NewKeyspanIter(testkeys.Comparer.Compare, block.CacheBufferHandle(cv), blockiter.NoFragmentTransforms) + it := NewKeyspanIter(testkeys.Comparer, block.CacheBufferHandle(cv), blockiter.NoFragmentTransforms) defer it.Close() s, err := it.First() require.NoError(t, err) @@ -166,7 +170,7 @@ func benchmarkKeyspanBlockRangeDeletions(b *testing.B, numSpans, keysPerSpan, ke kr.Init(w.Finish()) var it KeyspanIter - it.init(base.DefaultComparer.Compare, &kr, blockiter.NoFragmentTransforms) + it.init(base.DefaultComparer.Compare, nil, &kr, blockiter.NoFragmentTransforms) b.Run("SeekGE", func(b *testing.B) { rng := rand.New(rand.NewPCG(0, uint64(time.Now().UnixNano()))) diff --git a/sstable/colblk/suffix_mask_test_helpers_test.go b/sstable/colblk/suffix_mask_test_helpers_test.go new file mode 100644 index 00000000000..772787f40c6 --- /dev/null +++ b/sstable/colblk/suffix_mask_test_helpers_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +package colblk + +import ( + "fmt" + "testing" + + "github.com/cockroachdb/datadriven" + "github.com/cockroachdb/pebble/sstable/blockiter" +) + +// parseSuffixMaskArgs parses suffix-mask-lower/suffix-mask-upper pairs from a +// datadriven test data block. The first pair uses the unsuffixed names; the +// Nth pair (N >= 2) uses names with an integer suffix: +// +// iter suffix-mask-lower=@200 suffix-mask-upper=@5 \ +// suffix-mask-lower2=@2 suffix-mask-upper2=@1 \ +// suffix-mask-lower3=... +// +// Returns the masks in the order they appear (positional, not numeric). +func parseSuffixMaskArgs(t *testing.T, td *datadriven.TestData) []blockiter.SuffixMask { + var masks []blockiter.SuffixMask + for i := 1; ; i++ { + lowerKey, upperKey := "suffix-mask-lower", "suffix-mask-upper" + if i > 1 { + lowerKey = fmt.Sprintf("suffix-mask-lower%d", i) + upperKey = fmt.Sprintf("suffix-mask-upper%d", i) + } + var lower, upper string + td.MaybeScanArgs(t, lowerKey, &lower) + td.MaybeScanArgs(t, upperKey, &upper) + if lower == "" && upper == "" { + break + } + masks = append(masks, blockiter.SuffixMask{Lower: []byte(lower), Upper: []byte(upper)}) + } + return masks +} diff --git a/sstable/colblk/testdata/data_block/suffix_mask b/sstable/colblk/testdata/data_block/suffix_mask new file mode 100644 index 00000000000..cc7ee565133 --- /dev/null +++ b/sstable/colblk/testdata/data_block/suffix_mask @@ -0,0 +1,142 @@ +# Test suffix mask filtering. testkeys.Comparer sorts suffixes in +# descending order (larger suffix = smaller key). +# Mask [Lower=@200, Upper=@5) hides keys with suffix in [@200, @5), +# i.e., keys @200, @10, @9, @6, @5 are in range but @5 is exclusive. +# So @200, @10, @9, @6 are masked. @5, @2, @1 and suffixless keys pass. + +write-block +a@10#1,SET:a10 +a@5#2,SET:a5 +a@2#3,SET:a2 +b@200#4,SET:b200 +b@9#5,SET:b9 +b@1#6,SET:b1 +c#7,SET:c-nosuffix +---- + +# Forward iteration with suffix mask. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@5 +first +next +next +next +next +---- +first: a@5#2,SET:a5 + next: a@2#3,SET:a2 + next: b@1#6,SET:b1 + next: c#7,SET:c-nosuffix + next: . + +# Backward iteration. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@5 +last +prev +prev +prev +prev +---- +last: c#7,SET:c-nosuffix +prev: b@1#6,SET:b1 +prev: a@2#3,SET:a2 +prev: a@5#2,SET:a5 +prev: . + +# SeekGE landing on a masked key skips forward. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@5 +seek-ge a@10 +next +---- +seek-ge a@10: a@5#2,SET:a5 + next: a@2#3,SET:a2 + +# SeekLT landing on a masked key skips backward. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@5 +seek-lt c +prev +---- +seek-lt c: b@1#6,SET:b1 + prev: a@2#3,SET:a2 + +# Most keys masked. @1 survives (exclusive upper) and c (suffixless). +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@1 +first +next +next +---- +first: b@1#6,SET:b1 + next: c#7,SET:c-nosuffix + next: . + +# No keys masked — all visible. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@201 +first +next +next +next +next +next +next +---- +first: a@10#1,SET:a10 + next: a@5#2,SET:a5 + next: a@2#3,SET:a2 + next: b@200#4,SET:b200 + next: b@9#5,SET:b9 + next: b@1#6,SET:b1 + next: c#7,SET:c-nosuffix + +# Two disjoint masks: [@200,@10) hides only @200; [@5,@1) hides @5, @2. +# So visible: @10, @9, @1, c. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@10 suffix-mask-lower2=@5 suffix-mask-upper2=@1 +first +next +next +next +next +---- +first: a@10#1,SET:a10 + next: b@9#5,SET:b9 + next: b@1#6,SET:b1 + next: c#7,SET:c-nosuffix + next: . + +# Three disjoint masks: [@200,@10) hides @200; [@9,@5) hides @9; [@2,@1) hides +# @2. Visible: @10, @5, @1, c. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@10 suffix-mask-lower2=@9 suffix-mask-upper2=@5 suffix-mask-lower3=@2 suffix-mask-upper3=@1 +first +next +next +next +next +---- +first: a@10#1,SET:a10 + next: a@5#2,SET:a5 + next: b@1#6,SET:b1 + next: c#7,SET:c-nosuffix + next: . + +# Two overlapping masks: [@200,@5) and [@10,@1) overlap. Their union +# covers @200, @10, @9, @5, @2. Visible: @1, c. Per-row filtering +# applies each mask independently; either match hides the key. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@5 suffix-mask-lower2=@10 suffix-mask-upper2=@1 +first +next +next +---- +first: b@1#6,SET:b1 + next: c#7,SET:c-nosuffix + next: . + +# A mask that hides almost everything plus a redundant second mask +# contained within it. The second mask is effectively a no-op but +# iteration must still work. @1 survives (exclusive upper of the first +# mask) and c is suffixless. +iter verbose suffix-mask-lower=@200 suffix-mask-upper=@1 suffix-mask-lower2=@10 suffix-mask-upper2=@5 +first +next +next +---- +first: b@1#6,SET:b1 + next: c#7,SET:c-nosuffix + next: . diff --git a/sstable/colblk/testdata/keyspan_block b/sstable/colblk/testdata/keyspan_block index 9bc1ab225c9..97b3ec2a018 100644 --- a/sstable/colblk/testdata/keyspan_block +++ b/sstable/colblk/testdata/keyspan_block @@ -481,3 +481,226 @@ foob-food:{(#4,RANGEKEYSET,@3,coconut)} foob-food:{(#4,RANGEKEYSET,@3,coconut)} foob-food:{(#4,RANGEKEYSET,@3,coconut)} fooe-foog:{(#5,RANGEKEYSET,@1,tree)} + +# Suffix mask filtering on range keys (testkeys.Comparer sorts suffixes +# descending: larger numeric suffix = smaller key, so the mask [@200, @5) +# hides suffixes >= @5 and < @200, i.e. @200, @10, @9). Two spans +# exercise multi-span Next/Prev advancement past fully-masked spans. + +reset +---- +size=37: +0: user keys: bytes: 0 rows set; 0 bytes in data +1: start indices: uint: 0 rows +2: trailers: uint: 0 rows +3: suffixes: bytes: 0 rows set; 0 bytes in data +4: values: bytes: 0 rows set; 0 bytes in data + +add +a-c:{(#10,RANGEKEYSET,@200,a200) (#9,RANGEKEYSET,@10,a10) (#8,RANGEKEYSET,@5,a5) (#7,RANGEKEYSET,@2,a2)} +c-e:{(#6,RANGEKEYSET,@200,c200) (#5,RANGEKEYSET,@9,c9)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +---- +size=127: +0: user keys: bytes: 4 rows set; 4 bytes in data +1: start indices: uint: 4 rows +2: trailers: uint: 8 rows +3: suffixes: bytes: 8 rows set; 19 bytes in data +4: values: bytes: 8 rows set; 19 bytes in data + +finish +---- +Boundaries: a#10,RANGEKEYSET — g#inf,RANGEKEYSET +keyspan-decoder + └── keyspan block header + ├── 000-004: x 04000000 # user key count: 4 + ├── columnar block header + │ ├── 004-005: x 01 # version 1 + │ ├── 005-007: x 0500 # 5 columns + │ ├── 007-011: x 08000000 # 8 rows + │ ├── 011-012: b 00000011 # col 0: bytes + │ ├── 012-016: x 24000000 # col 0: page start 36 + │ ├── 016-017: b 00000010 # col 1: uint + │ ├── 017-021: x 2e000000 # col 1: page start 46 + │ ├── 021-022: b 00000010 # col 2: uint + │ ├── 022-026: x 33000000 # col 2: page start 51 + │ ├── 026-027: b 00000011 # col 3: bytes + │ ├── 027-031: x 44000000 # col 3: page start 68 + │ ├── 031-032: b 00000011 # col 4: bytes + │ └── 032-036: x 61000000 # col 4: page start 97 + ├── data for column 0 (bytes) + │ ├── offsets table + │ │ ├── 036-037: x 01 # encoding: 1b + │ │ ├── 037-038: x 00 # data[0] = 0 [42 overall] + │ │ ├── 038-039: x 01 # data[1] = 1 [43 overall] + │ │ ├── 039-040: x 02 # data[2] = 2 [44 overall] + │ │ ├── 040-041: x 03 # data[3] = 3 [45 overall] + │ │ └── 041-042: x 04 # data[4] = 4 [46 overall] + │ └── data + │ ├── 042-043: x 61 # data[0]: a + │ ├── 043-044: x 63 # data[1]: c + │ ├── 044-045: x 65 # data[2]: e + │ └── 045-046: x 67 # data[3]: g + ├── data for column 1 (uint) + │ ├── 046-047: x 01 # encoding: 1b + │ ├── 047-048: x 00 # data[0] = 0 + │ ├── 048-049: x 04 # data[1] = 4 + │ ├── 049-050: x 06 # data[2] = 6 + │ └── 050-051: x 08 # data[3] = 8 + ├── data for column 2 (uint) + │ ├── 051-052: x 02 # encoding: 2b + │ ├── 052-054: x 150a # data[0] = 2581 + │ ├── 054-056: x 1509 # data[1] = 2325 + │ ├── 056-058: x 1508 # data[2] = 2069 + │ ├── 058-060: x 1507 # data[3] = 1813 + │ ├── 060-062: x 1506 # data[4] = 1557 + │ ├── 062-064: x 1505 # data[5] = 1301 + │ ├── 064-066: x 1304 # data[6] = 1043 + │ └── 066-068: x 1503 # data[7] = 789 + ├── data for column 3 (bytes) + │ ├── offsets table + │ │ ├── 068-069: x 01 # encoding: 1b + │ │ ├── 069-070: x 00 # data[0] = 0 [78 overall] + │ │ ├── 070-071: x 04 # data[1] = 4 [82 overall] + │ │ ├── 071-072: x 07 # data[2] = 7 [85 overall] + │ │ ├── 072-073: x 09 # data[3] = 9 [87 overall] + │ │ ├── 073-074: x 0b # data[4] = 11 [89 overall] + │ │ ├── 074-075: x 0f # data[5] = 15 [93 overall] + │ │ ├── 075-076: x 11 # data[6] = 17 [95 overall] + │ │ ├── 076-077: x 11 # data[7] = 17 [95 overall] + │ │ └── 077-078: x 13 # data[8] = 19 [97 overall] + │ └── data + │ ├── 078-082: x 40323030 # data[0]: @200 + │ ├── 082-085: x 403130 # data[1]: @10 + │ ├── 085-087: x 4035 # data[2]: @5 + │ ├── 087-089: x 4032 # data[3]: @2 + │ ├── 089-093: x 40323030 # data[4]: @200 + │ ├── 093-095: x 4039 # data[5]: @9 + │ ├── 095-095: x # data[6]: + │ └── 095-097: x 4031 # data[7]: @1 + ├── data for column 4 (bytes) + │ ├── offsets table + │ │ ├── 097-098: x 01 # encoding: 1b + │ │ ├── 098-099: x 00 # data[0] = 0 [107 overall] + │ │ ├── 099-100: x 04 # data[1] = 4 [111 overall] + │ │ ├── 100-101: x 07 # data[2] = 7 [114 overall] + │ │ ├── 101-102: x 09 # data[3] = 9 [116 overall] + │ │ ├── 102-103: x 0b # data[4] = 11 [118 overall] + │ │ ├── 103-104: x 0f # data[5] = 15 [122 overall] + │ │ ├── 104-105: x 11 # data[6] = 17 [124 overall] + │ │ ├── 105-106: x 11 # data[7] = 17 [124 overall] + │ │ └── 106-107: x 13 # data[8] = 19 [126 overall] + │ └── data + │ ├── 107-111: x 61323030 # data[0]: a200 + │ ├── 111-114: x 613130 # data[1]: a10 + │ ├── 114-116: x 6135 # data[2]: a5 + │ ├── 116-118: x 6132 # data[3]: a2 + │ ├── 118-122: x 63323030 # data[4]: c200 + │ ├── 122-124: x 6339 # data[5]: c9 + │ ├── 124-124: x # data[6]: + │ └── 124-126: x 6531 # data[7]: e1 + └── 126-127: x 00 # block padding byte + +# Partial mask: keeps unmasked entries in each span. +iter suffix-mask-lower=@200 suffix-mask-upper=@5 +first +next +next +next +---- +a-c:{(#8,RANGEKEYSET,@5,a5) (#7,RANGEKEYSET,@2,a2)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +. +. + +# All entries in c-e are masked, so it is skipped entirely; iteration +# advances forward past it to e-g. RANGEKEYDEL has no suffix and +# survives. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +first +next +next +---- +a-c:{(#7,RANGEKEYSET,@2,a2)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +. + +# Backward iteration with skip of fully-masked middle span. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +last +prev +prev +---- +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +a-c:{(#7,RANGEKEYSET,@2,a2)} +. + +# SeekGE landing inside a fully-masked span advances to the next +# non-empty span. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +seek-ge c +---- +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + +# SeekLT landing inside a fully-masked span retreats to the previous +# non-empty span. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +seek-lt e +---- +a-c:{(#7,RANGEKEYSET,@2,a2)} + +# Mask `[@200, @1)` covers walls @2..@200 but is exclusive of @1; the +# RANGEKEYSET at @1 survives, and RANGEKEYDEL entries have no per-key +# suffix and are never masked. So only the e-g span (which contains both +# of those survivors) is visible. +iter suffix-mask-lower=@200 suffix-mask-upper=@1 +first +last +---- +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + +# Two disjoint masks: [@200,@10) hides @200; [@5,@2) hides @5. +# So visible: @10, @2, RANGEKEYDEL, @1. +iter suffix-mask-lower=@200 suffix-mask-upper=@10 suffix-mask-lower2=@5 suffix-mask-upper2=@2 +first +next +next +next +---- +a-c:{(#9,RANGEKEYSET,@10,a10) (#7,RANGEKEYSET,@2,a2)} +c-e:{(#5,RANGEKEYSET,@9,c9)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +. + +# Three disjoint masks: [@200,@10) hides @200; [@9,@5) hides @9; [@2,@1) +# hides @2. After filtering a-c keeps @10, @5; c-e is fully masked (both +# @200 and @9 hidden) and is skipped; e-g keeps RANGEKEYDEL and @1. +iter suffix-mask-lower=@200 suffix-mask-upper=@10 suffix-mask-lower2=@9 suffix-mask-upper2=@5 suffix-mask-lower3=@2 suffix-mask-upper3=@1 +first +next +next +---- +a-c:{(#9,RANGEKEYSET,@10,a10) (#8,RANGEKEYSET,@5,a5)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +. + +# Two overlapping masks: [@200,@5) and [@10,@1) overlap. Their union +# covers @200, @10, @9, @5, @2 — both suffixed spans (a-c, c-e) are +# fully masked and skipped. Only e-g survives (RANGEKEYDEL and @1). +iter suffix-mask-lower=@200 suffix-mask-upper=@5 suffix-mask-lower2=@10 suffix-mask-upper2=@1 +first +next +---- +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +. + +# A mask that hides almost everything plus a redundant second mask +# contained within it. @1 survives (exclusive upper of the first mask) +# and RANGEKEYDEL has no suffix; both other spans are fully masked. +iter suffix-mask-lower=@200 suffix-mask-upper=@1 suffix-mask-lower2=@10 suffix-mask-upper2=@5 +first +next +---- +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +. diff --git a/sstable/reader.go b/sstable/reader.go index 31ad803f4e1..5ed2a0b8e13 100644 --- a/sstable/reader.go +++ b/sstable/reader.go @@ -314,7 +314,7 @@ func (r *Reader) NewRawRangeDelIter( return nil, err } if r.tableFormat.BlockColumnar() { - iter = colblk.NewKeyspanIter(r.Comparer.Compare, h, transforms) + iter = colblk.NewKeyspanIter(r.Comparer, h, transforms) } else { iter, err = rowblk.NewFragmentIter(r.blockReader.FileNum(), r.Comparer, h, transforms) if err != nil { @@ -354,7 +354,7 @@ func (r *Reader) NewRawRangeKeyIter( return nil, err } if r.tableFormat.BlockColumnar() { - iter = colblk.NewKeyspanIter(r.Comparer.Compare, h, transforms) + iter = colblk.NewKeyspanIter(r.Comparer, h, transforms) } else { iter, err = rowblk.NewFragmentIter(r.blockReader.FileNum(), r.Comparer, h, transforms) if err != nil { diff --git a/sstable/reader_common.go b/sstable/reader_common.go index bea57351f61..327737a58bc 100644 --- a/sstable/reader_common.go +++ b/sstable/reader_common.go @@ -38,6 +38,8 @@ type ( SyntheticPrefix = blockiter.SyntheticPrefix // SyntheticPrefixAndSuffix re-exports block.SyntheticPrefixAndSuffix. SyntheticPrefixAndSuffix = blockiter.SyntheticPrefixAndSuffix + // SuffixMask re-exports blockiter.SuffixMask. + SuffixMask = blockiter.SuffixMask ) // NoTransforms is the default value for IterTransforms. diff --git a/sstable/rowblk/rowblk_fragment_iter.go b/sstable/rowblk/rowblk_fragment_iter.go index cf445bf70b5..5230d3010a3 100644 --- a/sstable/rowblk/rowblk_fragment_iter.go +++ b/sstable/rowblk/rowblk_fragment_iter.go @@ -41,16 +41,18 @@ import ( // byte slices (start, end, suffix, value) as stable for the lifetime of the // iterator. type fragmentIter struct { - suffixCmp base.CompareRangeSuffixes - blockIter Iter - keyBuf [2]keyspan.Key - span keyspan.Span - dir int8 + suffixCmp base.CompareRangeSuffixes + pointSuffixCmp base.ComparePointSuffixes + blockIter Iter + keyBuf [2]keyspan.Key + span keyspan.Span + dir int8 // fileNum is used for logging/debugging. fileNum base.DiskFileNum syntheticPrefixAndSuffix blockiter.SyntheticPrefixAndSuffix + suffixMasks []blockiter.SuffixMask // startKeyBuf is a buffer that is reused to store the start key of the span // when a synthetic prefix is used. startKeyBuf []byte @@ -81,14 +83,23 @@ func NewFragmentIter( blockHandle block.BufferHandle, transforms blockiter.FragmentTransforms, ) (keyspan.FragmentIterator, error) { + if len(transforms.SuffixMasks) > 0 && comparer.ComparePointSuffixes == nil { + // SuffixMasks require a comparer that knows how to order suffixes; + // configuring one without the other would silently leak range-key + // entries that should be hidden. + return nil, errors.AssertionFailedf( + "rowblk fragmentIter: SuffixMasks require non-nil ComparePointSuffixes") + } i := fragmentBlockIterPool.Get().(*fragmentIter) i.suffixCmp = comparer.CompareRangeSuffixes + i.pointSuffixCmp = comparer.ComparePointSuffixes // Use the i.keyBuf array to back the Keys slice to prevent an allocation // when the spans contain few keys. i.span.Keys = i.keyBuf[:0] i.fileNum = fileNum i.syntheticPrefixAndSuffix = transforms.SyntheticPrefixAndSuffix + i.suffixMasks = transforms.SuffixMasks if transforms.HasSyntheticPrefix() { i.endKeyBuf = append(i.endKeyBuf[:0], transforms.SyntheticPrefix()...) } @@ -189,6 +200,41 @@ func (i *fragmentIter) applySpanTransforms() error { } } } + if len(i.suffixMasks) > 0 { + // SuffixMasks use ComparePointSuffixes (not CompareRangeSuffixes) + // because the masks originate from a point-key DeleteSuffixRange + // request and must use the same ordering as the point path. + // + // This path correctly handles SyntheticSuffix already: applySpanTransforms + // above replaces non-empty RangeKeySet suffixes with the synthetic + // suffix before this masking loop runs, so `k.Suffix` is the + // effective suffix here. RangeKeySet entries with empty original + // suffix retain `len(k.Suffix) == 0` and are skipped (never masked, + // per the DSR contract). RangeKeyDelete entries have no per-key + // suffix and are skipped for the same reason. The point-key paths + // in rowblk_iter.go and colblk/data_block.go don't get this for + // free — see the TODOs there. + n := 0 + for j := range i.span.Keys { + k := &i.span.Keys[j] + masked := false + if len(k.Suffix) > 0 { + for _, m := range i.suffixMasks { + if i.pointSuffixCmp(k.Suffix, m.Lower) >= 0 && + i.pointSuffixCmp(k.Suffix, m.Upper) < 0 { + masked = true + break + } + } + } + if masked { + continue + } + i.span.Keys[n] = i.span.Keys[j] + n++ + } + i.span.Keys = i.span.Keys[:n] + } return nil } @@ -201,39 +247,45 @@ func (i *fragmentIter) applySpanTransforms() error { // gatherForward iterates forward, re-combining the fragmented internal keys to // reconstruct a keyspan.Span that holds all the keys defined over the span. func (i *fragmentIter) gatherForward(kv *base.InternalKV) (*keyspan.Span, error) { - i.span = keyspan.Span{} - if kv == nil || !i.blockIter.Valid() { - return nil, nil - } - // Use the i.keyBuf array to back the Keys slice to prevent an allocation - // when a span contains few keys. - i.span.Keys = i.keyBuf[:0] + for { + i.span = keyspan.Span{} + if kv == nil || !i.blockIter.Valid() { + return nil, nil + } + // Use the i.keyBuf array to back the Keys slice to prevent an allocation + // when a span contains few keys. + i.span.Keys = i.keyBuf[:0] - // Decode the span's end key and individual keys from the value. - if err := i.initSpan(kv.K, kv.InPlaceValue()); err != nil { - return nil, err - } + // Decode the span's end key and individual keys from the value. + if err := i.initSpan(kv.K, kv.InPlaceValue()); err != nil { + return nil, err + } - // There might exist additional internal keys with identical bounds encoded - // within the block. Iterate forward, accumulating all the keys with - // identical bounds to s. + // There might exist additional internal keys with identical bounds encoded + // within the block. Iterate forward, accumulating all the keys with + // identical bounds to s. - // Overlapping fragments are required to have exactly equal start and - // end bounds. - for kv = i.blockIter.Next(); kv != nil && i.blockIter.cmp(kv.K.UserKey, i.span.Start) == 0; kv = i.blockIter.Next() { - if err := i.addToSpan(i.blockIter.cmp, kv.K, kv.InPlaceValue()); err != nil { + // Overlapping fragments are required to have exactly equal start and + // end bounds. + for kv = i.blockIter.Next(); kv != nil && i.blockIter.cmp(kv.K.UserKey, i.span.Start) == 0; kv = i.blockIter.Next() { + if err := i.addToSpan(i.blockIter.cmp, kv.K, kv.InPlaceValue()); err != nil { + return nil, err + } + } + if err := i.applySpanTransforms(); err != nil { return nil, err } - } - if err := i.applySpanTransforms(); err != nil { - return nil, err - } - // Apply a consistent ordering. - keyspan.SortKeysByTrailer(i.span.Keys) + // Apply a consistent ordering. + keyspan.SortKeysByTrailer(i.span.Keys) - // i.blockIter is positioned over the first internal key for the next span. - return &i.span, nil + if len(i.span.Keys) > 0 { + // i.blockIter is positioned over the first internal key for the next span. + return &i.span, nil + } + // SuffixMask filtering removed all keys; advance to the next span. + // kv is already positioned at the start of the next span from the loop above. + } } // gatherBackward gathers internal keys with identical bounds. Keys defined over @@ -245,37 +297,43 @@ func (i *fragmentIter) gatherForward(kv *base.InternalKV) (*keyspan.Span, error) // gatherBackward iterates backwards, re-combining the fragmented internal keys // to reconstruct a keyspan.Span that holds all the keys defined over the span. func (i *fragmentIter) gatherBackward(kv *base.InternalKV) (*keyspan.Span, error) { - i.span = keyspan.Span{} - if kv == nil || !i.blockIter.Valid() { - return nil, nil - } - - // Decode the span's end key and individual keys from the value. - if err := i.initSpan(kv.K, kv.InPlaceValue()); err != nil { - return nil, err - } + for { + i.span = keyspan.Span{} + if kv == nil || !i.blockIter.Valid() { + return nil, nil + } - // There might exist additional internal keys with identical bounds encoded - // within the block. Iterate backward, accumulating all the keys with - // identical bounds to s. - // - // Overlapping fragments are required to have exactly equal start and - // end bounds. - for kv = i.blockIter.Prev(); kv != nil && i.blockIter.cmp(kv.K.UserKey, i.span.Start) == 0; kv = i.blockIter.Prev() { - if err := i.addToSpan(i.blockIter.cmp, kv.K, kv.InPlaceValue()); err != nil { + // Decode the span's end key and individual keys from the value. + if err := i.initSpan(kv.K, kv.InPlaceValue()); err != nil { return nil, err } - } - // i.blockIter is positioned over the last internal key for the previous - // span. - // Apply a consistent ordering. - keyspan.SortKeysByTrailer(i.span.Keys) + // There might exist additional internal keys with identical bounds encoded + // within the block. Iterate backward, accumulating all the keys with + // identical bounds to s. + // + // Overlapping fragments are required to have exactly equal start and + // end bounds. + for kv = i.blockIter.Prev(); kv != nil && i.blockIter.cmp(kv.K.UserKey, i.span.Start) == 0; kv = i.blockIter.Prev() { + if err := i.addToSpan(i.blockIter.cmp, kv.K, kv.InPlaceValue()); err != nil { + return nil, err + } + } + // i.blockIter is positioned over the last internal key for the previous + // span. - if err := i.applySpanTransforms(); err != nil { - return nil, err + // Apply a consistent ordering. + keyspan.SortKeysByTrailer(i.span.Keys) + + if err := i.applySpanTransforms(); err != nil { + return nil, err + } + if len(i.span.Keys) > 0 { + return &i.span, nil + } + // SuffixMask filtering removed all keys; step backward. + // kv is already positioned from the Prev loop above. } - return &i.span, nil } // SetContext is part of the FragmentIterator interface. @@ -296,6 +354,9 @@ func (i *fragmentIter) Close() { i.dir = 0 i.fileNum = 0 i.syntheticPrefixAndSuffix = blockiter.SyntheticPrefixAndSuffix{} + i.suffixMasks = nil + i.suffixCmp = nil + i.pointSuffixCmp = nil i.startKeyBuf = i.startKeyBuf[:0] i.endKeyBuf = i.endKeyBuf[:0] fragmentBlockIterPool.Put(i) diff --git a/sstable/rowblk/rowblk_fragment_iter_test.go b/sstable/rowblk/rowblk_fragment_iter_test.go index fa2563b870b..e24654aeb3e 100644 --- a/sstable/rowblk/rowblk_fragment_iter_test.go +++ b/sstable/rowblk/rowblk_fragment_iter_test.go @@ -88,6 +88,7 @@ func TestBlockFragmentIterator(t *testing.T) { d.MaybeScanArgs(t, "synthetic-prefix", &syntheticPrefix) d.MaybeScanArgs(t, "synthetic-suffix", &syntheticSuffix) transforms.SyntheticPrefixAndSuffix = blockiter.MakeSyntheticPrefixAndSuffix([]byte(syntheticPrefix), []byte(syntheticSuffix)) + transforms.SuffixMasks = parseSuffixMaskArgs(t, d) if d.HasArg("invariants-only") && !invariants.Enabled { // Skip testcase. return d.Expected diff --git a/sstable/rowblk/rowblk_index_iter.go b/sstable/rowblk/rowblk_index_iter.go index 065b2c79042..61a61bf7fba 100644 --- a/sstable/rowblk/rowblk_index_iter.go +++ b/sstable/rowblk/rowblk_index_iter.go @@ -21,6 +21,12 @@ var _ blockiter.Index = (*IndexIter)(nil) // Init initializes an iterator from the provided block data slice. func (i *IndexIter) Init(c *base.Comparer, blk []byte, transforms blockiter.Transforms) error { + // SuffixMasks must not be applied to index separator keys: doing so would + // skip a data block whenever the block's separator key happens to fall in + // the masked range, dropping every key in that block (including keys whose + // own suffix is outside the mask range). Strip them before initializing the + // underlying block iterator. + transforms.SuffixMasks = nil return i.iter.Init(c.Compare, c.ComparePointSuffixes, c.Split, blk, transforms) } @@ -28,6 +34,9 @@ func (i *IndexIter) Init(c *base.Comparer, blk []byte, transforms blockiter.Tran func (i *IndexIter) InitHandle( comparer *base.Comparer, block block.BufferHandle, transforms blockiter.Transforms, ) error { + // See the comment on Init: SuffixMasks must not be applied to index + // separator keys. + transforms.SuffixMasks = nil return i.iter.InitHandle(comparer, block, transforms) } diff --git a/sstable/rowblk/rowblk_index_iter_test.go b/sstable/rowblk/rowblk_index_iter_test.go new file mode 100644 index 00000000000..4e6bed8f5e9 --- /dev/null +++ b/sstable/rowblk/rowblk_index_iter_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +package rowblk + +import ( + "testing" + + "github.com/cockroachdb/pebble/internal/base" + "github.com/cockroachdb/pebble/internal/testkeys" + "github.com/cockroachdb/pebble/sstable/block" + "github.com/cockroachdb/pebble/sstable/blockiter" + "github.com/stretchr/testify/require" +) + +// TestIndexIterIgnoresSuffixMask verifies that an IndexIter does not apply +// SuffixMasks to its separator keys. The mask is intended to filter point +// keys at the data-block level; if it were applied to index separator keys, +// the iterator would skip the entire data block whose separator's suffix +// falls in the masked range — including data-block keys whose own suffixes +// are outside the mask range. +func TestIndexIterIgnoresSuffixMask(t *testing.T) { + // Build an index block whose separator keys include suffixes inside and + // outside the mask range. Under testkeys.Compare, "@N" sorts newest-first + // (smaller N = larger key), so the separators below are listed in + // ascending sort order. + separators := []string{ + "kprjuqa@37", + "kprjuqa@30", + "kprjuqa@23", + "kprjuqa@10", // suffix falls inside the mask [@14,@8). + } + + w := &Writer{RestartInterval: 1} + for i, sep := range separators { + // The value is an encoded block handle; the contents don't matter for + // this test, but the handle must be decodable. + bh := block.HandleWithProperties{ + Handle: block.Handle{Offset: uint64(i * 100), Length: 50}, + } + buf := make([]byte, 32) + val := buf[:bh.Handle.EncodeVarints(buf)] + require.NoError(t, w.Add(base.MakeInternalKey([]byte(sep), 1, base.InternalKeyKindSeparator), val)) + } + blockData := w.Finish() + + mask := blockiter.SuffixMask{Lower: []byte("@14"), Upper: []byte("@8")} + transforms := blockiter.Transforms{ + SuffixMasks: []blockiter.SuffixMask{mask}, + } + + var iter IndexIter + require.NoError(t, iter.Init(testkeys.Comparer, blockData, transforms)) + defer func() { _ = iter.Close() }() + + // Iterate forward; every separator must appear, even kprjuqa@10 whose + // suffix is inside the mask range. + var got []string + for ok := iter.First(); ok; ok = iter.Next() { + got = append(got, string(iter.Separator())) + } + require.Equal(t, separators, got) + + // Iterate backward; same expectation in reverse. + got = got[:0] + for ok := iter.Last(); ok; ok = iter.Prev() { + got = append(got, string(iter.Separator())) + } + expectedRev := make([]string, len(separators)) + for i, s := range separators { + expectedRev[len(separators)-1-i] = s + } + require.Equal(t, expectedRev, got) +} diff --git a/sstable/rowblk/rowblk_iter.go b/sstable/rowblk/rowblk_iter.go index 55754a3d18c..c403f54f05f 100644 --- a/sstable/rowblk/rowblk_iter.go +++ b/sstable/rowblk/rowblk_iter.go @@ -75,8 +75,9 @@ import ( // // We have picked the first option here. type Iter struct { - cmp base.Compare - split base.Split + cmp base.Compare + split base.Split + suffixCmp base.ComparePointSuffixes // Iterator transforms. // @@ -249,10 +250,18 @@ func (i *Iter) Init( if numRestarts == 0 { return base.CorruptionErrorf("pebble/table: invalid table (block has no restart points)") } + if len(transforms.SuffixMasks) > 0 && suffixCmp == nil { + // SuffixMasks require a comparer that knows how to order suffixes; + // configuring one without the other would silently leak keys that + // should be masked. + return errors.AssertionFailedf( + "rowblk: SuffixMasks require non-nil ComparePointSuffixes") + } i.transforms = transforms i.synthSuffixBuf = i.synthSuffixBuf[:0] i.split = split i.cmp = cmp + i.suffixCmp = suffixCmp i.restarts = offsetInBlock(len(blk)) - 4*(1+offsetInBlock(numRestarts)) i.numRestarts = numRestarts i.ptr = unsafe.Pointer(&blk[0]) @@ -496,6 +505,36 @@ func (i *Iter) decodeInternalKey(key []byte) (hiddenPoint bool) { if n := i.transforms.SyntheticSeqNum; n != 0 { i.ikv.K.SetSeqNum(base.SeqNum(n)) } + if !hiddenPoint && len(i.transforms.SuffixMasks) > 0 { + si := i.split(i.ikv.K.UserKey) + suffix := i.ikv.K.UserKey[si:] + // The mask must be evaluated against the EFFECTIVE suffix, not the + // stored bytes. Under SyntheticSuffix the effective suffix of any + // non-empty stored suffix is the synthetic one (the substitution + // happens just below via maybeReplaceSuffix); empty stored suffixes + // retain the empty effective suffix and are never masked. + // + // TODO(dt): A synth-suffix file's mask answer is uniform across all + // rows with non-empty stored suffix. DSR installs per-row masks on + // such files only in one case: synth-in-mask AND HasRangeKeys — + // because excising would drop RangeKeyDelete entries (suffixless + // per the DSR contract) and any empty-suffix RangeKeySet entries + // (effective suffix retains empty, also never masked). See the + // synth-suffix branch in `suffix_mask.go::DeleteSuffixRange`. An + // iter-init-time cache of synth-in-mask could collapse the per-row + // check to "is the stored suffix non-empty". + if len(suffix) > 0 && i.transforms.HasSyntheticSuffix() { + suffix = i.transforms.SyntheticSuffix() + } + if len(suffix) > 0 { + for _, m := range i.transforms.SuffixMasks { + if i.suffixCmp(suffix, m.Lower) >= 0 && i.suffixCmp(suffix, m.Upper) < 0 { + hiddenPoint = true + break + } + } + } + } } else { i.ikv.K.Trailer = base.InternalKeyTrailer(base.InternalKeyKindInvalid) i.ikv.K.UserKey = nil @@ -896,8 +935,11 @@ func (i *Iter) SeekLT(key []byte, flags base.SeekLTFlags) *base.InternalKV { // suffix replacement, the SeekLT would incorrectly return nil. With // suffix replacement though, a@4 should be returned as a@4 sorts before // a@3. - ikv := i.First() - if i.cmp(ikv.K.UserKey, key) < 0 { + // + // First() may return nil if every row in the block is hidden by + // HideObsoletePoints or a SuffixMask; in that case there is no + // visible key in this block less than the search key. + if ikv := i.First(); ikv != nil && i.cmp(ikv.K.UserKey, key) < 0 { return ikv } } @@ -1175,6 +1217,23 @@ start: if n := i.transforms.SyntheticSeqNum; n != 0 { i.ikv.K.SetSeqNum(base.SeqNum(n)) } + if !hiddenPoint && len(i.transforms.SuffixMasks) > 0 { + si := i.split(i.ikv.K.UserKey) + suffix := i.ikv.K.UserKey[si:] + // See the corresponding block in the initial decode path above + // for the rationale on substituting the synthetic suffix here. + if len(suffix) > 0 && i.transforms.HasSyntheticSuffix() { + suffix = i.transforms.SyntheticSuffix() + } + if len(suffix) > 0 { + for _, m := range i.transforms.SuffixMasks { + if i.suffixCmp(suffix, m.Lower) >= 0 && i.suffixCmp(suffix, m.Upper) < 0 { + hiddenPoint = true + break + } + } + } + } if hiddenPoint { goto start } @@ -1457,6 +1516,23 @@ func (i *Iter) nextPrefixV3(succKey []byte) *base.InternalKV { if n := i.transforms.SyntheticSeqNum; n != 0 { i.ikv.K.SetSeqNum(base.SeqNum(n)) } + if !hiddenPoint && len(i.transforms.SuffixMasks) > 0 { + si := i.split(i.ikv.K.UserKey) + suffix := i.ikv.K.UserKey[si:] + // See the corresponding block in the initial decode path above + // for the rationale on substituting the synthetic suffix here. + if len(suffix) > 0 && i.transforms.HasSyntheticSuffix() { + suffix = i.transforms.SyntheticSuffix() + } + if len(suffix) > 0 { + for _, m := range i.transforms.SuffixMasks { + if i.suffixCmp(suffix, m.Lower) >= 0 && i.suffixCmp(suffix, m.Upper) < 0 { + hiddenPoint = true + break + } + } + } + } if i.transforms.HasSyntheticSuffix() { // Inlined version of i.maybeReplaceSuffix() prefixLen := i.split(i.ikv.K.UserKey) @@ -1515,6 +1591,27 @@ start: trailer := base.InternalKeyTrailer(binary.LittleEndian.Uint64(i.key[n:])) hiddenPoint := i.transforms.HideObsoletePoints && (trailer&TrailerObsoleteBit != 0) + if !hiddenPoint { + userKey := i.key[:n:n] + if len(i.transforms.SuffixMasks) > 0 { + si := i.split(userKey) + suffix := userKey[si:] + // See the corresponding block in the initial decode path + // above for the rationale on substituting the synthetic + // suffix here. + if len(suffix) > 0 && i.transforms.HasSyntheticSuffix() { + suffix = i.transforms.SyntheticSuffix() + } + if len(suffix) > 0 { + for _, m := range i.transforms.SuffixMasks { + if i.suffixCmp(suffix, m.Lower) >= 0 && i.suffixCmp(suffix, m.Upper) < 0 { + hiddenPoint = true + break + } + } + } + } + } if hiddenPoint { continue } diff --git a/sstable/rowblk/rowblk_iter_test.go b/sstable/rowblk/rowblk_iter_test.go index 846ddb13ec6..46414897bbf 100644 --- a/sstable/rowblk/rowblk_iter_test.go +++ b/sstable/rowblk/rowblk_iter_test.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "fmt" + "slices" "strings" "testing" "unsafe" @@ -482,3 +483,252 @@ func TestBlockSyntheticSuffix(t *testing.T) { func ikey(s string) base.InternalKey { return base.InternalKey{UserKey: []byte(s)} } + +func TestBlockIterSuffixMask(t *testing.T) { + // Build a block with testkeys-format keys (prefix@suffix). + w := &Writer{RestartInterval: 1} + keys := []string{"a@10", "a@5", "a@2", "b@200", "b@9", "b@1", "c"} + for i, k := range keys { + ik := base.MakeInternalKey([]byte(k), base.SeqNum(100-i), base.InternalKeyKindSet) + require.NoError(t, w.Add(ik, []byte("val-"+k))) + } + blk := w.Finish() + + cmp := testkeys.Comparer + // Mask [@200, @5): hides @200, @10, @9. Keeps @5, @2, @1, suffixless. + transforms := blockiter.Transforms{ + SuffixMasks: []blockiter.SuffixMask{{Lower: []byte("@200"), Upper: []byte("@5")}}, + } + iter, err := NewIter(cmp.Compare, cmp.ComparePointSuffixes, cmp.Split, blk, transforms) + require.NoError(t, err) + + // Forward. + var forward []string + for kv := iter.First(); kv != nil; kv = iter.Next() { + v, _, _ := kv.V.Value(nil) + forward = append(forward, string(v)) + } + require.Equal(t, []string{"val-a@5", "val-a@2", "val-b@1", "val-c"}, forward) + + // Backward. + var backward []string + for kv := iter.Last(); kv != nil; kv = iter.Prev() { + v, _, _ := kv.V.Value(nil) + backward = append(backward, string(v)) + } + slices.Reverse(backward) + require.Equal(t, []string{"val-a@5", "val-a@2", "val-b@1", "val-c"}, backward) + + // SeekGE landing on masked key. + kv := iter.SeekGE([]byte("a@10"), base.SeekGEFlagsNone) + require.True(t, kv != nil) + v, _, _ := kv.V.Value(nil) + require.Equal(t, "val-a@5", string(v)) + + // SeekLT landing on masked key. + kv = iter.SeekLT([]byte("c"), base.SeekLTFlagsNone) + require.True(t, kv != nil) + v, _, _ = kv.V.Value(nil) + require.Equal(t, "val-b@1", string(v)) + + require.NoError(t, iter.Close()) +} + +// TestBlockIterSuffixMaskOracle exercises every Iter positioning method under +// random combinations of SuffixMask and HideObsoletePoints, comparing against +// an oracle that filters the raw key list in user space. Each positioning +// method's correctness must match the oracle row-for-row; the test would +// catch any positioning method that forgets to apply the mask check, any +// boundary-semantics divergence, and any obsolete×mask interaction bug. +func TestBlockIterSuffixMaskOracle(t *testing.T) { + seed := uint64(123456789) + t.Logf("seed: %d", seed) + rng := randNewPCG(seed) + + // Generate a deterministic set of testkeys-format keys with a small + // prefix space and a moderate suffix space (so the mask will actually + // filter something). ~10% are suffixless. + prefixes := []string{"aa", "ab", "ba", "bc", "ca", "cb"} + suffixes := []int{0 /* suffixless */, 1, 2, 5, 9, 10, 20, 100, 200} + type kv struct { + key string + obsolete bool + } + seen := map[string]bool{} + var rows []kv + for _, p := range prefixes { + for _, s := range suffixes { + k := p + if s > 0 { + k = fmt.Sprintf("%s@%d", p, s) + } + if seen[k] { + continue + } + seen[k] = true + rows = append(rows, kv{key: k, obsolete: rng()&3 == 0}) + } + } + // Sort by testkeys.Comparer. + cmp := testkeys.Comparer + slices.SortFunc(rows, func(a, b kv) int { return cmp.Compare([]byte(a.key), []byte(b.key)) }) + + // Build the block. Use AddWithOptionalValuePrefix so we can set the + // obsolete bit. + w := &Writer{RestartInterval: 1} + for i, r := range rows { + ik := base.MakeInternalKey([]byte(r.key), base.SeqNum(len(rows)-i), base.InternalKeyKindSet) + require.NoError(t, w.AddWithOptionalValuePrefix( + ik, r.obsolete, []byte("v"), len(r.key), false, 0, false)) + } + blk := w.Finish() + + // allSuffixes collects every distinct non-empty suffix in the block for + // use as candidate mask bounds. + var allSuffixes [][]byte + suffixSeen := map[string]bool{} + for _, r := range rows { + k := []byte(r.key) + s := k[cmp.Split(k):] + if len(s) > 0 && !suffixSeen[string(s)] { + suffixSeen[string(s)] = true + allSuffixes = append(allSuffixes, s) + } + } + + // visibleKeys returns the keys visible under the given transforms. A key + // is hidden if ANY mask matches its suffix. + visibleKeys := func(tr blockiter.Transforms) []string { + var out []string + for _, r := range rows { + if tr.HideObsoletePoints && r.obsolete { + continue + } + masked := false + if len(tr.SuffixMasks) > 0 { + k := []byte(r.key) + s := k[cmp.Split(k):] + if len(s) > 0 { + for _, m := range tr.SuffixMasks { + if cmp.ComparePointSuffixes(s, m.Lower) >= 0 && + cmp.ComparePointSuffixes(s, m.Upper) < 0 { + masked = true + break + } + } + } + } + if masked { + continue + } + out = append(out, r.key) + } + return out + } + + // randMask returns one random mask drawn from existing suffixes. + randMask := func() (blockiter.SuffixMask, bool) { + if len(allSuffixes) < 2 { + return blockiter.SuffixMask{}, false + } + a := allSuffixes[rng()%uint32(len(allSuffixes))] + b := allSuffixes[rng()%uint32(len(allSuffixes))] + if cmp.ComparePointSuffixes(a, b) > 0 { + a, b = b, a + } + return blockiter.SuffixMask{Lower: a, Upper: b}, true + } + + const trials = 25 + for trial := 0; trial < trials; trial++ { + // 0..3 masks per trial to exercise multi-mask paths. + nMasks := int(rng() & 3) + var masks []blockiter.SuffixMask + for j := 0; j < nMasks; j++ { + if m, ok := randMask(); ok { + masks = append(masks, m) + } + } + tr := blockiter.Transforms{ + HideObsoletePoints: rng()&1 == 0, + SuffixMasks: masks, + } + want := visibleKeys(tr) + t.Run(fmt.Sprintf("trial%d", trial), func(t *testing.T) { + t.Logf("hideObsolete=%v numMasks=%d visible=%d/%d", + tr.HideObsoletePoints, len(masks), len(want), len(rows)) + for i, m := range masks { + t.Logf(" mask[%d]=[%s,%s)", i, m.Lower, m.Upper) + } + + newIter := func(t *testing.T) *Iter { + it, err := NewIter(cmp.Compare, cmp.ComparePointSuffixes, cmp.Split, blk, tr) + require.NoError(t, err) + return it + } + keysOf := func(kv *base.InternalKV, next func() *base.InternalKV) []string { + var out []string + for ; kv != nil; kv = next() { + out = append(out, string(kv.K.UserKey)) + } + return out + } + + t.Run("forward", func(t *testing.T) { + it := newIter(t) + defer it.Close() + got := keysOf(it.First(), it.Next) + require.Equal(t, want, got) + }) + t.Run("backward", func(t *testing.T) { + it := newIter(t) + defer it.Close() + got := keysOf(it.Last(), it.Prev) + wantRev := slices.Clone(want) + slices.Reverse(wantRev) + require.Equal(t, wantRev, got) + }) + t.Run("seek-ge", func(t *testing.T) { + it := newIter(t) + defer it.Close() + for _, r := range rows { + seekKey := []byte(r.key) + var w []string + for _, k := range want { + if cmp.Compare([]byte(k), seekKey) >= 0 { + w = append(w, k) + } + } + got := keysOf(it.SeekGE(seekKey, base.SeekGEFlagsNone), it.Next) + require.Equal(t, w, got, "SeekGE(%q)", seekKey) + } + }) + t.Run("seek-lt", func(t *testing.T) { + it := newIter(t) + defer it.Close() + for _, r := range rows { + seekKey := []byte(r.key) + var w []string + for _, k := range want { + if cmp.Compare([]byte(k), seekKey) < 0 { + w = append([]string{k}, w...) + } + } + got := keysOf(it.SeekLT(seekKey, base.SeekLTFlagsNone), it.Prev) + require.Equal(t, w, got, "SeekLT(%q)", seekKey) + } + }) + }) + } +} + +// randNewPCG returns a closure-based PCG-style RNG. We avoid the +// math/rand/v2 import to keep this test file's import surface minimal — +// the only requirement here is determinism across runs. +func randNewPCG(seed uint64) func() uint32 { + state := seed | 1 + return func() uint32 { + state = state*6364136223846793005 + 1442695040888963407 + return uint32(state >> 33) + } +} diff --git a/sstable/rowblk/suffix_mask_test_helpers_test.go b/sstable/rowblk/suffix_mask_test_helpers_test.go new file mode 100644 index 00000000000..92321494afd --- /dev/null +++ b/sstable/rowblk/suffix_mask_test_helpers_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +package rowblk + +import ( + "fmt" + "testing" + + "github.com/cockroachdb/datadriven" + "github.com/cockroachdb/pebble/sstable/blockiter" +) + +// parseSuffixMaskArgs parses suffix-mask-lower/suffix-mask-upper pairs from a +// datadriven test data block. The first pair uses the unsuffixed names; the +// Nth pair (N >= 2) uses names with an integer suffix: +// +// iter suffix-mask-lower=@200 suffix-mask-upper=@5 \ +// suffix-mask-lower2=@2 suffix-mask-upper2=@1 \ +// suffix-mask-lower3=... +// +// Returns the masks in the order they appear (positional, not numeric). +func parseSuffixMaskArgs(t *testing.T, td *datadriven.TestData) []blockiter.SuffixMask { + var masks []blockiter.SuffixMask + for i := 1; ; i++ { + lowerKey, upperKey := "suffix-mask-lower", "suffix-mask-upper" + if i > 1 { + lowerKey = fmt.Sprintf("suffix-mask-lower%d", i) + upperKey = fmt.Sprintf("suffix-mask-upper%d", i) + } + var lower, upper string + td.MaybeScanArgs(t, lowerKey, &lower) + td.MaybeScanArgs(t, upperKey, &upper) + if lower == "" && upper == "" { + break + } + masks = append(masks, blockiter.SuffixMask{Lower: []byte(lower), Upper: []byte(upper)}) + } + return masks +} diff --git a/sstable/rowblk/testdata/rowblk_fragment_iter b/sstable/rowblk/testdata/rowblk_fragment_iter index 3732a415362..4e203a16d6a 100644 --- a/sstable/rowblk/testdata/rowblk_fragment_iter +++ b/sstable/rowblk/testdata/rowblk_fragment_iter @@ -153,3 +153,124 @@ iter synthetic-suffix=@4 invariants-only last ---- panic: synthetic suffix not supported with key kind RANGEKEYUNSET + +# Suffix mask filtering on range key entries. +# testkeys.Comparer sorts suffixes descending (larger = smaller key). +# Mask [@200, @5) hides entries @200, @10, @9. Keeps @5, @2. + +build +a-c:{(#10,RANGEKEYSET,@200,val200) (#9,RANGEKEYSET,@10,val10) (#8,RANGEKEYSET,@5,val5) (#7,RANGEKEYSET,@2,val2)} +---- +a-c:{(#10,RANGEKEYSET,@200,val200) (#9,RANGEKEYSET,@10,val10) (#8,RANGEKEYSET,@5,val5) (#7,RANGEKEYSET,@2,val2)} + +iter suffix-mask-lower=@200 suffix-mask-upper=@5 +first +next +---- + first: a-c:{(#8,RANGEKEYSET,@5,val5) (#7,RANGEKEYSET,@2,val2)} + next: + +# All entries masked — span should be skipped. +iter suffix-mask-lower=@200 suffix-mask-upper=@1 +first +---- + first: + +# Backward with mask. +iter suffix-mask-lower=@200 suffix-mask-upper=@5 +last +prev +---- + last: a-c:{(#8,RANGEKEYSET,@5,val5) (#7,RANGEKEYSET,@2,val2)} + prev: + +# Multi-span: mask fully hides the middle span, and includes a RANGEKEYDEL +# (suffixless) which must survive. Exercises Next/Prev/SeekGE/SeekLT +# advancing past a fully-masked span. +build +a-c:{(#10,RANGEKEYSET,@200,a200) (#7,RANGEKEYSET,@2,a2)} +c-e:{(#6,RANGEKEYSET,@10,c10) (#5,RANGEKEYSET,@9,c9)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} +---- +a-c:{(#10,RANGEKEYSET,@200,a200) (#7,RANGEKEYSET,@2,a2)} +c-e:{(#6,RANGEKEYSET,@10,c10) (#5,RANGEKEYSET,@9,c9)} +e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + +# Forward: c-e is fully masked and skipped. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +first +next +next +---- + first: a-c:{(#7,RANGEKEYSET,@2,a2)} + next: e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + next: + +# Backward symmetry. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +last +prev +prev +---- + last: e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + prev: a-c:{(#7,RANGEKEYSET,@2,a2)} + prev: + +# SeekGE landing inside the masked span advances forward. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +seek-ge c +---- + seek-ge: e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + +# SeekLT landing inside the masked span retreats backward. +iter suffix-mask-lower=@200 suffix-mask-upper=@2 +seek-lt e +---- + seek-lt: a-c:{(#7,RANGEKEYSET,@2,a2)} + +# Two disjoint masks: [@200,@10) hides @200; [@9,@2) hides @9. +# So visible: @2 in a-c; @10 in c-e (since @9 masked and @10 not); +# RANGEKEYDEL and @1 in e-g. +iter suffix-mask-lower=@200 suffix-mask-upper=@10 suffix-mask-lower2=@9 suffix-mask-upper2=@2 +first +next +next +next +---- + first: a-c:{(#7,RANGEKEYSET,@2,a2)} + next: c-e:{(#6,RANGEKEYSET,@10,c10)} + next: e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + next: + +# Three disjoint masks: [@200,@10) hides @200; [@10,@9) hides @10; +# [@9,@2) hides @9. (Three sites express what could collapse to one, +# exercising the per-row loop with three distinct entries.) Visible: +# @2 in a-c; c-e is fully masked and skipped; RANGEKEYDEL and @1. +iter suffix-mask-lower=@200 suffix-mask-upper=@10 suffix-mask-lower2=@10 suffix-mask-upper2=@9 suffix-mask-lower3=@9 suffix-mask-upper3=@2 +first +next +next +---- + first: a-c:{(#7,RANGEKEYSET,@2,a2)} + next: e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + next: + +# Two overlapping masks: [@200,@5) and [@10,@1) overlap. Their union +# covers @200, @10, @9, @5, @2. a-c is fully masked (@200, @2 both +# hit); c-e is fully masked (@10, @9); e-g (RANGEKEYDEL, @1) survives. +iter suffix-mask-lower=@200 suffix-mask-upper=@5 suffix-mask-lower2=@10 suffix-mask-upper2=@1 +first +next +---- + first: e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + next: + +# A mask that hides almost everything plus a redundant second mask +# contained within it. @1 (exclusive upper of the first mask) and the +# suffixless RANGEKEYDEL survive. +iter suffix-mask-lower=@200 suffix-mask-upper=@1 suffix-mask-lower2=@10 suffix-mask-upper2=@5 +first +next +---- + first: e-g:{(#4,RANGEKEYDEL) (#3,RANGEKEYSET,@1,e1)} + next: diff --git a/suffix_mask.go b/suffix_mask.go new file mode 100644 index 00000000000..ac2f3a53d05 --- /dev/null +++ b/suffix_mask.go @@ -0,0 +1,665 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +// SuffixMask: design overview +// +// A SuffixMask is a `[Lower, Upper)` range over the comparer's suffix +// ordering. When attached to a table's metadata, the iterator runtime +// hides every key in that table whose suffix falls in the range. Keys +// with no suffix are never hidden. The mask is invisible to readers — +// it surfaces as if the masked keys had been point-deleted. +// +// `DeleteSuffixRange` is the public API that attaches masks. For each +// SSTable overlapping the user-key span, it picks one of three actions +// based on how the file relates to the span and the suffix range: +// +// skip — BPF says no key in this file falls in [Lower, Upper). +// No version edit for this file. Pure optimization. +// excise — Whole-file content is in the mask range (today, only the +// `SyntheticSuffix` case detects this). The overlapping +// portion is deleted; outside portions become virtual SSTs +// retaining the parent's existing mask list (read-only). +// mask — Some keys fall in the mask range. The new mask is attached +// to the (possibly virtual) inside portion. If the file +// straddles the span, `applySuffixMaskToStraddlingTable` +// splits it into up to three virtual tables and the mask +// applies only to the inside one. +// +// Multi-mask: `TableMetadata.SuffixMasks` is an ordered list, not a +// single range, so repeated `DeleteSuffixRange` calls accumulate. +// On insert we compare only with the last entry (`expandSuffixMask`): +// if the new range is contiguous, we expand in place; otherwise we +// append. We never sort or reshape the list — the per-row filter +// scans it linearly, which is fast for the realistic small N. +// +// Aliasing discipline: `SuffixMasks` and the byte slices it contains +// are immutable by convention. Any DSR path that needs to extend an +// inherited list clones it first (`slices.Clone`). The convention is +// documented on each field; this file's helpers honor it. +// +// Compaction interaction: compactions iterate input files via the +// standard iterator stack, which applies the per-row mask filter. The +// output file is therefore post-filter and must NOT carry the mask +// forward; see `TestSuffixMaskClearedAfterCompaction`. + +package pebble + +import ( + "context" + "slices" + + "github.com/cockroachdb/errors" + "github.com/cockroachdb/pebble/internal/base" + "github.com/cockroachdb/pebble/internal/invariants" + "github.com/cockroachdb/pebble/internal/manifest" + "github.com/cockroachdb/pebble/sstable" + "github.com/cockroachdb/pebble/sstable/block" +) + +// DeleteSuffixRange deletes all point keys and range key entries within the +// given key span whose suffixes fall within the range [lower, upper) as +// determined by ComparePointSuffixes. Lower must be <= upper per the +// comparer; lower is inclusive, upper is exclusive. Keys with no suffix +// are not affected. The affected keys are hidden from all future iterators +// and eventually dropped during compaction. +// +// Internally, the deletion is implemented by attaching a suffix mask to the +// metadata of overlapping SSTables. For SSTables fully contained within the +// span, the mask is applied directly. For SSTables straddling the span +// boundary, the table is split into virtual tables so the mask applies only +// to the portion within the span. +// +// If the memtable overlaps the span, it is flushed before applying the mask +// to ensure all in-memory data is in SSTs. +// +// Durability: on successful return, the resulting manifest edit (if any) is +// durably recorded. If no SSTables overlap `span`, the call is a no-op and +// returns nil without forcing a manifest fsync. Concurrent writers that race +// with this call are not guaranteed to be masked; if the caller needs that +// guarantee it must serialize with its own writers. +// +// Repeated DeleteSuffixRange calls are supported: a per-table SuffixMasks +// list accumulates masks over time. Contiguous masks are merged in place; +// disjoint masks are appended. +// +// As an optimization, when Options.SuffixRangeIntersects is configured, +// each overlapping file's block-property aggregate is consulted before a +// mask is attached. Files whose aggregate proves no key falls within the +// mask's [lower, upper) range are skipped entirely (no mask, no excise, +// no version edit). The optimization is invisible to readers; it merely +// avoids attaching trivially-no-op masks. +func (d *DB) DeleteSuffixRange(ctx context.Context, span KeyRange, lower, upper []byte) error { + if err := d.closed.Load(); err != nil { + panic(err) + } + if d.opts.ReadOnly { + return ErrReadOnly + } + if len(lower) == 0 { + return errors.New("DeleteSuffixRange: lower bound is empty") + } + if len(upper) == 0 { + return errors.New("DeleteSuffixRange: upper bound is empty") + } + if !span.Valid() { + return errors.New("invalid key range") + } + if d.FormatMajorVersion() < FormatSuffixMask { + return errors.Newf( + "DeleteSuffixRange requires at least format major version %d", + FormatSuffixMask, + ) + } + + // Flush memtables that overlap the DSR span, and extend the overlap + // check with the protected ranges of any pre-file-only EFOS whose + // protected ranges overlap our span. The extension forces a flush that + // will transition such EFOSes to file-only — pinning a pre-DSR + // `*Version` — before our mask attaches. Without this, an EFOS reader + // in its protected range would observe our mask via the seqnum- + // filtered current-LSM read path that pre-file-only EFOSes use, and + // the EFOS contract that protected-range reads remain consistent + // across non-additive mutations would be violated. + // + // Classic `Snapshot` observers cannot be protected the same way (no + // protected ranges, no transition mechanism); their post-DSR reads + // will observe the masked LSM, matching the documented + // excise-with-classic-snapshot behavior. See the `DB.NewSnapshot` + // docstring. + if err := d.flushIfOverlapping(span, func() []bounded { + return exciseOverlapBounds(d.cmp, &d.mu.snapshots.snapshotList, span, base.SeqNumMax) + }); err != nil { + return err + } + + suffixMask := sstable.SuffixMask{Lower: lower, Upper: upper} + + d.mu.Lock() + defer d.mu.Unlock() + + _, err := d.mu.versions.UpdateVersionLocked(func() (versionUpdate, error) { + // Force any pre-file-only EFOS that can transition (no blocking + // memtable content in its protected ranges) to transition with the + // CURRENT (pre-DSR) version pinned. `flushIfOverlapping` above + // already handles the case where EFOS-protected memtable content + // blocks transition by forcing a flush; the flush completion's + // own call to this same routine transitions the EFOS. This + // explicit invocation closes the gap for EFOSes where the overlap + // check found no memtable content (so no flush was triggered) + // and for any EFOS whose pre-DSR transition would otherwise be + // deferred to an unrelated future flush — at which point the + // pinned version would include our mask. + d.maybeTransitionSnapshotsToFileOnlyLocked() + + current := d.mu.versions.currentVersion() + ve := &manifest.VersionEdit{ + DeletedTables: make(map[manifest.DeletedTableEntry]*manifest.TableMetadata), + } + + bounds := span.UserKeyBounds() + + suffixCmp := d.opts.Comparer.ComparePointSuffixes + for level := 0; level < manifest.NumLevels; level++ { + overlaps := current.Overlaps(level, bounds) + iter := overlaps.Iter() + for m := iter.First(); m != nil; m = iter.Next() { + // L0 Overlaps expands its result to include the transitive + // closure of all files reachable through pairwise overlap, + // which can include files that don't overlap our original + // span. Skip those. + mBounds := m.UserKeyBounds() + if !bounds.Overlaps(d.cmp, mBounds) { + continue + } + // Consult the file's block-property aggregate, if a hook + // is configured. If the aggregate proves no key in the + // file falls within the mask range, the file is skipped + // entirely (no version edit, no mask attached). + // + // Under invariants builds, randomly bypass the skip so + // the mask-attachment, virtual-table, and per-row filter + // machinery exercise on files that won't actually match. + // The mask is observably a no-op in that case. + if d.opts.SuffixRangeIntersects != nil { + skip, err := d.suffixMaskCanSkipFile(ctx, m, suffixMask.Lower, suffixMask.Upper) + if err != nil { + return versionUpdate{}, err + } + if skip && (suffixMaskSkipBypassDisabled || !invariants.Sometimes(25)) { + continue + } + } + // SyntheticSuffix files have a largely uniform mask answer: + // point keys all have effective suffix = synth; RangeKeySet + // entries with non-empty original suffix get effective = synth; + // RangeKeySet entries with empty original suffix retain empty + // (never masked); RangeKeyDelete entries have no per-key suffix + // (also never masked). RangeKeyUnset cannot appear on a + // SyntheticSuffix file (see the assertion in + // `rowblk_fragment_iter.go::applySpanTransforms`). + // + // Three cases based on (synth-in-mask, has-range-keys): + // + // 1. synth NOT in mask: no row in the file matches the mask. + // Skip the file entirely (no version edit). + // 2. synth IN mask AND no range keys: every point key is + // uniformly masked. Excise the file's overlap; a per-row + // mask would be functionally equivalent but more expensive + // at iteration time. + // 3. synth IN mask AND has range keys: cannot safely excise — + // excising would drop RangeKeyDelete entries (suffixless + // per the DSR contract) and any empty-suffix RangeKeySet + // entries (effective suffix retains empty, also never + // masked). Fall through to per-row mask attachment; the + // per-row paths in `rowblk_iter.go`, + // `colblk/data_block.go`, and `rowblk_fragment_iter.go` + // collectively honor SyntheticSuffix and skip + // empty-effective-suffix entries. + // + // TODO(dt): track "file contains an empty-suffix RangeKeySet" + // and "file contains a RangeKeyDelete" as bits on + // `TableMetadata` set at writer/ingest time. With those bits + // we could distinguish the genuinely unsafe case from the + // (synth-in-mask + range-keys-but-no-suffixless-entries) case, + // where excise would be safe and is cheaper. Today we fall + // through conservatively whenever HasRangeKeys is true. + if m.SyntheticPrefixAndSuffix.HasSuffix() { + ss := m.SyntheticPrefixAndSuffix.Suffix() + synthInMask := len(ss) > 0 && + suffixCmp(ss, suffixMask.Lower) >= 0 && + suffixCmp(ss, suffixMask.Upper) < 0 + if !synthInMask { + continue + } + if !m.HasRangeKeys { + if err := d.exciseOverlap(ctx, ve, m, level, span); err != nil { + return versionUpdate{}, err + } + continue + } + // Fall through to per-row mask attachment below. + } + + // Build the new SuffixMasks list for the (virtual) target + // table. We clone-on-extend to honor the aliasing discipline + // documented on TableMetadata.SuffixMasks. Defer cloning until + // we know we're producing a new list: the contained-in-last + // case below short-circuits with no version edit at all. + var newMasks []sstable.SuffixMask + if n := len(m.SuffixMasks); n > 0 { + merged, ok := expandSuffixMask(suffixCmp, m.SuffixMasks[n-1], suffixMask) + if ok { + // If the merge is a no-op (the new mask is fully + // contained in the existing last mask), skip the + // file entirely — there is no version edit to + // apply. Note this is independent of n; only the + // last mask is considered because masks are kept + // in a list whose last entry is the most-recently + // extended one. + if suffixCmp(merged.Lower, m.SuffixMasks[n-1].Lower) == 0 && + suffixCmp(merged.Upper, m.SuffixMasks[n-1].Upper) == 0 { + continue + } + newMasks = slices.Clone(m.SuffixMasks) + newMasks[n-1] = merged + } else { + newMasks = slices.Clone(m.SuffixMasks) + newMasks = append(newMasks, suffixMask) + } + } else { + newMasks = []sstable.SuffixMask{suffixMask} + } + + // span.End is exclusive; a naive `cmp(span.End, m.Largest().UserKey) >= 0` + // would treat a file whose largest key has the same user key as + // `span.End` (with an inclusive boundary) as fully contained, + // even though that key falls *outside* the span. Use the + // half-open bounds containment check. + if bounds.ContainsBounds(d.cmp, mBounds) { + d.applySuffixMaskToTable(ve, m, level, newMasks) + } else { + if err := d.applySuffixMaskToStraddlingTable(ctx, ve, m, level, span, newMasks); err != nil { + return versionUpdate{}, err + } + } + } + } + + if len(ve.DeletedTables) == 0 && len(ve.NewTables) == 0 { + return versionUpdate{}, nil + } + + if invariants.Enabled { + for _, e := range ve.NewTables { + if err := e.Meta.Validate(d.cmp, d.opts.Comparer.FormatKey); err != nil { + return versionUpdate{}, errors.AssertionFailedf( + "DeleteSuffixRange constructed invalid table %s: %v", e.Meta.TableNum, err) + } + } + } + + // Cancel any in-progress compactions whose inputs overlap the DSR + // span. Such compactions were picked against a version that included + // the original (pre-mask) tables; their version edits will try to + // delete those tables, but our version edit replaces them with + // virtual tables, leaving the manifest's blob-reference tracker + // unable to locate the original tables. The cancelled compactions + // will be re-picked against the post-DSR version. + // + // We must do this with `vs.logLock` held (which we hold here because + // we're inside `UpdateVersionLocked`'s updateFn). Compactions check + // `c.cancel` while holding `vs.logLock` inside their own apply step, + // so setting `c.cancel` under the same lock is sufficient to prevent + // the cancelled compaction from applying its conflicting VE. + // + // Note: an earlier version of this code instead waited for + // `compactingCount == 0` before entering `UpdateVersionLocked`. That + // is insufficient because `UpdateVersionLocked`'s `logLock` may + // release DB.mu while waiting for another writer to finish, during + // which a new compaction may be scheduled (incrementing + // `compactingCount`). The cancellation pattern is correct and + // mirrors what `IngestAndExcise` does. + for c := range d.mu.compact.inProgress { + if c.VersionEditApplied() { + continue + } + cBounds := c.Bounds() + if cBounds != nil && cBounds.Overlaps(d.cmp, bounds) { + c.Cancel() + } + } + + return versionUpdate{ + VE: ve, + InProgressCompactionsFn: func() []compactionInfo { + return d.getInProgressCompactionInfoLocked(nil) + }, + }, nil + }) + if err != nil { + return err + } + d.updateReadStateLocked(d.opts.DebugCheck) + return nil +} + +// suffixMaskSkipBypassDisabled is set by tests that need DeleteSuffixRange +// to skip files deterministically (i.e., never trigger the +// invariants.Sometimes metamorphic bypass). Production code never reads +// this; it is only consulted inside the bypass check. +var suffixMaskSkipBypassDisabled = false + +// suffixMaskCanSkipFile reports whether DeleteSuffixRange may skip the +// given file based on the file's block-property aggregate. It opens the +// file's sstable.Reader (via the file cache) and passes the reader's +// table-level UserProperties to the configured SuffixRangeIntersects +// hook. The hook returning false means "no key in this file falls +// within [lower, upper)", and the file is safe to skip; the hook +// returning true means a mask is required. +// +// If opening the reader fails, the error is returned and the caller +// must abort the DeleteSuffixRange. Callers must only invoke this +// when d.opts.SuffixRangeIntersects is non-nil. +func (d *DB) suffixMaskCanSkipFile( + ctx context.Context, m *manifest.TableMetadata, lower, upper []byte, +) (bool, error) { + var intersects bool + err := d.fileCache.withReader(ctx, block.NoReadEnv, m, + func(r *sstable.Reader, _ sstable.ReadEnv) error { + intersects = d.opts.SuffixRangeIntersects(r.UserProperties, lower, upper) + return nil + }) + if err != nil { + return false, err + } + return !intersects, nil +} + +// applySuffixMaskToTable applies the suffix masks to a table that is fully +// contained within the target span. +func (d *DB) applySuffixMaskToTable( + ve *manifest.VersionEdit, m *manifest.TableMetadata, level int, suffixMasks []sstable.SuffixMask, +) { + newMeta := &manifest.TableMetadata{ + TableNum: d.mu.versions.getNextTableNum(), + Size: m.Size, + CreationTime: m.CreationTime, + SeqNums: m.SeqNums, + LargestSeqNumAbsolute: m.LargestSeqNumAbsolute, + Virtual: true, + SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix, + SuffixMasks: suffixMasks, + BlobReferenceDepth: m.BlobReferenceDepth, + BlobReferences: m.BlobReferences, + } + if m.HasPointKeys { + newMeta.ExtendPointKeyBounds( + d.cmp, m.PointKeyBounds.Smallest(), m.PointKeyBounds.Largest()) + } + if m.HasRangeKeys { + newMeta.ExtendRangeKeyBounds( + d.cmp, m.RangeKeyKinds, m.RangeKeyBounds.Smallest(), m.RangeKeyBounds.Largest()) + } + newMeta.AttachVirtualBacking(m.TableBacking) + + ve.DeletedTables[manifest.DeletedTableEntry{ + Level: level, + FileNum: m.TableNum, + }] = m + if !m.Virtual { + ve.CreatedBackingTables = append(ve.CreatedBackingTables, m.TableBacking) + } + ve.NewTables = append(ve.NewTables, manifest.NewTableEntry{Level: level, Meta: newMeta}) +} + +// applySuffixMaskToStraddlingTable handles a table that straddles the span +// boundary. It splits the table into up to three virtual tables so that the +// new masks list is applied only to the portion within the span. The portions +// outside the span retain m's existing masks (if any): `exciseTable` shares +// (read-only) `m.SuffixMasks` with the outside virtual tables, per the +// aliasing discipline documented on `TableMetadata.SuffixMasks`. +func (d *DB) applySuffixMaskToStraddlingTable( + ctx context.Context, + ve *manifest.VersionEdit, + m *manifest.TableMetadata, + level int, + span KeyRange, + tableMasks []sstable.SuffixMask, +) error { + exciseBounds := span.UserKeyBounds() + + // exciseTable is used here to split the table: it returns the portions + // of m outside the span, which we keep unmasked. We separately construct + // a virtual table for the portion inside the span with the masks applied. + leftTable, rightTable, err := d.exciseTable(ctx, exciseBounds, m, level, tightExciseBoundsIfLocal) + if err != nil { + return err + } + + ve.DeletedTables[manifest.DeletedTableEntry{ + Level: level, + FileNum: m.TableNum, + }] = m + if !m.Virtual { + ve.CreatedBackingTables = append(ve.CreatedBackingTables, m.TableBacking) + } + + // Add outside portions; exciseTable carried m's existing masks (if any) + // onto them, which is what we want — only the inside portion gets the + // expanded masks via middleTable below. + if leftTable != nil { + ve.NewTables = append(ve.NewTables, manifest.NewTableEntry{Level: level, Meta: leftTable}) + } + if rightTable != nil { + ve.NewTables = append(ve.NewTables, manifest.NewTableEntry{Level: level, Meta: rightTable}) + } + + // Construct the inside portion with loose bounds clamped to the span. + middleTable := &manifest.TableMetadata{ + TableNum: d.mu.versions.getNextTableNum(), + CreationTime: m.CreationTime, + SeqNums: m.SeqNums, + LargestSeqNumAbsolute: m.LargestSeqNumAbsolute, + Virtual: true, + SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix, + SuffixMasks: tableMasks, + BlobReferenceDepth: m.BlobReferenceDepth, + } + looseMiddleTableBounds(d.cmp, m, middleTable, exciseBounds) + if !middleTable.HasPointKeys && !middleTable.HasRangeKeys { + return nil + } + + var used uint64 + if leftTable != nil { + used += leftTable.Size + } + if rightTable != nil { + used += rightTable.Size + } + middleTable.Size = middleTableSize(m.Size, used) + middleTable.AttachVirtualBacking(m.TableBacking) + determineSuffixMaskBlobReferences(m.BlobReferences, m.Size, middleTable, d.FormatMajorVersion()) + ve.NewTables = append(ve.NewTables, manifest.NewTableEntry{Level: level, Meta: middleTable}) + return nil +} + +// smallestInternalKeyAt returns the smallest possible (in InternalCompare +// order) non-sentinel internal key at the given user key, with a kind suitable +// for the bound it represents. The trailer pairs `SeqNumMax-1` (the largest +// non-sentinel sequence number) with the provided kind; because internal keys +// at equal user keys sort by trailer DESCENDING, this yields the smallest such +// key with the given kind. +// +// Use this when synthesizing the inclusive smallest bound of a virtual table +// at a user key. A naive `(userKey, 0, kind)` would sort AFTER any real range +// tombstone at the same user key (whose trailer encodes a real non-zero +// sequence number), tripping the lower-bound check in `keyspan.AssertBounds` +// against `PointKeyBounds.Smallest()`. +// +// `SeqNumMax` itself cannot be used: it marks an exclusive sentinel, and +// `UserKeyBoundsFromInternal` panics on a sentinel smallest. The caller is +// responsible for passing a kind that is valid for the bound type (e.g. +// `InternalKeyKindMaxForSSTable` for a point bound, `InternalKeyKindRangeKeyMax` +// for a range-key bound; see `isValidPointBoundKeyKind` and +// `isValidRangeKeyBoundKeyKind` in `internal/manifest/table_metadata.go`). +func smallestInternalKeyAt(userKey []byte, kind base.InternalKeyKind) base.InternalKey { + return base.MakeInternalKey(userKey, base.SeqNumMax-1, kind) +} + +// middleTableSize returns the size to attribute to the middle (masked) +// virtual table produced by `applySuffixMaskToStraddlingTable`. The left and +// right virtual tables already carry size estimates; the middle table gets +// whatever's left of the original. If the left+right estimates exceed the +// original (loose bounds make this possible), we clamp to 1 since a zero +// size would imply the table is empty and risks underflow elsewhere. +func middleTableSize(originalSize, used uint64) uint64 { + if used >= originalSize { + return 1 + } + return originalSize - used +} + +// looseMiddleTableBounds sets loose bounds on middleTable for the portion of +// originalTable that lies within the given bounds. +func looseMiddleTableBounds( + cmp Compare, originalTable, middleTable *manifest.TableMetadata, bounds base.UserKeyBounds, +) { + if originalTable.HasPointKeys { + // Always synthesize the smallest with the maximum non-sentinel + // trailer. Using the originalTable's PointKeyBounds.Smallest() + // directly is unsafe: that key may have a small trailer (e.g. + // seqnum=0, kind=DELSIZED), and a real range tombstone at the + // same user key with a larger trailer would violate the + // rangeDelIter's lower-bound assertion in invariants builds. + // The bound is loose either way; using the largest trailer is + // strictly safer. + smallestUserKey := originalTable.PointKeyBounds.Smallest().UserKey + if cmp(smallestUserKey, bounds.Start) < 0 { + smallestUserKey = bounds.Start + } + smallest := smallestInternalKeyAt(smallestUserKey, base.InternalKeyKindMaxForSSTable) + largest := originalTable.PointKeyBounds.Largest() + if largest.IsUpperBoundFor(cmp, bounds.End.Key) { + largest = base.MakeRangeDeleteSentinelKey(bounds.End.Key) + } + if base.InternalCompare(cmp, smallest, largest) < 0 { + middleTable.ExtendPointKeyBounds(cmp, smallest, largest) + } + } + if originalTable.HasRangeKeys { + // Same rationale as the point-key bound above. + smallestUserKey := originalTable.RangeKeyBounds.Smallest().UserKey + if cmp(smallestUserKey, bounds.Start) < 0 { + smallestUserKey = bounds.Start + } + smallest := smallestInternalKeyAt(smallestUserKey, base.InternalKeyKindRangeKeyMax) + largest := originalTable.RangeKeyBounds.Largest() + if largest.IsUpperBoundFor(cmp, bounds.End.Key) { + // `bounds` always has Kind == Exclusive when produced from + // KeyRange.UserKeyBounds(), the only construction path today. + largest = base.MakeExclusiveSentinelKey(largest.Kind(), bounds.End.Key) + } + if base.InternalCompare(cmp, smallest, largest) < 0 { + middleTable.ExtendRangeKeyBounds(cmp, originalTable.RangeKeyKinds, smallest, largest) + } + } +} + +// exciseOverlap deletes the portion of m that overlaps with the span. If m is +// fully contained, it is deleted entirely. If m straddles the span boundary, +// the outside portions are kept as virtual tables. This is used for files with +// SyntheticSuffix whose suffix falls within the mask range — every key in the +// file is masked, so no per-row filtering is needed. +func (d *DB) exciseOverlap( + ctx context.Context, + ve *manifest.VersionEdit, + m *manifest.TableMetadata, + level int, + span KeyRange, +) error { + // See the analogous check in DeleteSuffixRange; span.End is exclusive so + // the half-open bounds containment is the correct comparison. + spanBounds := span.UserKeyBounds() + mBounds := m.UserKeyBounds() + fullyContained := spanBounds.ContainsBounds(d.cmp, mBounds) + + ve.DeletedTables[manifest.DeletedTableEntry{ + Level: level, + FileNum: m.TableNum, + }] = m + if !m.Virtual { + ve.CreatedBackingTables = append(ve.CreatedBackingTables, m.TableBacking) + } + + if fullyContained { + return nil + } + + // Straddling: keep the outside portions. + exciseBounds := span.UserKeyBounds() + leftTable, rightTable, err := d.exciseTable(ctx, exciseBounds, m, level, tightExciseBoundsIfLocal) + if err != nil { + return err + } + if leftTable != nil { + ve.NewTables = append(ve.NewTables, manifest.NewTableEntry{Level: level, Meta: leftTable}) + } + if rightTable != nil { + ve.NewTables = append(ve.NewTables, manifest.NewTableEntry{Level: level, Meta: rightTable}) + } + return nil +} + +// expandSuffixMask attempts to merge two SuffixMasks into a single contiguous +// range. It returns (merged, true) when the union of the two input ranges is +// itself a single contiguous range, or (zero, false) when the two ranges are +// disjoint (i.e. have a gap between them) and cannot be merged. +// +// Both masks must be valid: suffixCmp(Lower, Upper) < 0. Bounds are compared +// using the provided suffixCmp. Using bytes.Compare here would be incorrect +// for any suffix encoding where byte order disagrees with the comparer's +// suffix order (e.g., testkeys' variable-length decimal "@N" suffixes: +// "@9" < "@99" in bytes but "@9" > "@99" in suffix order). +func expandSuffixMask( + suffixCmp base.ComparePointSuffixes, a, b sstable.SuffixMask, +) (sstable.SuffixMask, bool) { + // Two half-open intervals [L1, U1) and [L2, U2) (with L < U in suffix + // order) overlap or are adjacent iff max(L1, L2) <= min(U1, U2). If + // max(L1, L2) > min(U1, U2), there is a gap and the union is not a + // single contiguous range. + maxLower := a.Lower + if suffixCmp(b.Lower, maxLower) > 0 { + maxLower = b.Lower + } + minUpper := a.Upper + if suffixCmp(b.Upper, minUpper) < 0 { + minUpper = b.Upper + } + if suffixCmp(maxLower, minUpper) > 0 { + return sstable.SuffixMask{}, false + } + // Merged = [min(Lower), max(Upper)] in suffix order. + lower := a.Lower + if suffixCmp(b.Lower, lower) < 0 { + lower = b.Lower + } + upper := a.Upper + if suffixCmp(b.Upper, upper) > 0 { + upper = b.Upper + } + return sstable.SuffixMask{Lower: lower, Upper: upper}, true +} + +func determineSuffixMaskBlobReferences( + originalRefs manifest.BlobReferences, + originalSize uint64, + table *manifest.TableMetadata, + fmv FormatMajorVersion, +) { + if len(originalRefs) == 0 { + return + } + determineExcisedTableBlobReferences(originalRefs, originalSize, table, fmv) +} diff --git a/suffix_mask_compaction_test.go b/suffix_mask_compaction_test.go new file mode 100644 index 00000000000..294b19c581a --- /dev/null +++ b/suffix_mask_compaction_test.go @@ -0,0 +1,269 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +package pebble + +import ( + "context" + "math" + "testing" + + "github.com/cockroachdb/crlib/testutils/leaktest" + "github.com/cockroachdb/crlib/testutils/require" + "github.com/cockroachdb/pebble/cockroachkvs" + "github.com/cockroachdb/pebble/internal/manifest" + "github.com/cockroachdb/pebble/internal/testutils" + "github.com/cockroachdb/pebble/sstable" + "github.com/cockroachdb/pebble/vfs" +) + +// TestDeleteSuffixRangeCancelsOverlappingCompaction pins the fix for the +// race documented at the top of `DB.DeleteSuffixRange` in `suffix_mask.go`: +// +// A compaction picked against the pre-DSR version holds a *TableMetadata +// pointer for each input file. When that compaction later applies its +// version edit, the manifest's blob-reference tracker (keyed by pointer +// in `internal/manifest/blob_metadata.go`) looks up each deleted table's +// blob references. If DSR has, in the meantime, replaced the input file +// with a virtual table — a different *TableMetadata pointer — the +// tracker no longer has an entry for the original pointer and fires: +// +// pebble: deleted table NNNN's reference to blob file BNNN not known +// +// The fix: inside DSR's `UpdateVersionLocked` updateFn (which holds the +// manifest log lock), iterate `d.mu.compact.inProgress` and `Cancel()` +// any compaction whose `Bounds()` overlap the DSR span. The cancelled +// compaction's own apply step checks `c.cancel` under the same log lock +// and returns `ErrCancelledCompaction` instead of applying its VE. +// +// Test design: +// - Insert keys with values large enough to land in a blob file. Flush +// so the L0 table carries a blob reference. +// - Anchor an L6 file so the subsequent manual compaction must merge +// (not trivial-move). +// - Trigger a manual compaction in a goroutine. Pause it inside the I/O +// phase via `testingDuringCompactionIOFunc` — the compaction has +// constructed its `VersionEdit` deleting the L0 input but has not yet +// re-acquired DB.mu to apply it. +// - From the test goroutine, call `DeleteSuffixRange` over the same +// user-key span. With the fix, DSR enters its updateFn, sees the +// in-progress compaction overlapping its span, and calls +// `Cancel()`. DSR's VE applies (replacing the L0 input with a +// virtual table). +// - Release the compaction. Its apply step observes `c.cancel == true` +// and returns `ErrCancelledCompaction` gracefully. +// +// On unfixed code (which instead waited for `compactingCount == 0` before +// DSR ran), the test deadlocks: the paused compaction holds +// `compactingCount > 0` while DSR's wait blocks indefinitely, and the +// compaction can't proceed past the hook until DSR returns. The wait was +// the buggy mechanism the fix replaces; removing it requires the cancel +// pattern to prevent the manifest assertion the metamorphic test surfaced. +func TestDeleteSuffixRangeCancelsOverlappingCompaction(t *testing.T) { + defer leaktest.AfterTest(t)() + + // hookReady fires when the compaction's I/O phase has produced its + // VersionEdit and is about to re-acquire DB.mu. release tells the + // hook to return (allowing the compaction's apply step to proceed). + hookReady := make(chan struct{}) + release := make(chan struct{}) + + fs := vfs.NewMem() + opts := &Options{ + Comparer: &cockroachkvs.Comparer, + FS: fs, + FormatMajorVersion: FormatNewest, + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + L0CompactionThreshold: 100, + L0StopWritesThreshold: 100, + DisableAutomaticCompactions: true, + Logger: testutils.Logger{T: t}, + } + // Force value separation so the flushed L0 table will carry a blob + // reference; the manifest's tracker is what surfaces the bug. + opts.ValueSeparationPolicy = func() ValueSeparationPolicy { + return ValueSeparationPolicy{ + Enabled: true, + MinimumSize: 1, + MinimumMVCCGarbageSize: 1, + MaxBlobReferenceDepth: 10, + } + } + // The hook fires from inside the compaction's I/O phase; only the + // specific compaction we trigger below should pause. Use sync.Once-style + // gating via the channels themselves (closed-channel detection). + fired := make(chan struct{}, 1) + opts.private.testingDuringCompactionIOFunc = func() { + select { + case fired <- struct{}{}: + // First (and only) compaction we want to pause. + close(hookReady) + <-release + default: + // Subsequent compactions pass straight through. + } + } + db, err := Open("", opts) + require.NoError(t, err) + defer func() { require.NoError(t, db.Close()) }() + ctx := context.Background() + + aStart := testMakeEngineKey([]byte("a"), 0, 0) + bStart := testMakeEngineKey([]byte("b"), 0, 0) + + // Anchor at L6 with several user keys spanning [a, az] so any L0 file + // in that range overlaps the L6 anchor in user-key space and forces a + // merging compaction (not a trivial move). Each anchor entry uses a + // wall=1 suffix that's outside the mask range used below. + for _, k := range []string{"a", "ab", "ac", "ad", "ae", "af", "ag"} { + require.NoError(t, db.Set(testMakeEngineKey([]byte(k), 1, 0), + []byte("anchor-value-large-enough-for-separation"), nil)) + } + require.NoError(t, db.Flush()) + require.NoError(t, db.Compact(ctx, aStart, bStart, false)) + + // Flush an L0 table whose user-key range [a, ag] overlaps the L6 anchor + // (forcing a real merge). The values are large enough to be + // value-separated into a blob file, so the L0 table carries blob + // references. + for i, k := range []string{"a", "ab", "ac", "ad", "ae", "af", "ag"} { + // Vary the wall time so different keys land at different versions. + // The wall is > 10 so it falls within the mask range (10, MaxUint64]. + wall := uint64(20 + 10*i) + require.NoError(t, db.Set(testMakeEngineKey([]byte(k), wall, 0), + []byte("value-large-enough-for-blob-separation-aaaaaa"), nil)) + } + require.NoError(t, db.Flush()) + + // Verify the L0 table picked up at least one blob reference. + var blobRefsBefore int + for level := 0; level < manifest.NumLevels; level++ { + for f := range db.DebugCurrentVersion().Levels[level].All() { + blobRefsBefore += len(f.BlobReferences) + } + } + if blobRefsBefore == 0 { + t.Fatalf("setup: expected the flushed L0 table to carry at least one blob reference") + } + + // Trigger a manual compaction; it will pause in + // `testingDuringCompactionIOFunc` after constructing its VersionEdit. + compactDone := make(chan error, 1) + go func() { + compactDone <- db.Compact(ctx, aStart, bStart, false) + }() + + // Wait for the compaction to be paused inside the hook. + <-hookReady + + // While the compaction is paused, run DSR over the same span. With the + // fix, DSR enters its updateFn (acquiring d.mu since the compaction + // released it during I/O), finds the in-progress compaction overlapping + // its span, and cancels it. Without the fix, DSR's pre-updateFn + // "wait for compactingCount == 0" deadlocks here. + require.NoError(t, db.DeleteSuffixRange(ctx, + KeyRange{Start: aStart, End: bStart}, + testMakeSuffix(math.MaxUint64, 0), + testMakeSuffix(10, 0), + )) + + // Release the paused compaction; it should observe `c.cancel == true` + // and exit gracefully (Compact retries internally, but with the L0 + // input now replaced by a virtual table, the next compaction attempt + // either succeeds against the masked file or finds nothing to do). + close(release) + + // The manual Compact returns nil if any retry succeeded, or an error + // indicating cancellation. Either is acceptable; the critical property + // is that no manifest assertion fires. + require.NoError(t, <-compactDone) +} + +// The reasoning is correct by construction (the compaction iterator +// applies the mask as it reads the inputs, so the output is post-filter +// data), but if compaction were to ever blindly copy `SuffixMasks` from +// an input to its output we would silently re-mask already-filtered +// keys — a correctness bug that affects exactly the keys outside the +// mask range that happen to share a file with masked ones. This test +// pins the contract. +// +// Trivial-move compactions DO preserve the mask (and should — no +// rewrite happens, so the mask is still needed). To exercise a real +// merge we anchor a file in L6 before flushing the masked L0 file, so +// the subsequent compaction can't trivial-move. +func TestSuffixMaskClearedAfterCompaction(t *testing.T) { + defer leaktest.AfterTest(t)() + + // Disable the metamorphic skip bypass so the BPF skip's behavior + // (and therefore which files get masks) is deterministic here. + prev := suffixMaskSkipBypassDisabled + suffixMaskSkipBypassDisabled = true + defer func() { suffixMaskSkipBypassDisabled = prev }() + + db, _ := suffixMaskTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + ctx := context.Background() + + aStart := testMakeEngineKey([]byte("a"), 0, 0) + bStart := testMakeEngineKey([]byte("b"), 0, 0) + + // Anchor a single old version (wall=1) at L6. Any subsequent L0 file + // that shares the user key "a@1" will overlap this anchor in user-key + // space and force a real merging compaction (not a trivial move). + require.NoError(t, db.Set(testMakeEngineKey([]byte("a"), 1, 0), []byte("anchor"), nil)) + require.NoError(t, db.Flush()) + require.NoError(t, db.Compact(ctx, aStart, bStart, false)) + + // Flush six versions to L0. The wall=1 entry forces user-key overlap + // with the L6 anchor; the remaining walls populate the file with keys + // both inside and outside the mask range. + for _, wall := range []uint64{1, 5, 10, 20, 50, 100} { + require.NoError(t, db.Set(testMakeEngineKey([]byte("a"), wall, 0), []byte("v"), nil)) + } + require.NoError(t, db.Flush()) + + // Attach a mask covering walls (10, 50]: hides a@20 and a@50. + require.NoError(t, db.DeleteSuffixRange(ctx, + KeyRange{Start: aStart, End: bStart}, + testMakeSuffix(50, 0), // lower bound = newest wall to hide + testMakeSuffix(10, 0), // upper bound = oldest wall to keep visible + )) + + // Exactly one file should carry the mask: the L0 file whose key-range + // intersects the span AND whose wall-range intersects the mask. The L6 + // anchor (wall=1) has no walls in the mask range, so the BPF skip + // keeps it mask-free. + var maskedBefore int + for level := 0; level < manifest.NumLevels; level++ { + for f := range db.DebugCurrentVersion().Levels[level].All() { + if len(f.SuffixMasks) > 0 { + maskedBefore++ + } + } + } + require.Equal(t, 1, maskedBefore) + + // Compact: must merge the masked L0 file with the L6 anchor. + require.NoError(t, db.Compact(ctx, aStart, bStart, false)) + + // No file in any level may carry a SuffixMask after the rewrite. + for level := 0; level < manifest.NumLevels; level++ { + for f := range db.DebugCurrentVersion().Levels[level].All() { + require.Equal(t, 0, len(f.SuffixMasks)) + } + } + + // Iteration returns only the unmasked walls plus the anchor. + // cockroachkvs sorts MVCC keys newest-first: 100, 10, 5, 1. + iter, err := db.NewIter(nil) + require.NoError(t, err) + defer func() { require.NoError(t, iter.Close()) }() + var got []uint64 + for iter.First(); iter.Valid(); iter.Next() { + wall, _ := parseEngineKeyWallLogical(iter.Key()) + got = append(got, wall) + } + require.Equal(t, []uint64{100, 10, 5, 1}, got) +} diff --git a/suffix_mask_excise_test.go b/suffix_mask_excise_test.go new file mode 100644 index 00000000000..49827f73c30 --- /dev/null +++ b/suffix_mask_excise_test.go @@ -0,0 +1,720 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +// Suffix-mask tests: excise / file-splitting / middle-table machinery. These +// tests cover the interaction between SuffixMask and IngestAndExcise, the +// looseMiddleTableBounds helper that computes bounds for the middle virtual +// table when DeleteSuffixRange splits an existing table, the size estimator +// for that middle table, and the SyntheticSuffix path through exciseOverlap. + +package pebble + +import ( + "context" + "fmt" + "math" + "testing" + + "github.com/cockroachdb/crlib/testutils/leaktest" + "github.com/cockroachdb/crlib/testutils/require" + "github.com/cockroachdb/pebble/cockroachkvs" + "github.com/cockroachdb/pebble/internal/base" + "github.com/cockroachdb/pebble/internal/manifest" + "github.com/cockroachdb/pebble/objstorage/objstorageprovider" + "github.com/cockroachdb/pebble/objstorage/remote" + "github.com/cockroachdb/pebble/sstable" + "github.com/cockroachdb/pebble/vfs" +) + +// TestSuffixMaskExcisePreservation verifies that when a table with a SuffixMask +// is excised (split by IngestAndExcise), the resulting left and right virtual +// tables inherit the SuffixMask, and masked keys remain invisible. +// +// Setup: +// - Write keys for roach keys "a" through "e" at various wall times. +// - Flush to produce one SST. +// - Apply a suffix mask that hides wall times > 100. +// - Ingest-and-excise an SST covering roach key "c" to "d", which splits +// the masked table. +// +// After excise the LSM should contain: the left piece [a, c), the ingested +// table [c, d), and the right piece [d, e]. The left and right pieces should +// still carry the SuffixMask. Masked keys (wall > 100) should remain invisible +// through a normal iterator. +func TestSuffixMaskExcisePreservation(t *testing.T) { + defer leaktest.AfterTest(t)() + + db, fs := suffixMaskTestDB(t) + defer db.Close() + + // Write keys spanning roach keys a-e at various wall times. + type kv struct { + roachKey string + wall uint64 + value string + } + keys := []kv{ + {"a", 200, "a@200"}, + {"a", 100, "a@100"}, + {"b", 150, "b@150"}, + {"b", 50, "b@50"}, + {"c", 300, "c@300"}, + {"c", 80, "c@80"}, + {"d", 250, "d@250"}, + {"d", 90, "d@90"}, + {"e", 120, "e@120"}, + {"e", 60, "e@60"}, + } + for _, k := range keys { + engineKey := testMakeEngineKey([]byte(k.roachKey), k.wall, 0) + require.NoError(t, db.Set(engineKey, []byte(k.value), nil)) + } + require.NoError(t, db.Flush()) + + // Apply suffix mask: hide wall times in (100, MaxUint64]. + lower := testMakeSuffix(math.MaxUint64, 0) + upper := testMakeSuffix(100, 0) + spanStart := testMakeEngineKey([]byte("a"), 0, 0) + spanEnd := testMakeEngineKey([]byte("f"), 0, 0) + require.NoError(t, db.DeleteSuffixRange( + context.Background(), + KeyRange{Start: spanStart, End: spanEnd}, + lower, upper, + )) + + t.Logf("LSM before excise:\n%s", db.DebugString()) + + // This test focuses on whether `IngestAndExcise` propagates `SuffixMask` + // to the resulting virtual tables; end-to-end iterator visibility is + // covered by TestDeleteSuffixRangeOracle. + + // Record which tables have SuffixMasks set before excise. + ver := db.DebugCurrentVersion() + var maskedCountBefore int + for level := 0; level < manifest.NumLevels; level++ { + for f := range ver.Levels[level].All() { + if len(f.SuffixMasks) > 0 { + maskedCountBefore++ + } + } + } + t.Logf("masked tables before excise: %d", maskedCountBefore) + if maskedCountBefore == 0 { + t.Fatal("expected at least one masked table before excise") + } + + // Write an SST to ingest that covers [c, d). This will excise the masked + // table, splitting it into pieces. + suffixMaskWriteIngestSST(t, fs, "ingest.sst", []suffixMaskTestEntry{ + {"c", 500, "c@500-ingested"}, + }) + + exciseStart := testMakeEngineKey([]byte("c"), 0, 0) + exciseEnd := testMakeEngineKey([]byte("d"), 0, 0) + _, err := db.IngestAndExcise( + context.Background(), + []string{"ingest.sst"}, + nil, nil, + KeyRange{Start: exciseStart, End: exciseEnd}, + ) + require.NoError(t, err) + + t.Logf("LSM after excise:\n%s", db.DebugString()) + + // Check that the excised pieces retain the SuffixMasks. + ver = db.DebugCurrentVersion() + var maskedCountAfter int + for level := 0; level < manifest.NumLevels; level++ { + for f := range ver.Levels[level].All() { + if len(f.SuffixMasks) > 0 { + maskedCountAfter++ + require.Equal(t, 1, len(f.SuffixMasks)) + require.Equal(t, lower, f.SuffixMasks[0].Lower) + require.Equal(t, upper, f.SuffixMasks[0].Upper) + } + } + } + t.Logf("masked tables after excise: %d", maskedCountAfter) + // We expect at least two masked pieces (left and right of the excise span). + // The ingested table should not have a mask. + if maskedCountAfter < 2 { + t.Fatalf("expected at least 2 masked tables after excise, got %d", maskedCountAfter) + } + + // Verify masked keys are still invisible after excise. + postExcise := suffixMaskCollectVisible(t, db) + t.Logf("post-excise visible: %v", postExcise) + for _, val := range postExcise { + switch val { + case "a@200", "b@150", "d@250", "e@120": + t.Errorf("masked key %q should not be visible after excise", val) + } + } + // The ingested key c@500-ingested should be visible (it replaced the + // excised range). + found := false + for _, val := range postExcise { + if val == "c@500-ingested" { + found = true + break + } + } + if !found { + t.Fatal("expected ingested key c@500-ingested to be visible") + } +} + +// TestSuffixMaskLooseMiddleTableBounds systematically tests looseMiddleTableBounds +// across the cross-product of point key and range key positions relative to the +// span. Each key type can be: absent, fully inside, straddle-left, straddle-right, +// straddle-both, fully-outside-above, or fully-outside-below. +func TestSuffixMaskLooseMiddleTableBounds(t *testing.T) { + defer leaktest.AfterTest(t)() + + cmp := cockroachkvs.Comparer.Compare + mk := func(roachKey string) []byte { + return testMakeEngineKey([]byte(roachKey), 0, 0) + } + + // Span is always [d, p). + spanStart, spanEnd := mk("d"), mk("p") + + type bounds struct { + name string + start, end string // empty = absent + } + + // Positions relative to span [d, p): + positions := []bounds{ + {"absent", "", ""}, + {"inside", "e", "n"}, // fully inside [d, p) + {"straddle-left", "a", "h"}, // starts before d, ends inside + {"straddle-right", "k", "z"}, // starts inside, ends after p + {"straddle-both", "a", "z"}, // straddles both sides + {"outside-above", "q", "z"}, // entirely after p + {"outside-below", "a", "c"}, // entirely before d + {"boundary-exact", "d", "p"}, // bounds exactly at span start/end + {"boundary-start", "d", "n"}, // start at span start, end inside + {"boundary-end", "e", "p"}, // start inside, end at span end + } + + for _, pt := range positions { + for _, rk := range positions { + if pt.name == "absent" && rk.name == "absent" { + continue + } + name := fmt.Sprintf("pt=%s_rk=%s", pt.name, rk.name) + t.Run(name, func(t *testing.T) { + original := &manifest.TableMetadata{} + if pt.name != "absent" { + original.ExtendPointKeyBounds(cmp, + base.MakeInternalKey(mk(pt.start), 0, base.InternalKeyKindSet), + base.MakeInternalKey(mk(pt.end), 0, base.InternalKeyKindSet), + ) + } + if rk.name != "absent" { + original.ExtendRangeKeyBounds(cmp, manifest.AnyRangeKeys, + base.MakeInternalKey(mk(rk.start), 0, base.InternalKeyKindRangeKeySet), + base.MakeExclusiveSentinelKey(base.InternalKeyKindRangeKeySet, mk(rk.end)), + ) + } + + exciseBounds := base.UserKeyBoundsEndExclusive(spanStart, spanEnd) + middle := &manifest.TableMetadata{} + looseMiddleTableBounds(cmp, original, middle, exciseBounds) + + // Core invariants that must hold for ALL combinations: + checkBounds := func(label string, s, l base.InternalKey) { + if base.InternalCompare(cmp, s, l) >= 0 { + t.Fatalf("%s bounds inverted: %s >= %s", label, s, l) + } + if cmp(s.UserKey, spanStart) < 0 { + t.Fatalf("%s smallest %s before span start", label, s) + } + if cmp(l.UserKey, spanEnd) > 0 { + t.Fatalf("%s largest %s after span end", label, l) + } + } + if middle.HasPointKeys { + checkBounds("point", middle.PointKeyBounds.Smallest(), middle.PointKeyBounds.Largest()) + } + if middle.HasRangeKeys { + checkBounds("range", middle.RangeKeyBounds.Smallest(), middle.RangeKeyBounds.Largest()) + } + + // Keys fully outside the span should produce no bounds for that type. + if pt.name == "outside-above" || pt.name == "outside-below" { + if middle.HasPointKeys { + t.Fatal("point keys outside span should be absent") + } + } + if rk.name == "outside-above" || rk.name == "outside-below" { + if middle.HasRangeKeys { + t.Fatal("range keys outside span should be absent") + } + } + + // Keys with any overlap should produce bounds. + overlapping := []string{"inside", "straddle-left", "straddle-right", "straddle-both", + "boundary-exact", "boundary-start", "boundary-end"} + isOverlapping := func(name string) bool { + for _, n := range overlapping { + if n == name { + return true + } + } + return false + } + if isOverlapping(pt.name) { + if !middle.HasPointKeys { + t.Fatal("overlapping point keys should be present") + } + } + if isOverlapping(rk.name) { + if !middle.HasRangeKeys { + t.Fatal("overlapping range keys should be present") + } + } + + // When the original largest extends beyond the span end, + // the clamped largest must be a valid exclusive sentinel. + // This catches the sentinel-kind bug (fatal A) where + // MakeExclusiveSentinelKey(SET, ...) wasn't recognized. + clampedEnd := []string{"straddle-right", "straddle-both", "boundary-exact"} + isClamped := func(name string) bool { + for _, n := range clampedEnd { + if n == name { + return true + } + } + return false + } + if isClamped(pt.name) && middle.HasPointKeys { + if !middle.PointKeyBounds.Largest().IsExclusiveSentinel() { + t.Fatalf("clamped point largest should be exclusive sentinel, got %s", + middle.PointKeyBounds.Largest()) + } + } + if isClamped(rk.name) && middle.HasRangeKeys { + if !middle.RangeKeyBounds.Largest().IsExclusiveSentinel() { + t.Fatalf("clamped range largest should be exclusive sentinel, got %s", + middle.RangeKeyBounds.Largest()) + } + } + + }) + } + } +} + +// TestSuffixMaskSmallestBoundIsSafe verifies that the smallest bound +// synthesized by looseMiddleTableBounds (for the middle virtual table) and +// looseRightTableBounds (for the right virtual table after excise) is large +// enough in InternalCompare order that no real key in the file can violate +// it. +// +// This is a regression test for a bug where the synthesized smallest was +// `(userKey, 0, InternalKeyKindMaxForSSTable)` — a tiny trailer. A real +// range tombstone at the same user key with any non-zero seqnum has a much +// larger trailer, and therefore sorts BEFORE the synthesized bound in +// internal-key order, violating the lower-bound check in +// `keyspan.AssertBounds` (gated by invariants.Sometimes in +// `file_cache.newRangeDelIter`). The fix synthesizes with seqnum +// `SeqNumMax-1` (the largest non-sentinel seqnum); this test pins that +// choice rather than re-checking it via the randomly-gated assertion path. +// +// The bug was originally surfaced by the metamorphic test wiring (commit 8) +// with DSR weight > 0. This focused test makes a future regression +// immediately apparent without needing a metamorphic stress run. +func TestSuffixMaskSmallestBoundIsSafe(t *testing.T) { + defer leaktest.AfterTest(t)() + + cmp := cockroachkvs.Comparer.Compare + startKey := testMakeEngineKey([]byte("a"), 0, 0) + endKey := testMakeEngineKey([]byte("z"), 0, 0) + bounds := KeyRange{Start: startKey, End: endKey}.UserKeyBounds() + + // An original table whose smallest point key has a small trailer + // (seqnum=0, kind=DeleteSized) — the exact shape that triggered the bug. + original := &manifest.TableMetadata{} + original.ExtendPointKeyBounds(cmp, + base.MakeInternalKey(startKey, 0, base.InternalKeyKindDeleteSized), + base.MakeInternalKey(endKey, 0, base.InternalKeyKindSet), + ) + original.ExtendRangeKeyBounds(cmp, manifest.AnyRangeKeys, + base.MakeInternalKey(startKey, 0, base.InternalKeyKindRangeKeySet), + base.MakeInternalKey(endKey, 0, base.InternalKeyKindRangeKeySet), + ) + + t.Run("looseMiddleTableBounds", func(t *testing.T) { + var middle manifest.TableMetadata + looseMiddleTableBounds(cmp, original, &middle, bounds) + + // Any real internal key at startKey has trailer <= ((SeqNumMax-1)<<8)|kind. + // The synthesized bound's trailer must be >= that. + gotPoint := middle.PointKeyBounds.Smallest() + if got := gotPoint.SeqNum(); got != base.SeqNumMax-1 { + t.Fatalf("point smallest seqnum: got %d, want %d", got, base.SeqNumMax-1) + } + gotRange := middle.RangeKeyBounds.Smallest() + if got := gotRange.SeqNum(); got != base.SeqNumMax-1 { + t.Fatalf("range smallest seqnum: got %d, want %d", got, base.SeqNumMax-1) + } + }) + + t.Run("looseRightTableBounds", func(t *testing.T) { + var right manifest.TableMetadata + looseRightTableBounds(cmp, original, &right, startKey) + + gotPoint := right.PointKeyBounds.Smallest() + if got := gotPoint.SeqNum(); got != base.SeqNumMax-1 { + t.Fatalf("point smallest seqnum: got %d, want %d", got, base.SeqNumMax-1) + } + gotRange := right.RangeKeyBounds.Smallest() + if got := gotRange.SeqNum(); got != base.SeqNumMax-1 { + t.Fatalf("range smallest seqnum: got %d, want %d", got, base.SeqNumMax-1) + } + }) +} + +// TestSuffixMaskMiddleTableSize verifies the size computation for the middle +// table doesn't underflow when left + right size estimates exceed the original. +func TestSuffixMaskMiddleTableSize(t *testing.T) { + defer leaktest.AfterTest(t)() + + type testCase struct { + name string + originalSize uint64 + leftSize, rightSize uint64 + expectMiddleSize uint64 + } + for _, tc := range []testCase{ + {"normal", 1000, 300, 400, 300}, + {"exact", 1000, 500, 500, 1}, // left+right == original → clamp to 1 + {"overshoot", 1000, 600, 500, 1}, // left+right > original → clamp to 1 + {"left-only", 1000, 400, 0, 600}, + {"right-only", 1000, 0, 700, 300}, + {"both-zero", 1000, 0, 0, 1000}, + {"tiny-original", 1, 1, 1, 1}, // overshoot with tiny file + {"loose-bounds", 101, 51, 51, 1}, // (101+1)/2 = 51 each → overshoot + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expectMiddleSize, + middleTableSize(tc.originalSize, tc.leftSize+tc.rightSize)) + }) + } +} + +// TestDeleteSuffixRangeExciseOverlap exercises the exciseOverlap path in +// DeleteSuffixRange, which handles files with SyntheticSuffix. +func TestDeleteSuffixRangeExciseOverlap(t *testing.T) { + defer leaktest.AfterTest(t)() + + remoteStorage := remote.NewInMem() + opts := &Options{ + Comparer: &cockroachkvs.Comparer, + FormatMajorVersion: FormatSuffixMask, + FS: vfs.NewMem(), + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + DisableAutomaticCompactions: true, + } + opts.RemoteStorage = remote.MakeSimpleFactory(map[remote.Locator]remote.Storage{ + remote.MakeLocator("ext"): remoteStorage, + }) + d, err := Open("", opts) + require.NoError(t, err) + defer d.Close() + + // Write an SST with bare keys (no MVCC suffix). + writeOpts := d.opts.MakeWriterOptions(0, d.TableFormat()) + obj, err := remoteStorage.CreateObject("ext1") + require.NoError(t, err) + w := sstable.NewWriter(objstorageprovider.NewRemoteWritable(obj), writeOpts) + for _, rk := range []string{"bb", "cc"} { + require.NoError(t, w.Set(testMakeEngineKey([]byte(rk), 0, 0), []byte("val-"+rk))) + } + require.NoError(t, w.Close()) + + sz, err := remoteStorage.Size("ext1") + require.NoError(t, err) + + // Ingest with SyntheticSuffix at wall=50. + synthSuffix := testMakeSuffix(50, 0) + _, err = d.IngestExternalFiles(context.Background(), []ExternalFile{{ + Locator: remote.MakeLocator("ext"), + ObjName: "ext1", + Size: uint64(sz), + StartKey: testMakeEngineKey([]byte("bb"), 0, 0), + EndKey: testMakeEngineKey([]byte("cd"), 0, 0), + EndKeyIsInclusive: false, + HasPointKey: true, + SyntheticSuffix: synthSuffix, + }}) + require.NoError(t, err) + + // Verify keys are visible. + iter, err := d.NewIter(nil) + require.NoError(t, err) + var before int + for iter.First(); iter.Valid(); iter.Next() { + before++ + } + require.NoError(t, iter.Close()) + require.True(t, before > 0) + + // Test 1: fully-contained mask. Span covers entire file. + span := KeyRange{ + Start: testMakeEngineKey([]byte("a"), 0, 0), + End: testMakeEngineKey([]byte("z"), 0, 0), + } + require.NoError(t, d.DeleteSuffixRange(context.Background(), span, + testMakeSuffix(math.MaxUint64, 0), testMakeSuffix(10, 0))) + + iter, err = d.NewIter(nil) + require.NoError(t, err) + var after int + for iter.First(); iter.Valid(); iter.Next() { + after++ + } + require.NoError(t, iter.Close()) + require.Equal(t, 0, after) + + // Test 2: straddling mask. Re-ingest, then mask only part of the range. + obj2, err := remoteStorage.CreateObject("ext2") + require.NoError(t, err) + w2 := sstable.NewWriter(objstorageprovider.NewRemoteWritable(obj2), writeOpts) + for _, rk := range []string{"dd", "ff"} { + require.NoError(t, w2.Set(testMakeEngineKey([]byte(rk), 0, 0), []byte("val-"+rk))) + } + require.NoError(t, w2.Close()) + + sz2, err := remoteStorage.Size("ext2") + require.NoError(t, err) + _, err = d.IngestExternalFiles(context.Background(), []ExternalFile{{ + Locator: remote.MakeLocator("ext"), + ObjName: "ext2", + Size: uint64(sz2), + StartKey: testMakeEngineKey([]byte("dd"), 0, 0), + EndKey: testMakeEngineKey([]byte("fg"), 0, 0), + EndKeyIsInclusive: false, + HasPointKey: true, + SyntheticSuffix: synthSuffix, + }}) + require.NoError(t, err) + + // Verify both keys visible. + iter, err = d.NewIter(nil) + require.NoError(t, err) + var beforeStraddle []string + for iter.First(); iter.Valid(); iter.Next() { + beforeStraddle = append(beforeStraddle, string(iter.Value())) + } + require.NoError(t, iter.Close()) + t.Logf("before straddle mask: %v", beforeStraddle) + + // Mask only [dd, ee) — straddles the file [dd, ff]. + straddleSpan := KeyRange{ + Start: testMakeEngineKey([]byte("dd"), 0, 0), + End: testMakeEngineKey([]byte("ee"), 0, 0), + } + require.NoError(t, d.DeleteSuffixRange(context.Background(), straddleSpan, + testMakeSuffix(math.MaxUint64, 0), testMakeSuffix(10, 0))) + + // dd should be excised; ff should remain. + iter, err = d.NewIter(nil) + require.NoError(t, err) + var afterStraddle []string + for iter.First(); iter.Valid(); iter.Next() { + afterStraddle = append(afterStraddle, string(iter.Value())) + } + require.NoError(t, iter.Close()) + t.Logf("after straddle mask: %v", afterStraddle) + require.Equal(t, []string{"val-ff"}, afterStraddle) +} + +// TestLooseExciseTableBoundsSkipDisjointKeyType is a regression test for a bug +// in `looseLeftTableBounds` / `looseRightTableBounds`: when the original +// table's range keys (or point keys) sit entirely inside the excise span, the +// helpers were still calling `ExtendRangeKeyBounds` / `ExtendPointKeyBounds` +// on the left / right virtual table with a clamped largest (or smallest) that +// sorts before (or after) the original's preserved smallest (or largest). +// That produced a table with `smallest > largest` and tripped +// `TableMetadata.Validate`'s inconsistent-bounds check, surfaced by the +// metamorphic test as `file NNN has inconsistent range key bounds`. +// +// The fix gates each key type's contribution on whether keys of that type +// actually extend past the excise boundary in the relevant direction. This +// test pins both the left and right helpers across point keys and range keys +// using the cases that triggered the metamorphic failure (range keys / point +// keys entirely inside the excise span). +func TestLooseExciseTableBoundsSkipDisjointKeyType(t *testing.T) { + defer leaktest.AfterTest(t)() + + cmp := base.DefaultComparer.Compare + // Original table: range keys at [c, d), point keys at [c, d]. + original := &manifest.TableMetadata{} + original.ExtendPointKeyBounds(cmp, + base.MakeInternalKey([]byte("c"), 1, base.InternalKeyKindSet), + base.MakeInternalKey([]byte("d"), 1, base.InternalKeyKindSet), + ) + original.ExtendRangeKeyBounds(cmp, manifest.AnyRangeKeys, + base.MakeInternalKey([]byte("c"), 2, base.InternalKeyKindRangeKeySet), + base.MakeExclusiveSentinelKey(base.InternalKeyKindRangeKeySet, []byte("d")), + ) + + // looseLeftTableBounds with exciseSpanStart="b": both point and range + // keys start at or after "b", so neither extends to the left. The left + // table must have no point keys and no range keys. + t.Run("looseLeftTableBounds/disjoint", func(t *testing.T) { + var left manifest.TableMetadata + looseLeftTableBounds(cmp, original, &left, []byte("b")) + if left.HasPointKeys { + t.Errorf("left table should not have point keys; got %s..%s", + left.PointKeyBounds.Smallest(), left.PointKeyBounds.Largest()) + } + if left.HasRangeKeys { + t.Errorf("left table should not have range keys; got %s..%s", + left.RangeKeyBounds.Smallest(), left.RangeKeyBounds.Largest()) + } + }) + + // looseRightTableBounds with exciseSpanEnd="e": both point and range + // keys end at or before "e", so neither extends to the right. The right + // table must have no point keys and no range keys. + t.Run("looseRightTableBounds/disjoint", func(t *testing.T) { + var right manifest.TableMetadata + looseRightTableBounds(cmp, original, &right, []byte("e")) + if right.HasPointKeys { + t.Errorf("right table should not have point keys; got %s..%s", + right.PointKeyBounds.Smallest(), right.PointKeyBounds.Largest()) + } + if right.HasRangeKeys { + t.Errorf("right table should not have range keys; got %s..%s", + right.RangeKeyBounds.Smallest(), right.RangeKeyBounds.Largest()) + } + }) + + // Where one key type extends and the other does not, only the extending + // type should populate the resulting table. Use a fresh original where + // point keys extend left of "b" but range keys do not. + t.Run("looseLeftTableBounds/mixed", func(t *testing.T) { + mixed := &manifest.TableMetadata{} + mixed.ExtendPointKeyBounds(cmp, + base.MakeInternalKey([]byte("a"), 1, base.InternalKeyKindSet), + base.MakeInternalKey([]byte("d"), 1, base.InternalKeyKindSet), + ) + mixed.ExtendRangeKeyBounds(cmp, manifest.AnyRangeKeys, + base.MakeInternalKey([]byte("c"), 2, base.InternalKeyKindRangeKeySet), + base.MakeExclusiveSentinelKey(base.InternalKeyKindRangeKeySet, []byte("d")), + ) + var left manifest.TableMetadata + looseLeftTableBounds(cmp, mixed, &left, []byte("b")) + if !left.HasPointKeys { + t.Error("left table should have point keys (point keys extend left of b)") + } + if left.HasRangeKeys { + t.Errorf("left table should not have range keys; got %s..%s", + left.RangeKeyBounds.Smallest(), left.RangeKeyBounds.Largest()) + } + // The point bounds, if present, must be well-ordered (smallest + // <= largest). The TableBacking is not set here so we can't run + // the full Validate; assert the bounds directly. + if left.HasPointKeys && base.InternalCompare(cmp, + left.PointKeyBounds.Smallest(), left.PointKeyBounds.Largest()) > 0 { + t.Fatalf("left table has inconsistent point bounds: %s vs %s", + left.PointKeyBounds.Smallest(), left.PointKeyBounds.Largest()) + } + }) + t.Run("looseRightTableBounds/mixed", func(t *testing.T) { + mixed := &manifest.TableMetadata{} + mixed.ExtendPointKeyBounds(cmp, + base.MakeInternalKey([]byte("c"), 1, base.InternalKeyKindSet), + base.MakeInternalKey([]byte("h"), 1, base.InternalKeyKindSet), + ) + mixed.ExtendRangeKeyBounds(cmp, manifest.AnyRangeKeys, + base.MakeInternalKey([]byte("c"), 2, base.InternalKeyKindRangeKeySet), + base.MakeExclusiveSentinelKey(base.InternalKeyKindRangeKeySet, []byte("d")), + ) + var right manifest.TableMetadata + looseRightTableBounds(cmp, mixed, &right, []byte("e")) + if !right.HasPointKeys { + t.Error("right table should have point keys (point keys extend right of e)") + } + if right.HasRangeKeys { + t.Errorf("right table should not have range keys; got %s..%s", + right.RangeKeyBounds.Smallest(), right.RangeKeyBounds.Largest()) + } + if right.HasPointKeys && base.InternalCompare(cmp, + right.PointKeyBounds.Smallest(), right.PointKeyBounds.Largest()) > 0 { + t.Fatalf("right table has inconsistent point bounds: %s vs %s", + right.PointKeyBounds.Smallest(), right.PointKeyBounds.Largest()) + } + }) +} + +// TestDetermineExcisedTableBlobReferencesCapped is a regression test for a +// bug in `determineExcisedTableBlobReferences`: the scaling factor +// `excisedTable.Size / originalSize` can exceed 1 when size estimates diverge +// (e.g. the excised virtual is sized by `fc.estimateSize` while the original +// virtual's size was clamped to `1` by `determineExcisedTableSize` on a zero +// estimate, or loose-bound halving causes drift across repeated splits). +// Without a cap, the scaled `ValueSize` could exceed the physical blob file's +// `ValueSize`, tripping `MakeBlobReference`'s invariant check on manifest +// replay (`blob reference value size N > blob file's value size M`). +// +// The fix caps the per-reference scaled `ValueSize` at the original blob +// reference's `ValueSize` (excised is a subset of the original, so its share +// cannot exceed the original's). This test pins the cap with the smallest +// reproducer of the bug — an original blob reference of `ValueSize=14` and +// scaling ratios that, naïvely, produce values larger than `14`. +func TestDetermineExcisedTableBlobReferencesCapped(t *testing.T) { + defer leaktest.AfterTest(t)() + + type testCase struct { + name string + originalRefVS uint64 + originalSize uint64 + excisedSize uint64 + expectExcisVS uint64 + } + for _, tc := range []testCase{ + // Estimate noise: excisedSize > originalSize. Without the cap the + // scaled value size would exceed the physical blob file's value + // size and trip MakeBlobReference's invariant. With the cap it is + // clamped to the original ref's value size (the upper bound of any + // subset's share). + {"excised-bigger", 14, 10, 100, 14}, + // Pathological: the originalSize was clamped to 1 by a prior excise + // (e.g. determineExcisedTableSize's zero-estimate guard), so any + // non-trivial excisedSize multiplies the value size by orders of + // magnitude. The cap keeps it at the original ref's value size. + {"original-clamped-to-one", 14, 1, 50, 14}, + // Normal subset: excisedSize < originalSize, scale down. + {"subset-half", 100, 1000, 500, 50}, + // Subset that scales to zero: clamp to 1. + {"subset-tiny", 100, 1000, 1, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + originalRefs := manifest.BlobReferences{{ + FileID: base.BlobFileID(1), + ValueSize: tc.originalRefVS, + }} + excised := &manifest.TableMetadata{Size: tc.excisedSize} + determineExcisedTableBlobReferences(originalRefs, tc.originalSize, excised, FormatSuffixMask) + if len(excised.BlobReferences) != 1 { + t.Fatalf("expected 1 blob reference, got %d", len(excised.BlobReferences)) + } + got := excised.BlobReferences[0].ValueSize + if got != tc.expectExcisVS { + t.Errorf("ValueSize: got %d, want %d", got, tc.expectExcisVS) + } + if got > tc.originalRefVS { + t.Errorf("scaled ValueSize %d exceeds original ref ValueSize %d "+ + "(would trip MakeBlobReference invariant on manifest replay)", + got, tc.originalRefVS) + } + }) + } +} diff --git a/suffix_mask_sst_test.go b/suffix_mask_sst_test.go new file mode 100644 index 00000000000..3e59ceed2cd --- /dev/null +++ b/suffix_mask_sst_test.go @@ -0,0 +1,486 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +// Suffix-mask tests: SST-direct iteration. These tests build SSTs with +// sstable.Writer and exercise suffix masking through the sstable.Reader +// iterator API (no DB involved), covering point keys, range keys, forward +// and backward iteration, the columnar vs row-block parity, the fallback +// (non-SuffixMaskChecker) path, and the no-keys-masked optimization. + +package pebble + +import ( + "context" + "encoding/binary" + "fmt" + "math" + "slices" + "testing" + + "github.com/cockroachdb/crlib/testutils/leaktest" + "github.com/cockroachdb/crlib/testutils/require" + "github.com/cockroachdb/pebble/cockroachkvs" + "github.com/cockroachdb/pebble/internal/base" + "github.com/cockroachdb/pebble/internal/testkeys" + "github.com/cockroachdb/pebble/objstorage" + "github.com/cockroachdb/pebble/objstorage/objstorageprovider" + "github.com/cockroachdb/pebble/sstable" + "github.com/cockroachdb/pebble/sstable/colblk" + "github.com/cockroachdb/pebble/vfs" +) + +// TestSuffixMaskRangeKeys exercises suffix masking on range key entries in +// both columnar (pebblev5) and row-oriented (pebblev4) table formats. +func TestSuffixMaskRangeKeys(t *testing.T) { + defer leaktest.AfterTest(t)() + + for _, format := range []struct { + name string + format sstable.TableFormat + }{ + {"columnar", sstable.TableFormatPebblev5}, + {"rowblk", sstable.TableFormatPebblev4}, + } { + t.Run(format.name, func(t *testing.T) { + testSuffixMaskRangeKeys(t, format.format) + }) + } +} + +func testSuffixMaskRangeKeys(t *testing.T, tableFormat sstable.TableFormat) { + comparer := &cockroachkvs.Comparer + fs := vfs.NewMem() + + f, err := fs.Create("test.sst", vfs.WriteCategoryUnspecified) + require.NoError(t, err) + + writerOpts := sstable.WriterOptions{ + Comparer: comparer, + TableFormat: tableFormat, + } + if tableFormat.BlockColumnar() { + writerOpts.KeySchema = &cockroachkvs.KeySchema + } + w := sstable.NewWriter(objstorageprovider.NewFileWritable(f), writerOpts) + + start := testMakeEngineKey([]byte("a"), 0, 0) + end := testMakeEngineKey([]byte("z"), 0, 0) + for _, wall := range []uint64{200, 150, 100, 80, 50} { + require.NoError(t, w.RangeKeySet(start, end, testMakeSuffix(wall, 0), []byte(fmt.Sprintf("val@%d", wall)))) + } + require.NoError(t, w.Close()) + + f2, err := fs.Open("test.sst") + require.NoError(t, err) + readable, err := objstorage.NewSimpleReadable(f2) + require.NoError(t, err) + readerOpts := sstable.ReaderOptions{Comparer: comparer} + if tableFormat.BlockColumnar() { + readerOpts.KeySchemas = sstable.MakeKeySchemas(&cockroachkvs.KeySchema) + } + reader, err := sstable.NewReader(context.Background(), readable, readerOpts) + require.NoError(t, err) + defer reader.Close() + + transforms := sstable.FragmentIterTransforms{ + SuffixMasks: []sstable.SuffixMask{{ + Lower: testMakeSuffix(math.MaxUint64, 0), + Upper: testMakeSuffix(100, 0), + }}, + } + + iter, err := reader.NewRawRangeKeyIter(context.Background(), transforms, sstable.NoReadEnv) + require.NoError(t, err) + require.True(t, iter != nil) + defer iter.Close() + + type visibleEntry struct { + wallTime uint64 + value string + } + var visible []visibleEntry + for span, err := iter.First(); span != nil; span, err = iter.Next() { + require.NoError(t, err) + for _, k := range span.Keys { + if len(k.Suffix) == 0 { + continue + } + wall := binary.BigEndian.Uint64(k.Suffix[:8]) + visible = append(visible, visibleEntry{wall, string(k.Value)}) + } + } + + t.Logf("visible range key entries:") + for _, v := range visible { + t.Logf(" wall=%d value=%s", v.wallTime, v.value) + } + + // Entries at wall=200 and wall=150 should be masked (wall > 100). + // Entries at wall=100, wall=80, wall=50 should remain visible. + expectedWallTimes := []uint64{100, 80, 50} + var gotWallTimes []uint64 + for _, v := range visible { + gotWallTimes = append(gotWallTimes, v.wallTime) + } + + t.Logf("expected wall times: %v", expectedWallTimes) + t.Logf("got wall times: %v", gotWallTimes) + require.Equal(t, len(expectedWallTimes), len(gotWallTimes)) + for i := range expectedWallTimes { + require.Equal(t, expectedWallTimes[i], gotWallTimes[i]) + } +} + +// TestSuffixMaskRangeKeysBackward exercises backward iteration (Last/Prev) through +// range key spans with suffix masking, in both columnar and rowblk formats. +func TestSuffixMaskRangeKeysBackward(t *testing.T) { + defer leaktest.AfterTest(t)() + for _, format := range []struct { + name string + format sstable.TableFormat + }{ + {"columnar", sstable.TableFormatPebblev5}, + {"rowblk", sstable.TableFormatPebblev4}, + } { + t.Run(format.name, func(t *testing.T) { + testSuffixMaskRangeKeysBackward(t, format.format) + }) + } +} + +func testSuffixMaskRangeKeysBackward(t *testing.T, tableFormat sstable.TableFormat) { + comparer := &cockroachkvs.Comparer + fs := vfs.NewMem() + f, err := fs.Create("test.sst", vfs.WriteCategoryUnspecified) + require.NoError(t, err) + writerOpts := sstable.WriterOptions{Comparer: comparer, TableFormat: tableFormat} + if tableFormat.BlockColumnar() { + writerOpts.KeySchema = &cockroachkvs.KeySchema + } + w := sstable.NewWriter(objstorageprovider.NewFileWritable(f), writerOpts) + type sd struct{ s, e string } + for _, sp := range []sd{{"a", "b"}, {"c", "d"}, {"e", "f"}} { + for _, wall := range []uint64{200, 150, 100, 50} { + require.NoError(t, w.RangeKeySet( + testMakeEngineKey([]byte(sp.s), 0, 0), + testMakeEngineKey([]byte(sp.e), 0, 0), + testMakeSuffix(wall, 0), + []byte(fmt.Sprintf("%s@%d", sp.s, wall)), + )) + } + } + require.NoError(t, w.Close()) + f2, err := fs.Open("test.sst") + require.NoError(t, err) + readable, err := objstorage.NewSimpleReadable(f2) + require.NoError(t, err) + readerOpts := sstable.ReaderOptions{Comparer: comparer} + if tableFormat.BlockColumnar() { + readerOpts.KeySchemas = sstable.MakeKeySchemas(&cockroachkvs.KeySchema) + } + reader, err := sstable.NewReader(context.Background(), readable, readerOpts) + require.NoError(t, err) + defer reader.Close() + transforms := sstable.FragmentIterTransforms{ + SuffixMasks: []sstable.SuffixMask{{Lower: testMakeSuffix(math.MaxUint64, 0), Upper: testMakeSuffix(100, 0)}}, + } + iter, err := reader.NewRawRangeKeyIter(context.Background(), transforms, sstable.NoReadEnv) + require.NoError(t, err) + require.True(t, iter != nil) + defer iter.Close() + type result struct { + startKey byte + wallTimes []uint64 + } + var results []result + for span, err := iter.Last(); span != nil; span, err = iter.Prev() { + require.NoError(t, err) + r := result{startKey: span.Start[0]} + for _, k := range span.Keys { + if len(k.Suffix) > 0 { + r.wallTimes = append(r.wallTimes, binary.BigEndian.Uint64(k.Suffix[:8])) + } + } + results = append(results, r) + } + t.Logf("backward iteration results:") + for _, r := range results { + t.Logf(" start=%c wallTimes=%v", r.startKey, r.wallTimes) + } + require.Equal(t, 3, len(results)) + for i, expected := range []byte{'e', 'c', 'a'} { + require.Equal(t, expected, results[i].startKey) + require.Equal(t, []uint64{100, 50}, results[i].wallTimes) + } +} + +// TestSuffixMaskColblkRowblkParity asserts that the columnar +// (pebblev5) IsMaskedBySuffixMask fast path and the row-oriented (pebblev4) +// ComparePointSuffixes-based fallback agree on which keys to mask for the +// same input. A divergence would mean the two formats expose different +// "visible" sets after DeleteSuffixRange. +func TestSuffixMaskColblkRowblkParity(t *testing.T) { + defer leaktest.AfterTest(t)() + + comparer := &cockroachkvs.Comparer + + type entry struct { + roachKey string + wall uint64 + value string + } + entries := []entry{ + {"a", 200, "a@200"}, + {"a", 150, "a@150"}, + {"a", 100, "a@100"}, + {"a", 80, "a@80"}, + {"a", 50, "a@50"}, + } + + lower := testMakeSuffix(math.MaxUint64, 0) + upper := testMakeSuffix(100, 0) + transforms := sstable.IterTransforms{ + SuffixMasks: []sstable.SuffixMask{{Lower: lower, Upper: upper}}, + } + + // writeSST writes an SST with the given table format and returns the + // visible keys after applying the suffix mask. + writeSST := func(t *testing.T, format sstable.TableFormat) []string { + t.Helper() + fs := vfs.NewMem() + f, err := fs.Create("test.sst", vfs.WriteCategoryUnspecified) + require.NoError(t, err) + + writerOpts := sstable.WriterOptions{ + Comparer: comparer, + TableFormat: format, + } + if format >= sstable.TableFormatPebblev5 { + writerOpts.KeySchema = &cockroachkvs.KeySchema + } + w := sstable.NewWriter(objstorageprovider.NewFileWritable(f), writerOpts) + for _, e := range entries { + key := testMakeEngineKey([]byte(e.roachKey), e.wall, 0) + require.NoError(t, w.Set(key, []byte(e.value))) + } + // Unversioned key. + ukey := testMakeEngineKey([]byte("c"), 0, 0) + require.NoError(t, w.Set(ukey, []byte("c-unversioned"))) + require.NoError(t, w.Close()) + + f2, err := fs.Open("test.sst") + require.NoError(t, err) + readable, err := objstorage.NewSimpleReadable(f2) + require.NoError(t, err) + reader, err := sstable.NewReader(context.Background(), readable, sstable.ReaderOptions{ + Comparer: comparer, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + }) + require.NoError(t, err) + defer reader.Close() + + iter, err := reader.NewPointIter(context.Background(), sstable.IterOptions{ + Transforms: transforms, + }) + require.NoError(t, err) + + var visible []string + for kv := iter.First(); kv != nil; kv = iter.Next() { + v, _, err := kv.V.Value(nil) + require.NoError(t, err) + visible = append(visible, string(v)) + } + require.NoError(t, iter.Close()) + return visible + } + + v5Visible := writeSST(t, sstable.TableFormatPebblev5) + v4Visible := writeSST(t, sstable.TableFormatPebblev4) + + t.Logf("pebblev5 (columnar) visible keys: %v", v5Visible) + t.Logf("pebblev4 (rowblk) visible keys: %v", v4Visible) + + // Both formats should produce the same set of visible keys. If they + // disagree, the optimized and fallback masking logic are inconsistent. + require.Equal(t, v5Visible, v4Visible) +} + +// TestSuffixMaskFallbackPath exercises the columnar DataBlockIter fallback path +// for suffix masking. When a KeySeeker does NOT implement the optional +// SuffixMaskChecker interface, isSuffixMasked() materializes the key and uses: +// +// suffixCmp(suffix, lower) > 0 && suffixCmp(suffix, upper) <= 0 +// +// where suffixCmp is ComparePointSuffixes. For MVCC-style comparers (including +// testkeys.Comparer), ComparePointSuffixes sorts timestamps in REVERSE order +// (bigger timestamp = smaller comparison result). This means the condition is +// backwards: it masks keys with small timestamps instead of large ones. +// +// This test forces the fallback path by using DefaultKeySchema (whose +// defaultKeySeeker does NOT implement SuffixMaskChecker) with testkeys.Comparer +// and pebblev5 (columnar) format. +func TestSuffixMaskFallbackPath(t *testing.T) { + defer leaktest.AfterTest(t)() + + comparer := testkeys.Comparer + keySchema := colblk.DefaultKeySchema(comparer, 16) + + // Keys in testkeys format: prefix@suffix where suffix is a decimal integer. + // testkeys.Comparer.ComparePointSuffixes sorts larger integers first + // (reverse order), matching MVCC semantics. + type entry struct { + key string + value string + } + entries := []entry{ + {"a@200", "a@200"}, + {"a@100", "a@100"}, + {"a@50", "a@50"}, + {"b@150", "b@150"}, + {"b@80", "b@80"}, + } + + // SuffixMask [Lower, Upper) in comparer order. testkeys.Comparer sorts + // larger integers first, so Lower=@999 (newest, inclusive) and + // Upper=@100 (oldest, exclusive). This masks keys with timestamps in + // (100, 999] in magnitude — a@200 and b@150 are masked. + lower := []byte("@999") + upper := []byte("@100") + + fs := vfs.NewMem() + f, err := fs.Create("test.sst", vfs.WriteCategoryUnspecified) + require.NoError(t, err) + + writerOpts := sstable.WriterOptions{ + Comparer: comparer, + KeySchema: &keySchema, + TableFormat: sstable.TableFormatPebblev5, + } + w := sstable.NewWriter(objstorageprovider.NewFileWritable(f), writerOpts) + for _, e := range entries { + require.NoError(t, w.Set([]byte(e.key), []byte(e.value))) + } + // Unversioned key (no suffix). + require.NoError(t, w.Set([]byte("c"), []byte("c-unversioned"))) + require.NoError(t, w.Close()) + + f2, err := fs.Open("test.sst") + require.NoError(t, err) + readable, err := objstorage.NewSimpleReadable(f2) + require.NoError(t, err) + reader, err := sstable.NewReader(context.Background(), readable, sstable.ReaderOptions{ + Comparer: comparer, + KeySchemas: sstable.MakeKeySchemas(&keySchema), + }) + require.NoError(t, err) + defer reader.Close() + + transforms := sstable.IterTransforms{ + SuffixMasks: []sstable.SuffixMask{{Lower: lower, Upper: upper}}, + } + iter, err := reader.NewPointIter(context.Background(), sstable.IterOptions{ + Transforms: transforms, + }) + require.NoError(t, err) + + var visible []string + for kv := iter.First(); kv != nil; kv = iter.Next() { + v, _, err := kv.V.Value(nil) + require.NoError(t, err) + visible = append(visible, string(v)) + } + require.NoError(t, iter.Close()) + + t.Logf("visible keys: %v", visible) + + expectedVisible := []string{"a@100", "a@50", "b@80", "c-unversioned"} + require.Equal(t, expectedVisible, visible) + + // Also verify backward iteration to cover skipSuffixMaskedBackward. + iter, err = reader.NewPointIter(context.Background(), sstable.IterOptions{ + Transforms: transforms, + }) + require.NoError(t, err) + var backward []string + for kv := iter.Last(); kv != nil; kv = iter.Prev() { + v, _, err := kv.V.Value(nil) + require.NoError(t, err) + backward = append(backward, string(v)) + } + require.NoError(t, iter.Close()) + slices.Reverse(backward) + require.Equal(t, expectedVisible, backward) +} + +// TestSuffixMaskNoKeysMasked verifies that when NO keys in the SST fall in the +// mask range (all wall times <= 100), all keys are visible through every +// iteration method. This exercises the optimized columnar IsMaskedBySuffixMask +// path. +func TestSuffixMaskNoKeysMasked(t *testing.T) { + defer leaktest.AfterTest(t)() + + entries := []suffixMaskTestEntry{ + {"a", 100, "a@100"}, + {"a", 50, "a@50"}, + {"b", 80, "b@80"}, + {"c", 0, "c-unversioned"}, + } + reader := suffixMaskTestSST(t, entries) + defer reader.Close() + transforms := suffixMaskTestTransforms() + + // Forward iteration: all keys should be visible. + fwdIter, err := reader.NewPointIter(context.Background(), sstable.IterOptions{ + Transforms: transforms, + }) + require.NoError(t, err) + var fwd []string + for kv := fwdIter.First(); kv != nil; kv = fwdIter.Next() { + v, _, err := kv.V.Value(nil) + require.NoError(t, err) + fwd = append(fwd, string(v)) + } + require.NoError(t, fwdIter.Close()) + + expectedFwd := []string{"a@100", "a@50", "b@80", "c-unversioned"} + require.Equal(t, expectedFwd, fwd) + + // Backward iteration: all keys should be visible in reverse. + bwdIter, err := reader.NewPointIter(context.Background(), sstable.IterOptions{ + Transforms: transforms, + }) + require.NoError(t, err) + var bwd []string + for kv := bwdIter.Last(); kv != nil; kv = bwdIter.Prev() { + v, _, err := kv.V.Value(nil) + require.NoError(t, err) + bwd = append(bwd, string(v)) + } + require.NoError(t, bwdIter.Close()) + + expectedBwd := []string{"c-unversioned", "b@80", "a@50", "a@100"} + require.Equal(t, expectedBwd, bwd) + + // SeekGE to first key — should land on it. + seekIter, err := reader.NewPointIter(context.Background(), sstable.IterOptions{ + Transforms: transforms, + }) + require.NoError(t, err) + defer seekIter.Close() + + seekKey := testMakeEngineKey([]byte("a"), 100, 0) + kv := seekIter.SeekGE(seekKey, base.SeekGEFlagsNone) + require.True(t, kv != nil) + v, _, err := kv.V.Value(nil) + require.NoError(t, err) + require.Equal(t, "a@100", string(v)) + + // SeekLT past the end — should find the last key. + seekKey = testMakeEngineKey([]byte("z"), 0, 0) + kv = seekIter.SeekLT(seekKey, base.SeekLTFlagsNone) + require.True(t, kv != nil) + v, _, err = kv.V.Value(nil) + require.NoError(t, err) + require.Equal(t, "c-unversioned", string(v)) +} diff --git a/suffix_mask_test.go b/suffix_mask_test.go new file mode 100644 index 00000000000..72d628f7166 --- /dev/null +++ b/suffix_mask_test.go @@ -0,0 +1,1106 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +// TestDeleteSuffixRangeOracle is an end-to-end integration test: it +// confirms that the iterator stack, manifest edits, virtual-table +// construction, blob-reference attribution, file-cache invalidation, and +// compaction output all combine to produce the user-visible behavior +// `DeleteSuffixRange` specifies. Lower-level units have their own +// targeted tests (see the index below); this test exists to catch any +// composition bug those isolated tests would miss. +// +// The "oracle" is just a dumb in-memory map used as the specification: +// a DeleteSuffixRange call with span `[lo, hi)` and mask +// `(maskOldest, maskNewest]` means "for every key whose prefix lies in +// `[lo, hi)` and whose wall time lies in `(maskOldest, maskNewest]`, +// mark it deleted." Subsequent Set at a key reinstates it. +// +// The test interleaves random Sets, Flushes, Compactions, and +// DeleteSuffixRange calls, re-scanning the DB and the oracle after every +// few operations (and at the end of each trial) and asserting equality. +// Any divergence indicates a composition bug somewhere in the mask +// machinery. +// +// This file holds the integration test (TestDeleteSuffixRangeOracle), the +// random-input helpers it needs, and most of the focused DSR tests added in +// the same series — input validation, the expandSuffixMask helper, data- +// driven scenarios under testdata/delete_suffix_range, the BPF skip +// optimization, the SyntheticSuffix + empty-suffix-range-key regression, +// and the wire-format round-trip for SuffixMasks on ExternalFile/ +// SharedSSTMeta. +// +// A few concerns live in separate files because their content is large or +// stands cleanly on its own: +// +// - suffix_mask_excise_test.go Excise / file-splitting / middle- +// table bounds and size; SyntheticSuffix +// excise path. +// - suffix_mask_compaction_test.go DSR + compaction interactions +// (cancellation, post-compaction mask +// clearing). +// - suffix_mask_sst_test.go SST-direct iteration: range keys +// forward/backward, columnar vs rowblk +// parity, fallback path, no-keys-masked +// optimization. +// - suffix_mask_testutils_test.go Shared helpers: key/suffix encoding, +// SST/DB builders, oracle key parsing. + +package pebble + +import ( + "bytes" + "context" + "fmt" + "math" + "math/rand" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/cockroachdb/crlib/crstrings" + "github.com/cockroachdb/crlib/testutils/leaktest" + "github.com/cockroachdb/crlib/testutils/require" + "github.com/cockroachdb/datadriven" + "github.com/cockroachdb/pebble/cockroachkvs" + "github.com/cockroachdb/pebble/internal/base" + "github.com/cockroachdb/pebble/internal/invariants" + "github.com/cockroachdb/pebble/internal/keyspan" + "github.com/cockroachdb/pebble/internal/manifest" + "github.com/cockroachdb/pebble/internal/testkeys" + "github.com/cockroachdb/pebble/objstorage/objstorageprovider" + "github.com/cockroachdb/pebble/objstorage/remote" + "github.com/cockroachdb/pebble/sstable" + "github.com/cockroachdb/pebble/vfs" +) + +// TestDeleteSuffixRangeOracle is the headline correctness test for +// DeleteSuffixRange. It interleaves random Sets, Flushes, Compactions, and +// DeleteSuffixRange calls, and after each operation verifies that the DB's +// observable point keys match an in-memory oracle. +// +// The oracle is "dumb": it maintains a map from raw engine key to latest +// value, and on DeleteSuffixRange it marks every matching entry as deleted. +// This is the natural specification of the operation, and any divergence +// between the oracle and the real DB indicates a correctness bug somewhere +// in the mask machinery (manifest edit, virtual table construction, blob +// reference attribution, row-level filter, range-key filter, file-cache +// invalidation, or compaction output). +// +// The test would have caught the IsExclusiveSentinel boundary bug and would +// catch any future regression in iterator visibility under masks. +func TestDeleteSuffixRangeOracle(t *testing.T) { + defer leaktest.AfterTest(t)() + + seed := int64(time.Now().UnixNano()) + t.Logf("seed: %d", seed) + + // With multi-mask support DSR always succeeds (no disjoint skip branch), + // so each trial exercises many disjoint mask scenarios that accumulate + // per file. Increased trials/ops density catches regressions. + const trials = 12 + const opsPerTrial = 80 + + for trial := 0; trial < trials; trial++ { + t.Run(fmt.Sprintf("trial%d", trial), func(t *testing.T) { + rng := rand.New(rand.NewSource(seed + int64(trial))) + + db, _ := suffixMaskTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + cmp := cockroachkvs.Comparer.Compare + + // Build a random pool of distinct roach prefixes. + prefixes := func() []string { + n := 4 + rng.Intn(6) + seen := map[string]struct{}{} + for len(seen) < n { + p := string(rune('a'+rng.Intn(8))) + string(rune('a'+rng.Intn(8))) + seen[p] = struct{}{} + } + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + slices.Sort(out) + return out + }() + // Bracket past the end of the prefix space so span.End is always + // > any used prefix and we don't have to special-case the last + // element. + endBracket := string(rune('z')) + + // Oracle: map from raw engine key (as string) -> latest value. + // Empty value means the key is currently deleted (either via a + // DeleteSuffixRange that matched it, or because we never wrote + // it). A subsequent Set replaces an empty value with the new + // value, modelling that a write after a mask is visible. + type oracleVal struct{ value string } + oracle := map[string]oracleVal{} + + randSuffix := func() uint64 { return uint64(1 + rng.Intn(100)) } + randPrefix := func() string { return prefixes[rng.Intn(len(prefixes))] } + // randSpan returns a [start, end) pair drawn from the prefix pool. + randSpan := func() ([]byte, []byte) { + lo := rng.Intn(len(prefixes)) + var hiPrefix string + if hi := lo + 1 + rng.Intn(len(prefixes)-lo); hi >= len(prefixes) { + hiPrefix = endBracket + } else { + hiPrefix = prefixes[hi] + } + return testMakeEngineKey([]byte(prefixes[lo]), 0, 0), + testMakeEngineKey([]byte(hiPrefix), 0, 0) + } + + scanDB := func() []string { + iter, err := db.NewIter(nil) + require.NoError(t, err) + defer func() { require.NoError(t, iter.Close()) }() + var got []string + for iter.First(); iter.Valid(); iter.Next() { + got = append(got, string(iter.Value())) + } + return got + } + expectedScan := func() []string { + type kv struct { + key, value []byte + } + var kvs []kv + for k, v := range oracle { + if v.value == "" { + continue + } + kvs = append(kvs, kv{key: []byte(k), value: []byte(v.value)}) + } + slices.SortFunc(kvs, func(a, b kv) int { return cmp(a.key, b.key) }) + out := make([]string, len(kvs)) + for i, p := range kvs { + out[i] = string(p.value) + } + return out + } + validate := func(after string) { + t.Helper() + want := expectedScan() + got := scanDB() + if !slices.Equal(got, want) { + t.Fatalf("scan mismatch %s\n got: %v\nwant: %v", after, got, want) + } + } + + for op := 0; op < opsPerTrial; op++ { + switch r := rng.Intn(10); { + case r < 5: + // Set. + p := randPrefix() + wall := randSuffix() + value := fmt.Sprintf("%s@%d#op%d", p, wall, op) + key := testMakeEngineKey([]byte(p), wall, 0) + require.NoError(t, db.Set(key, []byte(value), nil)) + oracle[string(key)] = oracleVal{value: value} + case r == 5: + require.NoError(t, db.Flush()) + case r == 6: + // Best-effort compact; the underlying call may error if + // the range has no data and that's fine here. + lo, hi := randSpan() + _ = db.Compact(context.Background(), lo, hi, false) + default: + // DeleteSuffixRange. Pick the suffix bounds as two random + // wall times; the API takes (newest, oldest) in comparer + // order, which is (larger, smaller) in numeric order. + a, b := randSuffix(), randSuffix() + if a == b { + continue + } + maskOldest, maskNewest := a, b + if maskOldest > maskNewest { + maskOldest, maskNewest = maskNewest, maskOldest + } + lo, hi := randSpan() + span := KeyRange{Start: lo, End: hi} + err := db.DeleteSuffixRange( + context.Background(), span, + testMakeSuffix(maskNewest, 0), + testMakeSuffix(maskOldest, 0), + ) + require.NoError(t, err) + // Apply to oracle: any visible key whose prefix is in + // [lo, hi) and whose wall is in (maskOldest, maskNewest] + // in magnitude becomes deleted. + for k, v := range oracle { + if v.value == "" { + continue + } + kb := []byte(k) + prefix := kb[:cockroachkvs.Split(kb)] + if cmp(prefix, span.Start) < 0 || cmp(prefix, span.End) >= 0 { + continue + } + wall, _ := parseEngineKeyWallLogical(kb) + if wall > maskOldest && wall <= maskNewest { + oracle[k] = oracleVal{} // tombstoned + } + } + } + // Spot-check every few ops to keep runtime down; full check + // at the end. + if op%5 == 4 { + validate(fmt.Sprintf("after op %d", op)) + } + } + validate("final") + }) + } +} + +// TestDeleteSuffixRangeValidation covers the early error/validation paths of +// DeleteSuffixRange: empty bounds, invalid key range, ReadOnly, FMV gating. +func TestDeleteSuffixRangeValidation(t *testing.T) { + defer leaktest.AfterTest(t)() + + validSpan := KeyRange{ + Start: testMakeEngineKey([]byte("a"), 0, 0), + End: testMakeEngineKey([]byte("z"), 0, 0), + } + lower := testMakeSuffix(math.MaxUint64, 0) + upper := testMakeSuffix(100, 0) + baseOpts := func() *Options { + return &Options{ + Comparer: &cockroachkvs.Comparer, + FormatMajorVersion: FormatSuffixMask, + FS: vfs.NewMem(), + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + } + } + + // errSubstr is matched against the returned error's message; "" means + // any non-nil error is acceptable. + for _, tc := range []struct { + name string + opts func() *Options + span KeyRange + lower []byte + upper []byte + errSubstr string + }{ + { + name: "format major version too low", + opts: func() *Options { + o := baseOpts() + o.FormatMajorVersion = FormatColumnarBlocks + return o + }, + span: validSpan, lower: lower, upper: upper, + errSubstr: "format major version", + }, + { + name: "empty lower bound", + opts: baseOpts, span: validSpan, lower: nil, upper: upper, + errSubstr: "lower bound is empty", + }, + { + name: "empty upper bound", + opts: baseOpts, span: validSpan, lower: lower, upper: nil, + errSubstr: "upper bound is empty", + }, + { + name: "invalid KeyRange (nil Start)", + opts: baseOpts, + span: KeyRange{Start: nil, End: testMakeEngineKey([]byte("z"), 0, 0)}, + lower: lower, upper: upper, + errSubstr: "invalid key range", + }, + } { + t.Run(tc.name, func(t *testing.T) { + d, err := Open("", tc.opts()) + require.NoError(t, err) + defer func() { require.NoError(t, d.Close()) }() + err = d.DeleteSuffixRange(context.Background(), tc.span, tc.lower, tc.upper) + if err == nil { + t.Fatalf("expected non-nil error") + } + if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.errSubstr) + } + }) + } + + t.Run("ReadOnly", func(t *testing.T) { + // Create then reopen read-only to surface ErrReadOnly. + fs := vfs.NewMem() + opts := baseOpts() + opts.FS = fs + d, err := Open("", opts) + require.NoError(t, err) + require.NoError(t, d.Close()) + + ro := baseOpts() + ro.FS = fs + ro.ReadOnly = true + d, err = Open("", ro) + require.NoError(t, err) + defer func() { require.NoError(t, d.Close()) }() + + err = d.DeleteSuffixRange(context.Background(), validSpan, lower, upper) + require.Equal(t, ErrReadOnly, err) + }) +} + +func TestExpandSuffixMask(t *testing.T) { + defer leaktest.AfterTest(t)() + + // expandSuffixMask returns (merged, true) when the union of two suffix + // masks is itself a single contiguous range, and (zero, false) when it + // is not. Suffix masks use [Lower, Upper) under the suffix comparator + // passed in. The test cases below use single-byte synthetic suffixes + // with a reverse-bytes comparator, simulating cockroachkvs-style MVCC + // where newer walls (smaller wall_time) sort before older ones. + reverseBytesCmp := func(a, b []byte) int { return bytes.Compare(b, a) } + tests := []struct { + name string + a, b sstable.SuffixMask + wantLower []byte // ignored if wantOK is false + wantUpper []byte + wantOK bool + }{ + { + name: "disjoint with gap", + a: sstable.SuffixMask{Lower: []byte{0x10}, Upper: []byte{0x05}}, + b: sstable.SuffixMask{Lower: []byte{0x30}, Upper: []byte{0x20}}, + wantOK: false, + }, + { + name: "adjacent (no gap)", + a: sstable.SuffixMask{Lower: []byte{0x20}, Upper: []byte{0x10}}, + b: sstable.SuffixMask{Lower: []byte{0x10}, Upper: []byte{0x05}}, + wantLower: []byte{0x20}, + wantUpper: []byte{0x05}, + wantOK: true, + }, + { + name: "one range contains the other", + a: sstable.SuffixMask{Lower: []byte{0x40}, Upper: []byte{0x01}}, + b: sstable.SuffixMask{Lower: []byte{0x30}, Upper: []byte{0x10}}, + wantLower: []byte{0x40}, + wantUpper: []byte{0x01}, + wantOK: true, + }, + { + name: "identical ranges", + a: sstable.SuffixMask{Lower: []byte{0x20}, Upper: []byte{0x10}}, + b: sstable.SuffixMask{Lower: []byte{0x20}, Upper: []byte{0x10}}, + wantLower: []byte{0x20}, + wantUpper: []byte{0x10}, + wantOK: true, + }, + { + name: "partially overlapping", + a: sstable.SuffixMask{Lower: []byte{0x30}, Upper: []byte{0x10}}, + b: sstable.SuffixMask{Lower: []byte{0x40}, Upper: []byte{0x20}}, + wantLower: []byte{0x40}, + wantUpper: []byte{0x10}, + wantOK: true, + }, + { + name: "multi-byte suffixes (overlap)", + a: sstable.SuffixMask{Lower: []byte{0x00, 0x20}, Upper: []byte{0x00, 0x05}}, + b: sstable.SuffixMask{Lower: []byte{0x00, 0x30}, Upper: []byte{0x00, 0x10}}, + wantLower: []byte{0x00, 0x30}, + wantUpper: []byte{0x00, 0x05}, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Expansion is commutative; verify both orderings. + for _, args := range []struct{ a, b sstable.SuffixMask }{{tt.a, tt.b}, {tt.b, tt.a}} { + got, ok := expandSuffixMask(reverseBytesCmp, args.a, args.b) + if !tt.wantOK { + if ok { + t.Fatalf("want ok=false, got merged=%v", got) + } + continue + } + if !ok { + t.Fatalf("want ok=true, got ok=false") + } + require.Equal(t, tt.wantLower, got.Lower) + require.Equal(t, tt.wantUpper, got.Upper) + } + }) + } + + // Cross-length suffixes (variable-length decimals) where the byte + // ordering disagrees with the suffix comparator. For testkeys, lower + // timestamps sort *after* higher ones in suffix order, so the mask + // `[@9, @5)` covers walls {6,7,8,9} and `[@99, @50)` covers walls + // {51..99}. The two are disjoint with a gap at walls {10..50}, but + // byte ordering puts "@50"<"@9" and "@99">"@9", which causes the + // previous bytes.Compare-based implementation to spuriously merge + // them into `[@99, @5)` = walls {6..99} (filling the gap). + t.Run("variable-length-decimal disjoint with gap", func(t *testing.T) { + a := sstable.SuffixMask{Lower: []byte("@9"), Upper: []byte("@5")} + b := sstable.SuffixMask{Lower: []byte("@99"), Upper: []byte("@50")} + suffixCmp := testkeys.Comparer.ComparePointSuffixes + for _, args := range []struct{ a, b sstable.SuffixMask }{{a, b}, {b, a}} { + got, ok := expandSuffixMask(suffixCmp, args.a, args.b) + if ok { + t.Fatalf("a=%q,%q b=%q,%q want ok=false, got merged=%q,%q", + args.a.Lower, args.a.Upper, args.b.Lower, args.b.Upper, + got.Lower, got.Upper) + } + } + }) + + // Same scenario but contiguous: `[@9, @5)` and `[@50, @9)` are + // adjacent (no gap) — together they cover walls {6..49}. Under + // suffix comparator they should merge to `[@50, @5)`. Under + // bytes.Compare they were spuriously refused: maxUpper="@9" > + // minLower="@50" in bytes, so the contiguity check fails. + t.Run("variable-length-decimal contiguous", func(t *testing.T) { + a := sstable.SuffixMask{Lower: []byte("@9"), Upper: []byte("@5")} + b := sstable.SuffixMask{Lower: []byte("@50"), Upper: []byte("@9")} + suffixCmp := testkeys.Comparer.ComparePointSuffixes + // Sanity-check via the actual testkeys suffix comparator that the + // expected merged bounds are well-formed: @50 sorts < @5 in suffix + // order. + require.True(t, suffixCmp([]byte("@50"), []byte("@5")) < 0) + for _, args := range []struct{ a, b sstable.SuffixMask }{{a, b}, {b, a}} { + got, ok := expandSuffixMask(suffixCmp, args.a, args.b) + if !ok { + t.Fatalf("a=%q,%q b=%q,%q want ok=true, got ok=false", + args.a.Lower, args.a.Upper, args.b.Lower, args.b.Upper) + } + require.Equal(t, []byte("@50"), got.Lower) + require.Equal(t, []byte("@5"), got.Upper) + } + }) +} + +func TestDeleteSuffixRangeDataDriven(t *testing.T) { + defer leaktest.AfterTest(t)() + + var d *DB + cleanup := func() { + if d != nil { + require.NoError(t, d.Close()) + d = nil + } + } + defer cleanup() + + var enableBlobStorage bool + openDB := func() { + cleanup() + opts := &Options{ + Comparer: &cockroachkvs.Comparer, + FormatMajorVersion: FormatSuffixMask, + FS: vfs.NewMem(), + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + DisableAutomaticCompactions: true, + } + if enableBlobStorage { + opts.FormatMajorVersion = FormatSuffixMask + opts.ValueSeparationPolicy = func() ValueSeparationPolicy { + return ValueSeparationPolicy{ + Enabled: true, + MinimumSize: 1, + MinimumMVCCGarbageSize: 1, + MaxBlobReferenceDepth: 10, + } + } + } + var err error + d, err = Open("", opts) + require.NoError(t, err) + } + openDB() + + // parseMVCCKey parses "roachKey@wallTime" or "roachKey" (bare). + parseMVCCKey := func(s string) []byte { + if i := strings.Index(s, "@"); i >= 0 { + roachKey := s[:i] + wall, err := strconv.ParseUint(s[i+1:], 10, 64) + if err != nil { + panic(fmt.Sprintf("bad wall time in %q: %v", s, err)) + } + return testMakeEngineKey([]byte(roachKey), wall, 0) + } + return testMakeEngineKey([]byte(s), 0, 0) + } + + parseWallTime := func(s string) uint64 { + if s == "max" { + return math.MaxUint64 + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + panic(fmt.Sprintf("bad wall time %q: %v", s, err)) + } + return v + } + + datadriven.RunTest(t, "testdata/delete_suffix_range", func(t *testing.T, td *datadriven.TestData) string { + switch td.Cmd { + case "reset": + enableBlobStorage = false + for _, arg := range td.CmdArgs { + if arg.Key == "blob-storage" { + enableBlobStorage = true + } + } + openDB() + return "" + case "batch": + b := d.NewBatch() + for line := range crstrings.LinesSeq(td.Input) { + parts := strings.Fields(line) + if len(parts) == 0 { + continue + } + switch parts[0] { + case "set": + if len(parts) != 3 { + return "set \n" + } + require.NoError(t, b.Set(parseMVCCKey(parts[1]), []byte(parts[2]), nil)) + case "range-key-set": + if len(parts) != 5 { + return "range-key-set @ \n" + } + start := testMakeEngineKey([]byte(parts[1]), 0, 0) + end := testMakeEngineKey([]byte(parts[2]), 0, 0) + suffix := parts[3] + if !strings.HasPrefix(suffix, "@") { + return "range-key-set suffix must start with @\n" + } + wall := parseWallTime(suffix[1:]) + require.NoError(t, b.RangeKeySet(start, end, testMakeSuffix(wall, 0), []byte(parts[4]), nil)) + default: + return fmt.Sprintf("unknown batch op: %s\n", parts[0]) + } + } + require.NoError(t, b.Commit(nil)) + return "" + case "flush": + require.NoError(t, d.Flush()) + return "" + case "compact": + if len(td.CmdArgs) != 2 { + return "compact \n" + } + require.NoError(t, d.Compact( + context.Background(), + testMakeEngineKey([]byte(td.CmdArgs[0].String()), 0, 0), + testMakeEngineKey([]byte(td.CmdArgs[1].String()), 0, 0), + false, + )) + return "" + case "delete-suffix-range": + parts := strings.Fields(td.CmdArgs[0].String() + " " + td.CmdArgs[1].String()) + start := testMakeEngineKey([]byte(parts[0]), 0, 0) + end := testMakeEngineKey([]byte(parts[1]), 0, 0) + var lowerWall, upperWall uint64 + for _, arg := range td.CmdArgs[2:] { + switch arg.Key { + case "lower": + lowerWall = parseWallTime(arg.Vals[0]) + case "upper": + upperWall = parseWallTime(arg.Vals[0]) + } + } + span := KeyRange{Start: start, End: end} + err := d.DeleteSuffixRange(context.Background(), span, testMakeSuffix(lowerWall, 0), testMakeSuffix(upperWall, 0)) + if err != nil { + return err.Error() + } + return "" + case "iter": + iter, err := d.NewIter(nil) + require.NoError(t, err) + defer iter.Close() + var buf bytes.Buffer + for line := range crstrings.LinesSeq(td.Input) { + parts := strings.Fields(line) + if len(parts) == 0 { + continue + } + switch parts[0] { + case "first": + iter.First() + case "next": + iter.Next() + case "prev": + iter.Prev() + case "last": + iter.Last() + case "seek-ge": + iter.SeekGE(parseMVCCKey(parts[1])) + case "seek-lt": + iter.SeekLT(parseMVCCKey(parts[1])) + } + if iter.Valid() { + fmt.Fprintf(&buf, "%s\n", iter.Value()) + } else { + fmt.Fprintf(&buf, ".\n") + } + } + return buf.String() + default: + return fmt.Sprintf("unknown command: %s\n", td.Cmd) + } + }) +} + +// TestDeleteSuffixRangeSyntheticSuffixWithEmptyRangeKey reproduces a bug where +// DeleteSuffixRange's SyntheticSuffix excise shortcut incorrectly dropped +// range-key entries whose original suffix was empty. +// +// Background: a file ingested with SyntheticSuffix has every point key's +// suffix replaced at iteration time. RangeKeySet entries also have their +// suffix replaced — but only if the original suffix is non-empty. A +// RangeKeySet with an empty original suffix retains the empty suffix. +// +// DSR's per-file shortcut checked whether the file's SyntheticSuffix falls in +// the mask range; if so, it excised the file's overlapping portion entirely. +// This was incorrect for files containing RangeKeySet entries with empty +// original suffix: those entries' effective suffix is empty, empty suffixes +// are never masked (per DSR's documented contract), and they should remain +// visible after DSR. The shortcut excised them anyway. +// +// The fix gates the shortcut on `!m.HasRangeKeys`, falling through to the +// per-row mask attachment path for files containing range keys. +func TestDeleteSuffixRangeSyntheticSuffixWithEmptyRangeKey(t *testing.T) { + defer leaktest.AfterTest(t)() + + remoteStorage := remote.NewInMem() + opts := &Options{ + Comparer: &cockroachkvs.Comparer, + FormatMajorVersion: FormatSuffixMask, + FS: vfs.NewMem(), + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + DisableAutomaticCompactions: true, + } + opts.RemoteStorage = remote.MakeSimpleFactory(map[remote.Locator]remote.Storage{ + remote.MakeLocator("ext"): remoteStorage, + }) + d, err := Open("", opts) + require.NoError(t, err) + defer d.Close() + + // Write an SST that contains a RangeKeySet with an EMPTY suffix. + writeOpts := d.opts.MakeWriterOptions(0, d.TableFormat()) + obj, err := remoteStorage.CreateObject("ext1") + require.NoError(t, err) + w := sstable.NewWriter(objstorageprovider.NewRemoteWritable(obj), writeOpts) + // A single point key, plus a range key with empty suffix spanning [a, z). + require.NoError(t, w.Set(testMakeEngineKey([]byte("c"), 0, 0), []byte("v-c"))) + require.NoError(t, w.Raw().EncodeSpan(keyspan.Span{ + Start: testMakeEngineKey([]byte("a"), 0, 0), + End: testMakeEngineKey([]byte("z"), 0, 0), + Keys: []keyspan.Key{ + { + Trailer: base.MakeTrailer(0, base.InternalKeyKindRangeKeySet), + Suffix: nil, + Value: []byte("rk-empty-suffix"), + }, + }, + })) + require.NoError(t, w.Close()) + + sz, err := remoteStorage.Size("ext1") + require.NoError(t, err) + + // Ingest with a SyntheticSuffix at wall=50. This file's effective point + // keys all have suffix @50; range-key entry's original suffix is empty + // (nil), so its effective suffix stays empty. + synthSuffix := testMakeSuffix(50, 0) + _, err = d.IngestExternalFiles(context.Background(), []ExternalFile{{ + Locator: remote.MakeLocator("ext"), + ObjName: "ext1", + Size: uint64(sz), + StartKey: testMakeEngineKey([]byte("a"), 0, 0), + EndKey: testMakeEngineKey([]byte("z"), 0, 0), + EndKeyIsInclusive: false, + HasPointKey: true, + HasRangeKey: true, + SyntheticSuffix: synthSuffix, + }}) + require.NoError(t, err) + + // Sanity check: range key with empty suffix is visible before DSR. + rangeKeyVisible := func() bool { + it, err := d.NewIter(&IterOptions{ + KeyTypes: IterKeyTypeRangesOnly, + }) + require.NoError(t, err) + defer it.Close() + for valid := it.First(); valid; valid = it.Next() { + for _, rk := range it.RangeKeys() { + if bytes.Equal(rk.Suffix, nil) || len(rk.Suffix) == 0 { + return true + } + } + } + return false + } + require.True(t, rangeKeyVisible()) + + // DSR with mask range covering @50 (the synthetic suffix). Per the + // SyntheticSuffix optimization, the file's point keys are all masked. + // But the range-key entry's effective suffix is empty, so it must + // remain visible. + span := KeyRange{ + Start: testMakeEngineKey([]byte("a"), 0, 0), + End: testMakeEngineKey([]byte("z"), 0, 0), + } + require.NoError(t, d.DeleteSuffixRange(context.Background(), span, + testMakeSuffix(math.MaxUint64, 0), // newer-side, inclusive + testMakeSuffix(10, 0), // older-side, exclusive (covers @50) + )) + + // After DSR, the range-key entry with empty suffix must still be visible. + if !rangeKeyVisible() { + t.Fatal("empty-suffix range-key entry incorrectly hidden by DSR's SyntheticSuffix shortcut") + } +} + +// NOTE: a regression test for the runCopyCompaction SuffixMask propagation +// fix lives in the metamorphic suite — running shared-storage + DSR + Download +// reproduces it. A direct unit test proved tricky to author because the copy- +// compaction is only chosen for specific (external<->local, virtual) file +// configurations that a self-contained test couldn't easily produce in +// isolation. + +// TestDeleteSuffixRangeSkipsNonOverlappingFiles verifies the BPF-based +// per-file skip optimization in DeleteSuffixRange. Several tables are +// constructed at disjoint wall-time ranges, and DSR is called with a +// suffix range that matches only the middle band. Files that don't +// overlap the mask range must not have a SuffixMask attached. +// +// The metamorphic bypass (invariants.Sometimes inside DeleteSuffixRange) +// is disabled for this test so the skip behavior is deterministic. +func TestDeleteSuffixRangeSkipsNonOverlappingFiles(t *testing.T) { + defer leaktest.AfterTest(t)() + + // Disable the metamorphic bypass: we want the skip to be deterministic. + prev := suffixMaskSkipBypassDisabled + suffixMaskSkipBypassDisabled = true + defer func() { suffixMaskSkipBypassDisabled = prev }() + + db, _ := suffixMaskTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + ctx := context.Background() + + // Build three disjoint files by writing keys in three distinct wall-time + // bands and flushing between writes. + // + // file A: keys "a".."c" with walls 10..30 + // file B: keys "d".."f" with walls 100..130 + // file C: keys "g".."i" with walls 500..530 + type fileGroup struct { + prefixes []string + walls []uint64 + } + groups := []fileGroup{ + {prefixes: []string{"a", "b", "c"}, walls: []uint64{10, 20, 30}}, + {prefixes: []string{"d", "e", "f"}, walls: []uint64{100, 110, 130}}, + {prefixes: []string{"g", "h", "i"}, walls: []uint64{500, 520, 530}}, + } + for i, g := range groups { + for _, p := range g.prefixes { + for _, w := range g.walls { + val := fmt.Sprintf("%s@%d/file%d", p, w, i) + require.NoError(t, db.Set(testMakeEngineKey([]byte(p), w, 0), []byte(val), nil)) + } + } + require.NoError(t, db.Flush()) + } + + // Sanity: three files in L0. + ver := db.DebugCurrentVersion() + var total int + for level := 0; level < manifest.NumLevels; level++ { + for range ver.Levels[level].All() { + total++ + } + } + require.Equal(t, 3, total) + + // Call DSR with a wall range (200, 400] — i.e. lower=suffix(400), + // upper=suffix(200). This wall band overlaps no file. Span the full + // key range so the spatial overlap test would otherwise match every + // file; the BPF skip is the only thing that can elide them. + lower := testMakeSuffix(400, 0) + upper := testMakeSuffix(200, 0) + spanStart := testMakeEngineKey([]byte("a"), 0, 0) + spanEnd := testMakeEngineKey([]byte("z"), 0, 0) + require.NoError(t, db.DeleteSuffixRange(ctx, + KeyRange{Start: spanStart, End: spanEnd}, lower, upper)) + + // No file should have received a mask: every file's wall band lies + // entirely outside (200, 400]. + ver = db.DebugCurrentVersion() + var masked, after int + for level := 0; level < manifest.NumLevels; level++ { + for f := range ver.Levels[level].All() { + after++ + if len(f.SuffixMasks) > 0 { + masked++ + } + } + } + require.Equal(t, 3, after) + require.Equal(t, 0, masked) + + // Now DSR a band that matches only file B's wall range (90, 140]: + // lower=suffix(140), upper=suffix(90). Only file B should be + // masked; files A (walls 10..30) and C (walls 500..530) must be + // skipped. + lower = testMakeSuffix(140, 0) + upper = testMakeSuffix(90, 0) + require.NoError(t, db.DeleteSuffixRange(ctx, + KeyRange{Start: spanStart, End: spanEnd}, lower, upper)) + + ver = db.DebugCurrentVersion() + masked = 0 + after = 0 + for level := 0; level < manifest.NumLevels; level++ { + for f := range ver.Levels[level].All() { + after++ + if len(f.SuffixMasks) > 0 { + masked++ + } + } + } + require.Equal(t, 3, after) + require.Equal(t, 1, masked) +} + +// TestDeleteSuffixRangeSkipBypassExercisesPath verifies that with the +// metamorphic bypass enabled (the default in invariants builds), at +// least one file occasionally has a no-op mask attached even when its +// block-property aggregate would normally let it be skipped. This is a +// statistical assertion across many DSR calls; with bypass probability +// 25%, the chance that 100 calls all skip is (1-0.25)^100 ≈ 3*10^-13. +func TestDeleteSuffixRangeSkipBypassExercisesPath(t *testing.T) { + defer leaktest.AfterTest(t)() + if !invariants.Enabled { + t.Skip("metamorphic bypass only fires under invariants/race builds") + } + + // Reset the bypass to its default (enabled) for this test. + prev := suffixMaskSkipBypassDisabled + suffixMaskSkipBypassDisabled = false + defer func() { suffixMaskSkipBypassDisabled = prev }() + + db, _ := suffixMaskTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + ctx := context.Background() + + // One file with walls in [10, 30]. + for _, w := range []uint64{10, 20, 30} { + require.NoError(t, db.Set(testMakeEngineKey([]byte("a"), w, 0), + []byte(fmt.Sprintf("a@%d", w)), nil)) + } + require.NoError(t, db.Flush()) + + // DSR with a wall band that doesn't intersect: lower=suffix(400), + // upper=suffix(200). The BPF would normally skip. We loop until + // the bypass triggers and a mask is attached, or give up after + // many tries (extraordinarily unlikely). + spanStart := testMakeEngineKey([]byte("a"), 0, 0) + spanEnd := testMakeEngineKey([]byte("z"), 0, 0) + lower := testMakeSuffix(400, 0) + upper := testMakeSuffix(200, 0) + + const maxCalls = 200 + for i := 0; i < maxCalls; i++ { + require.NoError(t, db.DeleteSuffixRange(ctx, + KeyRange{Start: spanStart, End: spanEnd}, lower, upper)) + ver := db.DebugCurrentVersion() + for level := 0; level < manifest.NumLevels; level++ { + for f := range ver.Levels[level].All() { + if len(f.SuffixMasks) > 0 { + // The bypass triggered at least once: the mask was + // attached even though no key matches. Visible scan + // should still see all rows. + iter, err := db.NewIter(nil) + require.NoError(t, err) + var visible int + for iter.First(); iter.Valid(); iter.Next() { + visible++ + } + require.NoError(t, iter.Close()) + require.Equal(t, 3, visible) + return + } + } + } + } + t.Fatalf("expected metamorphic bypass to attach a no-op mask within %d calls", maxCalls) +} + +// TestExternalFileSuffixMasksRoundTrip exercises the SuffixMasks field on +// ExternalFile (and indirectly SharedSSTMeta) end-to-end: a source DB +// attaches a mask via DeleteSuffixRange, ScanInternal emits an ExternalFile +// carrying the mask, and a destination DB reconstructs the masked vsst via +// IngestAndExcise. The destination's reads must observe the same mask +// behavior the source did. +func TestExternalFileSuffixMasksRoundTrip(t *testing.T) { + defer leaktest.AfterTest(t)() + + remoteStorage := remote.NewInMem() + mkDB := func(name string) *DB { + opts := &Options{ + Comparer: &cockroachkvs.Comparer, + FormatMajorVersion: FormatSuffixMask, + FS: vfs.NewMem(), + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + DisableAutomaticCompactions: true, + BlockPropertyCollectors: cockroachkvs.BlockPropertyCollectors, + SuffixRangeIntersects: cockroachkvs.SuffixRangeIntersectsTable, + } + opts.RemoteStorage = remote.MakeSimpleFactory(map[remote.Locator]remote.Storage{ + remote.MakeLocator("ext"): remoteStorage, + }) + d, err := Open(name, opts) + require.NoError(t, err) + return d + } + src := mkDB("src") + defer src.Close() + dst := mkDB("dst") + defer dst.Close() + + // Write an SST into shared storage with three keys at walls 200, 50, 5. + writeOpts := src.opts.MakeWriterOptions(0, src.TableFormat()) + obj, err := remoteStorage.CreateObject("file1") + require.NoError(t, err) + w := sstable.NewWriter(objstorageprovider.NewRemoteWritable(obj), writeOpts) + // MVCC keys sort newer-first within a prefix. + require.NoError(t, w.Set(testMakeEngineKey([]byte("a"), 200, 0), []byte("v-a-200"))) + require.NoError(t, w.Set(testMakeEngineKey([]byte("a"), 50, 0), []byte("v-a-50"))) + require.NoError(t, w.Set(testMakeEngineKey([]byte("a"), 5, 0), []byte("v-a-5"))) + require.NoError(t, w.Close()) + objSize, err := remoteStorage.Size("file1") + require.NoError(t, err) + + // Ingest into source DB. + _, err = src.IngestExternalFiles(context.Background(), []ExternalFile{{ + Locator: remote.MakeLocator("ext"), + ObjName: "file1", + Size: uint64(objSize), + StartKey: testMakeEngineKey([]byte("a"), 0, 0), + EndKey: testMakeEngineKey([]byte("b"), 0, 0), + EndKeyIsInclusive: false, + HasPointKey: true, + }}) + require.NoError(t, err) + + // Mask the middle key (wall=50) with [100, 10) — covers wall 50 only. + maskSpan := KeyRange{ + Start: testMakeEngineKey([]byte("a"), 0, 0), + End: testMakeEngineKey([]byte("b"), 0, 0), + } + require.NoError(t, src.DeleteSuffixRange(context.Background(), maskSpan, + testMakeSuffix(100, 0), testMakeSuffix(10, 0))) + + // Sanity check on source: walls 200 and 5 visible, wall 50 hidden. + require.Equal(t, []string{"v-a-200", "v-a-5"}, suffixMaskCollectVisible(t, src)) + + // Source-side: ScanInternal emits an ExternalFile carrying the mask. + var externals []ExternalFile + err = src.ScanInternal(context.Background(), ScanInternalOptions{ + IterOptions: IterOptions{ + KeyTypes: IterKeyTypePointsAndRanges, + LowerBound: testMakeEngineKey([]byte("a"), 0, 0), + UpperBound: testMakeEngineKey([]byte("b"), 0, 0), + }, + VisitExternalFile: func(ef *ExternalFile) error { + externals = append(externals, *ef) + return nil + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(externals)) + require.Equal(t, 1, len(externals[0].SuffixMasks)) + // Mask bytes must be deep-cloned (not alias src's TableMetadata). + require.Equal(t, testMakeSuffix(100, 0), externals[0].SuffixMasks[0].Lower) + require.Equal(t, testMakeSuffix(10, 0), externals[0].SuffixMasks[0].Upper) + + // Destination-side: IngestAndExcise applies the masks to the reconstructed + // vsst. The destination must observe the same hidden/visible pattern. + _, err = dst.IngestAndExcise(context.Background(), nil, nil, externals, + KeyRange{ + Start: testMakeEngineKey([]byte("a"), 0, 0), + End: testMakeEngineKey([]byte("b"), 0, 0), + }) + require.NoError(t, err) + require.Equal(t, []string{"v-a-200", "v-a-5"}, suffixMaskCollectVisible(t, dst)) +} + +// TestExternalFileSuffixMasksFMVGate verifies that ingesting an ExternalFile +// with non-empty SuffixMasks into a destination at a too-old format major +// version is refused, rather than silently losing the masks. +func TestExternalFileSuffixMasksFMVGate(t *testing.T) { + defer leaktest.AfterTest(t)() + + // FormatSuffixMask - 1 is the highest FMV that should refuse masks. + if FormatSuffixMask == 1 { + t.Skip("no FMV below FormatSuffixMask") + } + remoteStorage := remote.NewInMem() + opts := &Options{ + Comparer: &cockroachkvs.Comparer, + FormatMajorVersion: FormatSuffixMask - 1, + FS: vfs.NewMem(), + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + DisableAutomaticCompactions: true, + } + opts.RemoteStorage = remote.MakeSimpleFactory(map[remote.Locator]remote.Storage{ + remote.MakeLocator("ext"): remoteStorage, + }) + d, err := Open("", opts) + require.NoError(t, err) + defer d.Close() + + // Trivial backing file so the ingest path actually reaches the FMV check + // (Size != 0 etc). + writeOpts := d.opts.MakeWriterOptions(0, d.TableFormat()) + obj, err := remoteStorage.CreateObject("file1") + require.NoError(t, err) + w := sstable.NewWriter(objstorageprovider.NewRemoteWritable(obj), writeOpts) + require.NoError(t, w.Set(testMakeEngineKey([]byte("a"), 50, 0), []byte("v"))) + require.NoError(t, w.Close()) + sz, err := remoteStorage.Size("file1") + require.NoError(t, err) + + _, err = d.IngestExternalFiles(context.Background(), []ExternalFile{{ + Locator: remote.MakeLocator("ext"), + ObjName: "file1", + Size: uint64(sz), + StartKey: testMakeEngineKey([]byte("a"), 0, 0), + EndKey: testMakeEngineKey([]byte("b"), 0, 0), + EndKeyIsInclusive: false, + HasPointKey: true, + SuffixMasks: []sstable.SuffixMask{{ + Lower: testMakeSuffix(100, 0), + Upper: testMakeSuffix(10, 0), + }}, + }}) + if err == nil { + t.Fatalf("expected IngestExternalFiles to refuse SuffixMasks at FMV=%d (<%d), got nil", + FormatSuffixMask-1, FormatSuffixMask) + } +} diff --git a/suffix_mask_testutils_test.go b/suffix_mask_testutils_test.go new file mode 100644 index 00000000000..9403b80903b --- /dev/null +++ b/suffix_mask_testutils_test.go @@ -0,0 +1,227 @@ +// Copyright 2026 The LevelDB-Go and Pebble Authors. All rights reserved. Use +// of this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +// Suffix-mask tests: shared helpers and conventions. +// +// All test files named suffix_mask*_test.go share a small set of helpers +// defined here. They embed two conventions used by every test: +// +// 1. MVCC-style engine keys are constructed by appending a CockroachDB-style +// sentinel byte (0x00) to a raw key, then optionally appending a 9- or +// 13-byte MVCC suffix encoding a big-endian wall time and optional logical +// tick. testMakeEngineKey and testMakeSuffix produce those encodings; +// parseEngineKeyWallLogical inverts them. +// +// 2. SuffixMask{Lower, Upper} is a half-open interval in comparer order. For +// cockroachkvs (and any MVCC encoding) larger wall times sort first, so +// Lower carries the larger ("newest") wall time and Upper carries the +// smaller ("oldest") wall time. suffixMaskTestTransforms returns the +// standard mask used by several tests: hide every key with wall > 100. +// +// Helpers grouped by purpose: +// +// - testMakeEngineKey, testMakeSuffix, parseEngineKeyWallLogical: MVCC key +// encoding and decoding. +// - suffixMaskTestEntry: a (roachKey, wall, value) triple consumed by the +// SST/DB builders. +// - suffixMaskTestSST: build a single cockroachkvs+pebblev5 SST and open +// it for reading. +// - suffixMaskTestTransforms: the canonical "hide wall > 100" mask. +// - suffixMaskTestDB: open an in-memory DB with cockroachkvs options and +// automatic compactions disabled. +// - suffixMaskWriteIngestSST: write an SST suitable for ingestion into a +// suffixMaskTestDB. +// - suffixMaskCollectVisible: scan a DB and return the visible point-key +// values in order. +// +// The package-level suffixMaskSkipBypassDisabled toggle (defined in +// suffix_mask.go) is flipped by the BPF skip tests in suffix_mask_skip_test.go. + +package pebble + +import ( + "context" + "encoding/binary" + "math" + "testing" + + "github.com/cockroachdb/crlib/testutils/require" + "github.com/cockroachdb/pebble/cockroachkvs" + "github.com/cockroachdb/pebble/internal/testutils" + "github.com/cockroachdb/pebble/objstorage" + "github.com/cockroachdb/pebble/objstorage/objstorageprovider" + "github.com/cockroachdb/pebble/sstable" + "github.com/cockroachdb/pebble/vfs" +) + +func testMakeEngineKey(roachKey []byte, wallTime uint64, logical uint32) []byte { + key := make([]byte, 0, len(roachKey)+1+9) + key = append(key, roachKey...) + key = append(key, 0) // sentinel + if wallTime == 0 && logical == 0 { + return key + } + if logical == 0 { + var buf [9]byte + binary.BigEndian.PutUint64(buf[:8], wallTime) + buf[8] = 9 + key = append(key, buf[:]...) + return key + } + var buf [13]byte + binary.BigEndian.PutUint64(buf[:8], wallTime) + binary.BigEndian.PutUint32(buf[8:12], logical) + buf[12] = 13 + key = append(key, buf[:]...) + return key +} + +func testMakeSuffix(wallTime uint64, logical uint32) []byte { + if wallTime == 0 && logical == 0 { + return nil + } + if logical == 0 { + var buf [9]byte + binary.BigEndian.PutUint64(buf[:8], wallTime) + buf[8] = 9 + return buf[:] + } + var buf [13]byte + binary.BigEndian.PutUint64(buf[:8], wallTime) + binary.BigEndian.PutUint32(buf[8:12], logical) + buf[12] = 13 + return buf[:] +} + +// parseEngineKeyWallLogical decodes the MVCC suffix appended by +// testMakeEngineKey. Returns 0,0 for suffixless keys. +func parseEngineKeyWallLogical(k []byte) (wall uint64, logical uint32) { + if len(k) == 0 { + return 0, 0 + } + suffixLen := int(k[len(k)-1]) + if suffixLen == 0 { + return 0, 0 + } + suffix := k[len(k)-suffixLen : len(k)-1] + if len(suffix) >= 8 { + wall = binary.BigEndian.Uint64(suffix[:8]) + } + if len(suffix) >= 12 { + logical = binary.BigEndian.Uint32(suffix[8:12]) + } + return wall, logical +} + +// suffixMaskTestEntry describes a key to write into an SST for suffix mask +// testing. +type suffixMaskTestEntry struct { + roachKey string + wall uint64 // 0 means unversioned + value string +} + +// suffixMaskTestSST writes an SST with the given entries using +// cockroachkvs.Comparer, cockroachkvs.KeySchema, and TableFormatPebblev5 +// (columnar), then opens it for reading. The caller must close the returned +// reader. +func suffixMaskTestSST(t *testing.T, entries []suffixMaskTestEntry) *sstable.Reader { + t.Helper() + comparer := &cockroachkvs.Comparer + fs := vfs.NewMem() + + f, err := fs.Create("test.sst", vfs.WriteCategoryUnspecified) + require.NoError(t, err) + + writerOpts := sstable.WriterOptions{ + Comparer: comparer, + KeySchema: &cockroachkvs.KeySchema, + TableFormat: sstable.TableFormatPebblev5, + } + w := sstable.NewWriter(objstorageprovider.NewFileWritable(f), writerOpts) + for _, e := range entries { + key := testMakeEngineKey([]byte(e.roachKey), e.wall, 0) + require.NoError(t, w.Set(key, []byte(e.value))) + } + require.NoError(t, w.Close()) + + f2, err := fs.Open("test.sst") + require.NoError(t, err) + readable, err := objstorage.NewSimpleReadable(f2) + require.NoError(t, err) + reader, err := sstable.NewReader(context.Background(), readable, sstable.ReaderOptions{ + Comparer: comparer, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + }) + require.NoError(t, err) + return reader +} + +// suffixMaskTestTransforms returns IterTransforms with a single SuffixMask +// [MaxUint64, 100) in comparer order. This masks keys with wall time > 100. +func suffixMaskTestTransforms() sstable.IterTransforms { + lower := testMakeSuffix(math.MaxUint64, 0) + upper := testMakeSuffix(100, 0) + return sstable.IterTransforms{ + SuffixMasks: []sstable.SuffixMask{{Lower: lower, Upper: upper}}, + } +} + +// suffixMaskTestDB opens a DB configured with cockroachkvs, disabling +// automatic compactions. The caller must close it. +func suffixMaskTestDB(t *testing.T) (*DB, vfs.FS) { + t.Helper() + fs := vfs.NewMem() + opts := &Options{ + Comparer: &cockroachkvs.Comparer, + FS: fs, + FormatMajorVersion: FormatNewest, + KeySchema: cockroachkvs.KeySchema.Name, + KeySchemas: sstable.MakeKeySchemas(&cockroachkvs.KeySchema), + L0CompactionThreshold: 100, + L0StopWritesThreshold: 100, + DisableAutomaticCompactions: true, + DebugCheck: DebugCheckLevels, + Logger: testutils.Logger{T: t}, + BlockPropertyCollectors: cockroachkvs.BlockPropertyCollectors, + SuffixRangeIntersects: cockroachkvs.SuffixRangeIntersectsTable, + } + db, err := Open("", opts) + require.NoError(t, err) + return db, fs +} + +// suffixMaskWriteIngestSST writes an SST on the given filesystem, suitable for +// ingestion into a DB opened with cockroachkvs options. The SST contains the +// specified entries. +func suffixMaskWriteIngestSST(t *testing.T, fs vfs.FS, path string, entries []suffixMaskTestEntry) { + t.Helper() + f, err := fs.Create(path, vfs.WriteCategoryUnspecified) + require.NoError(t, err) + writerOpts := sstable.WriterOptions{ + Comparer: &cockroachkvs.Comparer, + KeySchema: &cockroachkvs.KeySchema, + TableFormat: sstable.TableFormatPebblev5, + } + w := sstable.NewWriter(objstorageprovider.NewFileWritable(f), writerOpts) + for _, e := range entries { + key := testMakeEngineKey([]byte(e.roachKey), e.wall, 0) + require.NoError(t, w.Set(key, []byte(e.value))) + } + require.NoError(t, w.Close()) +} + +// suffixMaskCollectVisible reads all visible point key values from a DB +// iterator, returning them as strings. +func suffixMaskCollectVisible(t *testing.T, db *DB) []string { + t.Helper() + iter, err := db.NewIter(nil) + require.NoError(t, err) + defer iter.Close() + var vals []string + for iter.First(); iter.Valid(); iter.Next() { + vals = append(vals, string(iter.Value())) + } + return vals +} diff --git a/testdata/checkpoint b/testdata/checkpoint index 9748dff7867..021c6843471 100644 --- a/testdata/checkpoint +++ b/testdata/checkpoint @@ -111,6 +111,11 @@ sync: db/marker.format-version.000017.030 close: db/marker.format-version.000017.030 remove: db/marker.format-version.000016.029 sync: db +create: db/marker.format-version.000018.031 +sync: db/marker.format-version.000018.031 +close: db/marker.format-version.000018.031 +remove: db/marker.format-version.000017.030 +sync: db get-disk-usage: db batch db @@ -175,9 +180,9 @@ sync-data: checkpoints/checkpoint1/OPTIONS-000002 close: checkpoints/checkpoint1/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint1 -create: checkpoints/checkpoint1/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint1/marker.format-version.000001.030 -close: checkpoints/checkpoint1/marker.format-version.000001.030 +create: checkpoints/checkpoint1/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint1/marker.format-version.000001.031 +close: checkpoints/checkpoint1/marker.format-version.000001.031 sync: checkpoints/checkpoint1 close: checkpoints/checkpoint1 link: db/000005.sst -> checkpoints/checkpoint1/000005.sst @@ -220,9 +225,9 @@ sync-data: checkpoints/checkpoint2/OPTIONS-000002 close: checkpoints/checkpoint2/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint2 -create: checkpoints/checkpoint2/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint2/marker.format-version.000001.030 -close: checkpoints/checkpoint2/marker.format-version.000001.030 +create: checkpoints/checkpoint2/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint2/marker.format-version.000001.031 +close: checkpoints/checkpoint2/marker.format-version.000001.031 sync: checkpoints/checkpoint2 close: checkpoints/checkpoint2 link: db/000007.sst -> checkpoints/checkpoint2/000007.sst @@ -260,9 +265,9 @@ sync-data: checkpoints/checkpoint3/OPTIONS-000002 close: checkpoints/checkpoint3/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint3 -create: checkpoints/checkpoint3/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint3/marker.format-version.000001.030 -close: checkpoints/checkpoint3/marker.format-version.000001.030 +create: checkpoints/checkpoint3/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint3/marker.format-version.000001.031 +close: checkpoints/checkpoint3/marker.format-version.000001.031 sync: checkpoints/checkpoint3 close: checkpoints/checkpoint3 link: db/000005.sst -> checkpoints/checkpoint3/000005.sst @@ -349,7 +354,7 @@ list db LOCK MANIFEST-000001 OPTIONS-000002 -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 list checkpoints/checkpoint1 @@ -359,7 +364,7 @@ list checkpoints/checkpoint1 000007.sst MANIFEST-000001 OPTIONS-000002 -marker.format-version.000001.030 +marker.format-version.000001.031 marker.manifest.000001.MANIFEST-000001 open checkpoints/checkpoint1 readonly @@ -427,7 +432,7 @@ list checkpoints/checkpoint2 000007.sst MANIFEST-000001 OPTIONS-000002 -marker.format-version.000001.030 +marker.format-version.000001.031 marker.manifest.000001.MANIFEST-000001 open checkpoints/checkpoint2 readonly @@ -470,7 +475,7 @@ list checkpoints/checkpoint3 000007.sst MANIFEST-000001 OPTIONS-000002 -marker.format-version.000001.030 +marker.format-version.000001.031 marker.manifest.000001.MANIFEST-000001 open checkpoints/checkpoint3 readonly @@ -590,9 +595,9 @@ sync-data: checkpoints/checkpoint4/OPTIONS-000002 close: checkpoints/checkpoint4/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint4 -create: checkpoints/checkpoint4/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint4/marker.format-version.000001.030 -close: checkpoints/checkpoint4/marker.format-version.000001.030 +create: checkpoints/checkpoint4/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint4/marker.format-version.000001.031 +close: checkpoints/checkpoint4/marker.format-version.000001.031 sync: checkpoints/checkpoint4 close: checkpoints/checkpoint4 link: db/000010.sst -> checkpoints/checkpoint4/000010.sst @@ -682,7 +687,7 @@ list db LOCK MANIFEST-000001 OPTIONS-000002 -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 @@ -701,9 +706,9 @@ sync-data: checkpoints/checkpoint5/OPTIONS-000002 close: checkpoints/checkpoint5/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint5 -create: checkpoints/checkpoint5/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint5/marker.format-version.000001.030 -close: checkpoints/checkpoint5/marker.format-version.000001.030 +create: checkpoints/checkpoint5/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint5/marker.format-version.000001.031 +close: checkpoints/checkpoint5/marker.format-version.000001.031 sync: checkpoints/checkpoint5 close: checkpoints/checkpoint5 link: db/000010.sst -> checkpoints/checkpoint5/000010.sst @@ -808,9 +813,9 @@ sync-data: checkpoints/checkpoint6/OPTIONS-000002 close: checkpoints/checkpoint6/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint6 -create: checkpoints/checkpoint6/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint6/marker.format-version.000001.030 -close: checkpoints/checkpoint6/marker.format-version.000001.030 +create: checkpoints/checkpoint6/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint6/marker.format-version.000001.031 +close: checkpoints/checkpoint6/marker.format-version.000001.031 sync: checkpoints/checkpoint6 close: checkpoints/checkpoint6 link: db/000011.sst -> checkpoints/checkpoint6/000011.sst @@ -1040,6 +1045,11 @@ sync: valsepdb/marker.format-version.000017.030 close: valsepdb/marker.format-version.000017.030 remove: valsepdb/marker.format-version.000016.029 sync: valsepdb +create: valsepdb/marker.format-version.000018.031 +sync: valsepdb/marker.format-version.000018.031 +close: valsepdb/marker.format-version.000018.031 +remove: valsepdb/marker.format-version.000017.030 +sync: valsepdb get-disk-usage: valsepdb batch valsepdb @@ -1085,9 +1095,9 @@ sync-data: checkpoints/checkpoint8/OPTIONS-000002 close: checkpoints/checkpoint8/OPTIONS-000002 close: valsepdb/OPTIONS-000002 open-dir: checkpoints/checkpoint8 -create: checkpoints/checkpoint8/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint8/marker.format-version.000001.030 -close: checkpoints/checkpoint8/marker.format-version.000001.030 +create: checkpoints/checkpoint8/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint8/marker.format-version.000001.031 +close: checkpoints/checkpoint8/marker.format-version.000001.031 sync: checkpoints/checkpoint8 close: checkpoints/checkpoint8 link: valsepdb/000006.blob -> checkpoints/checkpoint8/000006.blob @@ -1202,9 +1212,9 @@ sync-data: checkpoints/checkpoint9/OPTIONS-000002 close: checkpoints/checkpoint9/OPTIONS-000002 close: valsepdb/OPTIONS-000002 open-dir: checkpoints/checkpoint9 -create: checkpoints/checkpoint9/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint9/marker.format-version.000001.030 -close: checkpoints/checkpoint9/marker.format-version.000001.030 +create: checkpoints/checkpoint9/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint9/marker.format-version.000001.031 +close: checkpoints/checkpoint9/marker.format-version.000001.031 sync: checkpoints/checkpoint9 close: checkpoints/checkpoint9 link: valsepdb/000006.blob -> checkpoints/checkpoint9/000006.blob diff --git a/testdata/checkpoint_shared b/testdata/checkpoint_shared index 87aab0b4c7c..65937a315dd 100644 --- a/testdata/checkpoint_shared +++ b/testdata/checkpoint_shared @@ -96,6 +96,11 @@ sync: db/marker.format-version.000014.030 close: db/marker.format-version.000014.030 remove: db/marker.format-version.000013.029 sync: db +create: db/marker.format-version.000015.031 +sync: db/marker.format-version.000015.031 +close: db/marker.format-version.000015.031 +remove: db/marker.format-version.000014.030 +sync: db get-disk-usage: db create: db/REMOTE-OBJ-CATALOG-000001 sync: db/REMOTE-OBJ-CATALOG-000001 @@ -162,9 +167,9 @@ sync-data: checkpoints/checkpoint1/OPTIONS-000002 close: checkpoints/checkpoint1/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint1 -create: checkpoints/checkpoint1/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint1/marker.format-version.000001.030 -close: checkpoints/checkpoint1/marker.format-version.000001.030 +create: checkpoints/checkpoint1/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint1/marker.format-version.000001.031 +close: checkpoints/checkpoint1/marker.format-version.000001.031 sync: checkpoints/checkpoint1 close: checkpoints/checkpoint1 open: db/MANIFEST-000001 (options: *vfs.sequentialReadsOption) @@ -217,9 +222,9 @@ sync-data: checkpoints/checkpoint2/OPTIONS-000002 close: checkpoints/checkpoint2/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint2 -create: checkpoints/checkpoint2/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint2/marker.format-version.000001.030 -close: checkpoints/checkpoint2/marker.format-version.000001.030 +create: checkpoints/checkpoint2/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint2/marker.format-version.000001.031 +close: checkpoints/checkpoint2/marker.format-version.000001.031 sync: checkpoints/checkpoint2 close: checkpoints/checkpoint2 open: db/MANIFEST-000001 (options: *vfs.sequentialReadsOption) @@ -268,9 +273,9 @@ sync-data: checkpoints/checkpoint3/OPTIONS-000002 close: checkpoints/checkpoint3/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoints/checkpoint3 -create: checkpoints/checkpoint3/marker.format-version.000001.030 -sync-data: checkpoints/checkpoint3/marker.format-version.000001.030 -close: checkpoints/checkpoint3/marker.format-version.000001.030 +create: checkpoints/checkpoint3/marker.format-version.000001.031 +sync-data: checkpoints/checkpoint3/marker.format-version.000001.031 +close: checkpoints/checkpoint3/marker.format-version.000001.031 sync: checkpoints/checkpoint3 close: checkpoints/checkpoint3 open: db/MANIFEST-000001 (options: *vfs.sequentialReadsOption) @@ -331,7 +336,7 @@ LOCK MANIFEST-000001 OPTIONS-000002 REMOTE-OBJ-CATALOG-000001 -marker.format-version.000014.030 +marker.format-version.000015.031 marker.manifest.000001.MANIFEST-000001 marker.remote-obj-catalog.000001.REMOTE-OBJ-CATALOG-000001 @@ -341,7 +346,7 @@ list checkpoints/checkpoint1 MANIFEST-000001 OPTIONS-000002 REMOTE-OBJ-CATALOG-000001 -marker.format-version.000001.030 +marker.format-version.000001.031 marker.manifest.000001.MANIFEST-000001 marker.remote-obj-catalog.000001.REMOTE-OBJ-CATALOG-000001 @@ -394,7 +399,7 @@ list checkpoints/checkpoint2 MANIFEST-000001 OPTIONS-000002 REMOTE-OBJ-CATALOG-000001 -marker.format-version.000001.030 +marker.format-version.000001.031 marker.manifest.000001.MANIFEST-000001 marker.remote-obj-catalog.000001.REMOTE-OBJ-CATALOG-000001 diff --git a/testdata/delete_suffix_range b/testdata/delete_suffix_range new file mode 100644 index 00000000000..ad74c73577e --- /dev/null +++ b/testdata/delete_suffix_range @@ -0,0 +1,370 @@ +# Fully contained: all keys within mask span. +# Mask (100, max] over [a, z) should hide keys with wall > 100. + +batch +set a@200 a@200 +set a@100 a@100 +set a@50 a@50 +set b@150 b@150 +set b@80 b@80 +set c c-unversioned +set m@300 m@300 +set m@10 m@10 +set y@500 y@500 +set y@99 y@99 +---- + +flush +---- + +delete-suffix-range a z lower=max upper=100 +---- + +iter +first +next +next +next +next +next +next +---- +a@100 +a@50 +b@80 +c-unversioned +m@10 +y@99 +. + +# Straddling: mask [d, p) straddles the single SST. +# Keys inside span with wall > 100 masked; keys outside untouched. + +reset +---- + +batch +set a@200 a@200 +set a@50 a@50 +set c@300 c@300 +set d@200 d@200 +set d@80 d@80 +set h@150 h@150 +set h@100 h@100 +set m@500 m@500 +set m@10 m@10 +set p@400 p@400 +set p@20 p@20 +set y@600 y@600 +---- + +flush +---- + +delete-suffix-range d p lower=max upper=100 +---- + +iter +first +next +next +next +next +next +next +next +next +next +---- +a@200 +a@50 +c@300 +d@80 +h@100 +m@10 +p@400 +p@20 +y@600 +. + +# Straddling with range keys at the boundary. +# Range key [m, z) crosses the mask boundary at p. +# Compact to L6 so the sorted-level overlap check fires. + +reset +---- + +batch +set a@200 a@200 +set a@50 a@50 +set d@200 d@200 +set d@80 d@80 +set h@150 h@150 +set h@100 h@100 +set p@400 p@400 +set p@20 p@20 +set y@600 y@600 +range-key-set m z @50 rangeval +---- + +flush +---- + +compact a z +---- + +delete-suffix-range d p lower=max upper=100 +---- + +# Straddling with compaction after mask. +# The middleTable size is computed as m.Size - left.Size - right.Size. +# If size estimates overshoot (left + right > original), the uint64 +# subtraction would underflow without the clamp fix. Compact after +# masking exercises LevelMetadata.remove which catches bad sizes. + +reset +---- + +batch +set a@200 a@200 +set a@50 a@50 +set d@200 d@200 +set d@80 d@80 +set p@400 p@400 +set p@20 p@20 +set y@600 y@600 +---- + +flush +---- + +compact a z +---- + +delete-suffix-range d p lower=max upper=100 +---- + +compact a z +---- + +iter +first +next +next +next +next +next +next +---- +a@200 +a@50 +d@80 +p@400 +p@20 +y@600 +. + +# Range keys outside mask span. +# Point keys in [a, y], range key [p, z), mask over [a, k). +# Range keys are entirely beyond span end — must not produce +# inverted range key bounds. + +reset +---- + +batch +set a@200 a@200 +set a@50 a@50 +set d@200 d@200 +set d@80 d@80 +set p@400 p@400 +set p@20 p@20 +set y@600 y@600 +range-key-set p z @50 rangeval +---- + +flush +---- + +compact a z +---- + +delete-suffix-range a k lower=max upper=100 +---- + +iter +first +next +next +next +next +next +---- +a@50 +d@80 +p@400 +p@20 +y@600 +. + +# Blob storage (fully contained): tables with blob references. + +reset blob-storage +---- + +batch +set a@200 a@200-blob-val +set a@50 a@50-blob-val +set b@150 b@150-blob-val +set b@80 b@80-blob-val +---- + +flush +---- + +delete-suffix-range a z lower=max upper=100 +---- + +iter +first +next +next +---- +a@50-blob-val +b@80-blob-val +. + +# Blob storage (straddling): mask straddles a table with blob refs. + +reset blob-storage +---- + +batch +set a@200 a@200-blob-val +set a@50 a@50-blob-val +set d@200 d@200-blob-val +set d@80 d@80-blob-val +set p@400 p@400-blob-val +set p@20 p@20-blob-val +---- + +flush +---- + +compact a z +---- + +delete-suffix-range d p lower=max upper=100 +---- + +iter +first +next +next +next +next +next +---- +a@200-blob-val +a@50-blob-val +d@80-blob-val +p@400-blob-val +p@20-blob-val +. + +# Blob storage: multi-table revert pattern. +# Simulates CRDB's RevertRange with UseMVCCHistoryTruncation across +# multiple SQL tables within a tenant. 4 "tables" (key prefixes t1-t4) +# get initial writes, flush, more writes, flush, then a +# DeleteSuffixRange covering all 4 tables, followed by compaction. + +reset blob-storage +---- + +# Step 1-2: Initial writes across 4 tables at wall=100. +batch +set t1/a@100 t1a@100 +set t1/b@100 t1b@100 +set t2/a@100 t2a@100 +set t2/b@100 t2b@100 +set t3/a@100 t3a@100 +set t3/b@100 t3b@100 +set t4/a@100 t4a@100 +set t4/b@100 t4b@100 +---- + +# Step 3: Flush initial state. +flush +---- + +# Step 4: More writes at wall=200 (after targetTime=100). +batch +set t1/a@200 t1a@200 +set t1/b@200 t1b@200 +set t2/a@200 t2a@200 +set t2/b@200 t2b@200 +set t3/a@200 t3a@200 +set t3/b@200 t3b@200 +set t4/a@200 t4a@200 +set t4/b@200 t4b@200 +---- + +# Step 5: Flush again. +flush +---- + +# Step 6: Revert — mask (100, max] over [t1, t5). +# This should hide all wall=200 writes. +delete-suffix-range t1 t5 lower=max upper=100 +---- + +# Step 7: Compact. +compact t1 t5 +---- + +iter +first +next +next +next +next +next +next +next +next +---- +t1a@100 +t1b@100 +t2a@100 +t2b@100 +t3a@100 +t3b@100 +t4a@100 +t4b@100 +. + +# Already masked: re-masking with the same bounds is a no-op. + +reset +---- + +batch +set a@200 a@200 +set a@50 a@50 +---- + +flush +---- + +delete-suffix-range a z lower=max upper=100 +---- + +delete-suffix-range a z lower=max upper=100 +---- + +iter +first +next +---- +a@50 +. diff --git a/testdata/event_listener b/testdata/event_listener index 9495903f0a7..dade6bdcff8 100644 --- a/testdata/event_listener +++ b/testdata/event_listener @@ -136,6 +136,12 @@ close: db/marker.format-version.000017.030 remove: db/marker.format-version.000016.029 sync: db upgraded to format version: 030 +create: db/marker.format-version.000018.031 +sync: db/marker.format-version.000018.031 +close: db/marker.format-version.000018.031 +remove: db/marker.format-version.000017.030 +sync: db +upgraded to format version: 031 get-disk-usage: db flush @@ -592,9 +598,9 @@ sync-data: checkpoint/OPTIONS-000002 close: checkpoint/OPTIONS-000002 close: db/OPTIONS-000002 open-dir: checkpoint -create: checkpoint/marker.format-version.000001.030 -sync-data: checkpoint/marker.format-version.000001.030 -close: checkpoint/marker.format-version.000001.030 +create: checkpoint/marker.format-version.000001.031 +sync-data: checkpoint/marker.format-version.000001.031 +close: checkpoint/marker.format-version.000001.031 sync: checkpoint close: checkpoint link: db/000013.sst -> checkpoint/000013.sst diff --git a/testdata/excise b/testdata/excise index 333c7ec8abd..f8db4fca9a3 100644 --- a/testdata/excise +++ b/testdata/excise @@ -775,7 +775,7 @@ L6: 000023(000018):[a#0,SET-a#0,SET] seqnums:[#0-#0] points:[a#0,SET-a#0,SET] size:71(615) 000024(000018):[f#0,SET-f#0,SET] seqnums:[#0-#0] points:[f#0,SET-f#0,SET] size:71(615) 000027(000026):[g#26,DELSIZED-ga#inf,RANGEDEL] seqnums:[#26-#26] points:[g#26,DELSIZED-ga#inf,RANGEDEL] size:297(594) - 000028(000026):[ha#0,DELSIZED-j#inf,RANGEDEL] seqnums:[#26-#26] points:[ha#0,DELSIZED-j#inf,RANGEDEL] size:297(594) + 000028(000026):[ha#b36028797018963966,DELSIZED-j#inf,RANGEDEL] seqnums:[#26-#26] points:[ha#b36028797018963966,DELSIZED-j#inf,RANGEDEL] size:297(594) 000025(000018):[x#0,SET-x#0,SET] seqnums:[#0-#0] points:[x#0,SET-x#0,SET] size:71(615) # Non-local tables are excised without data access. @@ -806,9 +806,9 @@ L6: 000023(000018):[a#0,SET-a#0,SET] seqnums:[#0-#0] points:[a#0,SET-a#0,SET] size:71(615) 000024(000018):[f#0,SET-f#0,SET] seqnums:[#0-#0] points:[f#0,SET-f#0,SET] size:71(615) 000027(000026):[g#26,DELSIZED-ga#inf,RANGEDEL] seqnums:[#26-#26] points:[g#26,DELSIZED-ga#inf,RANGEDEL] size:297(594) - 000028(000026):[ha#0,DELSIZED-j#inf,RANGEDEL] seqnums:[#26-#26] points:[ha#0,DELSIZED-j#inf,RANGEDEL] size:297(594) + 000028(000026):[ha#b36028797018963966,DELSIZED-j#inf,RANGEDEL] seqnums:[#26-#26] points:[ha#b36028797018963966,DELSIZED-j#inf,RANGEDEL] size:297(594) 000030(000026):[u#28,DELSIZED-uk#inf,RANGEDEL] seqnums:[#28-#28] points:[u#28,DELSIZED-uk#inf,RANGEDEL] size:297(594) - 000031(000026):[vk#0,DELSIZED-w#inf,RANGEDEL] seqnums:[#28-#28] points:[vk#0,DELSIZED-w#inf,RANGEDEL] size:297(594) + 000031(000026):[vk#b36028797018963966,DELSIZED-w#inf,RANGEDEL] seqnums:[#28-#28] points:[vk#b36028797018963966,DELSIZED-w#inf,RANGEDEL] size:297(594) 000025(000018):[x#0,SET-x#0,SET] seqnums:[#0-#0] points:[x#0,SET-x#0,SET] size:71(615) excise a z diff --git a/testdata/excise_bounds b/testdata/excise_bounds index 139f7b44eb3..784ea047d68 100644 --- a/testdata/excise_bounds +++ b/testdata/excise_bounds @@ -15,8 +15,8 @@ Right table bounds: overall: c#1,SET - e#1,SET point: c#1,SET - e#1,SET Right table bounds (loose): - overall: bb#0,DELSIZED - e#1,SET - point: bb#0,DELSIZED - e#1,SET + overall: bb#b36028797018963966,DELSIZED - e#1,SET + point: bb#b36028797018963966,DELSIZED - e#1,SET excise [a, b] @@ -37,8 +37,8 @@ Right table bounds: overall: c#1,SET - e#1,SET point: c#1,SET - e#1,SET Right table bounds (loose): - overall: c#0,DELSIZED - e#1,SET - point: c#0,DELSIZED - e#1,SET + overall: c#b36028797018963966,DELSIZED - e#1,SET + point: c#b36028797018963966,DELSIZED - e#1,SET excise [a, cc] @@ -101,8 +101,8 @@ Right table bounds: overall: d#1,SET - e#1,SET point: d#1,SET - e#1,SET Right table bounds (loose): - overall: cc#0,DELSIZED - e#1,SET - point: cc#0,DELSIZED - e#1,SET + overall: cc#b36028797018963966,DELSIZED - e#1,SET + point: cc#b36028797018963966,DELSIZED - e#1,SET excise [bb, cc] @@ -130,8 +130,8 @@ Right table bounds: overall: d#1,SET - e#1,SET point: d#1,SET - e#1,SET Right table bounds (loose): - overall: d#0,DELSIZED - e#1,SET - point: d#0,DELSIZED - e#1,SET + overall: d#b36028797018963966,DELSIZED - e#1,SET + point: d#b36028797018963966,DELSIZED - e#1,SET # Test with a RANGEDEL. build-sst @@ -151,8 +151,8 @@ Right table bounds: overall: cc#2,RANGEDEL - e#1,SET point: cc#2,RANGEDEL - e#1,SET Right table bounds (loose): - overall: cc#0,DELSIZED - e#1,SET - point: cc#0,DELSIZED - e#1,SET + overall: cc#b36028797018963966,DELSIZED - e#1,SET + point: cc#b36028797018963966,DELSIZED - e#1,SET excise [b, b] @@ -182,8 +182,8 @@ Right table bounds: overall: e#1,SET - e#1,SET point: e#1,SET - e#1,SET Right table bounds (loose): - overall: e#0,DELSIZED - e#1,SET - point: e#0,DELSIZED - e#1,SET + overall: e#b36028797018963966,DELSIZED - e#1,SET + point: e#b36028797018963966,DELSIZED - e#1,SET excise [cc, e] @@ -216,9 +216,9 @@ Right table bounds: point: cc#1,SET - e#1,SET range: cc#2,RANGEKEYSET - d#inf,RANGEKEYSET Right table bounds (loose): - overall: cc#0,DELSIZED - e#1,SET - point: cc#0,DELSIZED - e#1,SET - range: cc#0,RANGEKEYSET - d#inf,RANGEKEYSET + overall: cc#b36028797018963966,DELSIZED - e#1,SET + point: cc#b36028797018963966,DELSIZED - e#1,SET + range: cc#b36028797018963966,RANGEKEYSET - d#inf,RANGEKEYSET excise [b, cc] @@ -240,9 +240,8 @@ Right table bounds: overall: e#1,SET - e#1,SET point: e#1,SET - e#1,SET Right table bounds (loose): - overall: e#0,DELSIZED - e#1,SET - point: e#0,DELSIZED - e#1,SET - range: e#0,RANGEKEYSET - d#inf,RANGEKEYSET + overall: e#b36028797018963966,DELSIZED - e#1,SET + point: e#b36028797018963966,DELSIZED - e#1,SET excise [cc, e] diff --git a/testdata/flushable_ingest b/testdata/flushable_ingest index f98e004b0db..89cb559490f 100644 --- a/testdata/flushable_ingest +++ b/testdata/flushable_ingest @@ -38,7 +38,7 @@ ext ext1 ext2 ext3 -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 # Ingest can complete despite the flush being blocked. @@ -62,7 +62,7 @@ LOCK MANIFEST-000001 OPTIONS-000002 ext -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 allowFlush @@ -96,7 +96,7 @@ LOCK MANIFEST-000001 OPTIONS-000002 ext -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 # Test basic WAL replay @@ -117,7 +117,7 @@ LOCK MANIFEST-000001 OPTIONS-000002 ext -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 open @@ -210,7 +210,7 @@ MANIFEST-000001 OPTIONS-000002 ext ext5 -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 allowFlush @@ -440,7 +440,7 @@ LOCK MANIFEST-000001 OPTIONS-000002 ext -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 close @@ -460,7 +460,7 @@ LOCK MANIFEST-000001 OPTIONS-000002 ext -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 open @@ -493,7 +493,7 @@ MANIFEST-000001 MANIFEST-000012 OPTIONS-000010 ext -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000002.MANIFEST-000012 # Make sure that the new mutable memtable can accept writes. @@ -636,7 +636,7 @@ LOCK MANIFEST-000001 OPTIONS-000002 ext -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 close @@ -655,7 +655,7 @@ MANIFEST-000001 OPTIONS-000002 ext ext1 -marker.format-version.000017.030 +marker.format-version.000018.031 marker.manifest.000001.MANIFEST-000001 open diff --git a/tool/testdata/db_upgrade b/tool/testdata/db_upgrade index e57d67e4a5c..e283bb27918 100644 --- a/tool/testdata/db_upgrade +++ b/tool/testdata/db_upgrade @@ -27,7 +27,7 @@ db get foo yellow db upgrade foo ---- ---- -Upgrading DB from internal version 16 to 30. +Upgrading DB from internal version 16 to 31. WARNING!!! This DB will not be usable with older versions of Pebble! @@ -43,7 +43,7 @@ Continue? [Y/N] Error: EOF db upgrade foo --yes ---- -Upgrading DB from internal version 16 to 30. +Upgrading DB from internal version 16 to 31. Upgrade complete. db get foo blue @@ -56,4 +56,4 @@ db get foo yellow db upgrade foo ---- -DB is already at internal version 30. +DB is already at internal version 31.