From 3431fa6d5f83fe0de7a2aa51c5928a4e85ee5a09 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:08:03 +0000 Subject: [PATCH 01/16] excise: tighten loose{Left,Right}TableBounds `looseLeftTableBounds` and `looseRightTableBounds` synthesize loose bounds for the post-excise virtual tables when the caller chose not to consult the original SST for tight bounds. Two latent issues: 1. Both helpers unconditionally extended bounds for each key type present on the original table, even when all keys of that type fell entirely outside the excised portion. The synthesized largest (left table, clamped at `exciseSpanStart`) or smallest (right table, clamped at `exciseSpanEnd`) could then end up on the wrong side of the original's preserved bound, producing a table where `smallest > largest` and failing `TableMetadata.Validate`. This change gates each key-type contribution on whether the original actually extends past the excise boundary on the relevant side. 2. `looseRightTableBounds` copied the original's smallest internal key verbatim, which carries the original's trailer (often a tiny `seqnum=0, kind=DELSIZED`). A real range tombstone in the file at the same user key with a larger trailer then violated the rangeDelIter's lower-bound assertion in invariants builds. Since the bound is loose either way, synthesize the smallest with the maximum non-sentinel trailer (`SeqNumMax-1`, `InternalKeyKindMaxForSSTable`); strictly safer. Testdata in `testdata/excise` and `testdata/excise_bounds` is updated to reflect the new synthesized trailer in the right-table-loose case. --- excise.go | 51 +++++++++++++++++++++++++++++++++--------- testdata/excise | 6 ++--- testdata/excise_bounds | 35 ++++++++++++++--------------- 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/excise.go b/excise.go index 7ff1a0be5b8..544d97110af 100644 --- a/excise.go +++ b/excise.go @@ -262,17 +262,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 +300,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()) } } 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] From 608a93f0ec638d8a964328602fda9b898ca882fa Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:08:31 +0000 Subject: [PATCH 02/16] excise: cap blob references at original size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `determineExcisedTableBlobReferences` scales each blob reference's `ValueSize` proportionally from the original table to the excised table. The scaling factor (`excisedTable.Size / originalSize`) is normally <= 1, but size-estimate noise can produce a factor > 1 — for example when `excisedTable.Size` is the loose estimate from `determineExcisedTableSize` and exceeds the (also-estimated) `originalSize`. In that case the scaled `ValueSize` exceeds the physical blob file's `ValueSize`, tripping the invariant check in `MakeBlobReference` on manifest replay. Cap the scaled value at the original reference's `ValueSize`. The excised table is a sub-range of the original; its share of any referenced blob file cannot legitimately exceed the original's share, so the cap is a strictly-tighter bound rather than a behavior change. --- excise.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/excise.go b/excise.go index 544d97110af..3c0a0431b71 100644 --- a/excise.go +++ b/excise.go @@ -479,6 +479,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, @@ -490,7 +499,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 } From f7c0e7d69ded974c4aa008e5755b7151f19f7e34 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:10:32 +0000 Subject: [PATCH 03/16] db: add flushIfOverlapping helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a private `flushIfOverlapping(span, extraBoundsLocked)` helper for features that mutate file metadata in a key range and need any data still in the memtable for that range to be moved to SSTs first. `extraBoundsLocked`, if non-nil, is invoked with `d.mu` held and may read from `DB.mu`-protected state to produce additional overlap-check bounds — for example, a caller can extend the overlap check with snapshot-protected ranges so that a snapshot reader's view of the LSM is preserved across the metadata mutation. The callback returns internal `bounded` values, so the helper is package-private. The helper itself has no knowledge of its callers' semantics; the callback is the seam. Foundation for `DeleteSuffixRange` (queued in this branch) and the planned cloning-with-prefix-replacements feature, both of which act on file metadata and need a memtable flush as a prerequisite. Each will pass its own `extraBoundsLocked` callback as appropriate. Tests in `flush_test.go` cover the `nil` callback path (basic span-only overlap check against point keys, range deletes, range keys, and boundary semantics), the non-nil callback path (extra bounds trigger a flush when the span itself wouldn't), and error paths (closed DB, read-only, invalid `KeyRange`). --- db.go | 69 ++++++++++++++++++++++++++++ flush_test.go | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) 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/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)) + }) +} From adfeac5a3b0b7e5266f06c305843fcf8e38edc70 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 20 May 2026 04:45:29 +0000 Subject: [PATCH 04/16] blockiter,manifest: add SuffixMask transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `SuffixMask` (a `[Lower, Upper)` range over the comparer's suffix ordering) and an ordered list `SuffixMasks` to `blockiter.Transforms`, `blockiter.FragmentTransforms`, and `manifest.TableMetadata`. Keys whose suffix falls in any of the listed ranges are hidden during iteration; keys with no suffix are never hidden. The list shape lets repeated `DeleteSuffixRange` calls accumulate per file: contiguous ranges merge in place via `expandSuffixMask`, disjoint ranges append. The list and the byte slices it holds are immutable by convention — any mutator clones first. This is documented on each field. `Lower`/`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 (inclusive) and `Upper` is the oldest (exclusive). Encoding: a single new `customTagSuffixMask` (tag 71) on the existing `NewFile4` record carries one (Lower, Upper) pair. The encoder emits one tag per list entry; the decoder appends each occurrence. No on-wire structure change beyond the new tag. The new `FormatSuffixMask` format major version gates writers from emitting the tag at all. `DebugString` / `ParseTableMetadataDebug` render each entry as `suffixmask:[loHex-upHex)`; the field is one token because `-` is a debug-parser separator. Tests: `TestVersionEditRoundTripSuffixMask` covers a hand-picked set plus a randomized roundtrip generating 0..N masks per file (catches length-prefix arithmetic and asymmetric bound sizes). A `suffixmask` case is added to `TestTableMetadata_ParseRoundTrip`. --- format_major_version.go | 9 +++ format_major_version_test.go | 7 +- internal/manifest/table_metadata.go | 35 ++++++++++ internal/manifest/table_metadata_test.go | 10 ++- internal/manifest/version_edit.go | 56 ++++++++++++++- internal/manifest/version_edit_test.go | 86 ++++++++++++++++++++++++ sstable/blockiter/transforms.go | 53 ++++++++++++++- sstable/reader_common.go | 2 + testdata/checkpoint | 68 +++++++++++-------- testdata/checkpoint_shared | 29 ++++---- testdata/event_listener | 12 +++- testdata/flushable_ingest | 20 +++--- tool/testdata/db_upgrade | 6 +- 13 files changed, 330 insertions(+), 63 deletions(-) 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/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/sstable/blockiter/transforms.go b/sstable/blockiter/transforms.go index 62df229f0d3..c566f984cd0 100644 --- a/sstable/blockiter/transforms.go +++ b/sstable/blockiter/transforms.go @@ -12,6 +12,39 @@ 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). +// +// Files with SyntheticSuffix are handled at the file level by +// DeleteSuffixRange, not by per-row masking: since every key has the +// same suffix, the file is either excised entirely or left untouched. +// +// 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 +58,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 +76,7 @@ var NoTransforms = Transforms{} func (t *Transforms) NoTransforms() bool { return t.SyntheticSeqNum == 0 && !t.HideObsoletePoints && + len(t.SuffixMasks) == 0 && t.SyntheticPrefixAndSuffix.IsUnset() } @@ -60,12 +101,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/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/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/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/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. From 80bd2c240cbfdd008fdf90ea48a549d9ce967c2f Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:11:17 +0000 Subject: [PATCH 05/16] colblk,cockroachkvs,rowblk: row-level SuffixMask filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the runtime portion of `SuffixMask`: a data-block iterator (row-based or columnar) configured with `Transforms.SuffixMasks` hides every row whose effective suffix falls within any mask's `[Lower, Upper)` range, as ordered by `ComparePointSuffixes`. Rows with an empty suffix are never hidden, matching the documented DSR contract. Three components: 1. Per-row check in both data-block iterators. The columnar path (`colblk/data_block.go::isSuffixMasked`) delegates to a new `KeySeeker.IsMaskedBySuffixMask` method; the row-based path (`rowblk/rowblk_iter.go`) inlines the comparison at four positioning sites in lockstep with the file's existing manually- inlined hot-path style. Both correctly handle `SyntheticSuffix`: the effective suffix of a non-empty stored suffix is the synthetic one, so the mask check operates against the substituted suffix. 2. Interleaving with `HideObsoletePoints`. `skipHiddenForward`/ `skipHiddenBackward` (colblk) advance past mask-hidden rows while also respecting the obsolete-point predicate, with the per-call `nextObsoletePoint` cursor reinitialized on mask-induced jumps so the two filters compose correctly. 3. IndexIter must NOT apply `SuffixMasks`. The row-based index iter (`rowblk/rowblk_index_iter.go`) wraps `rowblk.Iter`; without this fix it forwarded `Transforms.SuffixMasks` to the underlying iter, which then evaluated the mask against the index block's separator keys and silently dropped data blocks whose separator's suffix happened to fall in the masked range. `Init`/`InitHandle` now clear `SuffixMasks` before delegating. `TestIndexIterIgnoresSuffixMask` is the regression test. A nil-check is added in `rowblk_iter.go::SeekLT`'s synthetic-suffix correction branch: `First()` can now return nil if every row in the block is hidden (by the mask or by `HideObsoletePoints`), and the prior code unconditionally dereferenced the returned key. Init-time assertions in `Iter.Init`/`Iter.InitHandle` require a non-nil `ComparePointSuffixes` whenever `SuffixMasks` is non-empty — silently leaking masked keys due to a missing comparer would be a correctness regression rather than a missing feature. Oracle tests in `data_block_test.go::TestDataBlockIterSuffixMaskOracle` and `rowblk_iter_test.go::TestBlockIterSuffixMaskOracle` cross-product mask × obsolete × all positioning methods; cockroachkvs gains a fast- path `IsMaskedBySuffixMask` that inline-decodes MVCC suffix bounds. --- cockroachkvs/blockproperties.go | 11 + cockroachkvs/cockroachkvs.go | 54 +++ cockroachkvs/suffix_mask_test.go | 182 +++++++ sstable/colblk/data_block.go | 226 +++++++-- sstable/colblk/data_block_test.go | 457 ++++++++++++++++++ .../colblk/suffix_mask_test_helpers_test.go | 41 ++ .../colblk/testdata/data_block/suffix_mask | 142 ++++++ sstable/rowblk/rowblk_index_iter.go | 9 + sstable/rowblk/rowblk_index_iter_test.go | 75 +++ sstable/rowblk/rowblk_iter.go | 105 +++- sstable/rowblk/rowblk_iter_test.go | 250 ++++++++++ 11 files changed, 1502 insertions(+), 50 deletions(-) create mode 100644 cockroachkvs/suffix_mask_test.go create mode 100644 sstable/colblk/suffix_mask_test_helpers_test.go create mode 100644 sstable/colblk/testdata/data_block/suffix_mask create mode 100644 sstable/rowblk/rowblk_index_iter_test.go diff --git a/cockroachkvs/blockproperties.go b/cockroachkvs/blockproperties.go index 08ca0e7914d..37ef3a3d810 100644 --- a/cockroachkvs/blockproperties.go +++ b/cockroachkvs/blockproperties.go @@ -144,6 +144,17 @@ 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) +} + 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..369dc24e1d0 --- /dev/null +++ b/cockroachkvs/suffix_mask_test.go @@ -0,0 +1,182 @@ +// 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)() + + bound := testEncodeMVCCSuffix(100, 0) + filter := MakeSuffixMaskBlockPropertyFilter(bound) + require.True(t, filter != nil) + + // Empty bound should return nil filter. + require.True(t, MakeSuffixMaskBlockPropertyFilter(nil) == nil) +} + +// 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/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/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/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) + } +} From d04fde2cf93febd5ca32f6a406341b6941af6e27 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 20 May 2026 04:45:57 +0000 Subject: [PATCH 06/16] sstable: filter range key entries by SuffixMask Implement SuffixMask filtering for range key and range deletion fragment iterators in both columnar (`keyspan.go`) and row-oriented (`rowblk_fragment_iter.go`) paths. Entries whose suffix falls within `[Lower, Upper)` are removed from spans during iteration. If all entries in a span are masked, the span is skipped entirely. `keyspanIter.init` now takes the suffix comparer alongside the key comparer (previously it was assigned separately by `NewKeyspanIter`). Both `keyspanIter.init` and `rowblk.NewFragmentIter` fail loudly when a `SuffixMask` is configured without a non-nil `ComparePointSuffixes`; this prevents the mask from silently leaking range key entries that should be hidden. Add `suffix-mask-{lower,upper}` directives to `TestKeyspanBlock`'s data-driven harness, plus testdata cases that exercise: - partial mask filtering within a span, - fully-masked-span skip in both `Next`/`Prev` directions, - `SeekGE` / `SeekLT` landing inside a fully-masked span, - `RANGEKEYDEL` (suffixless) preservation alongside masked `RANGEKEYSET` entries, - all-keys-masked across every span. Add the parallel multi-span / mixed-kind cases to the row-oriented `testdata/rowblk_fragment_iter`. --- sstable/colblk/keyspan.go | 113 ++++++--- sstable/colblk/keyspan_test.go | 10 +- sstable/colblk/testdata/keyspan_block | 223 ++++++++++++++++++ sstable/reader.go | 4 +- sstable/rowblk/rowblk_fragment_iter.go | 173 +++++++++----- sstable/rowblk/rowblk_fragment_iter_test.go | 1 + .../rowblk/suffix_mask_test_helpers_test.go | 41 ++++ sstable/rowblk/testdata/rowblk_fragment_iter | 121 ++++++++++ 8 files changed, 598 insertions(+), 88 deletions(-) create mode 100644 sstable/rowblk/suffix_mask_test_helpers_test.go 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/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/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/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: From 07934480d72cda6b6838fe7b38dfe0cce2ee3a18 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:22:09 +0000 Subject: [PATCH 07/16] db: add DeleteSuffixRange API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `DB.DeleteSuffixRange(ctx, span, lower, upper)`, which hides every point key and range-key entry within `span` whose suffix falls in `[lower, upper)` per `ComparePointSuffixes`. The masked entries are invisible to all subsequent iterators and are physically dropped during compaction. The operation is metadata-only at call time (no row-level writes); per-row filtering is applied at iteration via the `Transforms.SuffixMasks` machinery added in prior commits. This commit lands the core shape: a public API that, for every overlapping SST, attaches the new mask to the (possibly virtual) inside portion of the file. Files fully contained in the span gain a mask directly; straddling files are split via `exciseTable` into up to three virtual tables so the mask applies only to the inside one. Repeated calls on the same file accumulate masks in a per-table list (`expandSuffixMask` merges contiguous ranges; disjoint ranges append). A new comment in `file_cache.go::newPointIter` documents why no block- or file-level property filter is installed for `SuffixMask` (the collector doesn't currently track suffixless-key presence, so a filter could incorrectly skip blocks containing unfiltered suffixless keys). A later commit adds a file-level skip optimization gated on a caller- supplied per-file aggregate check. Several follow-up layers are deliberately deferred to subsequent commits so each can be reviewed in isolation as an enhancement to the core: - `SyntheticSuffix` file-level excise optimization (the file's point keys all share an effective suffix, so the per-row path can be short-circuited to an excise when the synth suffix is in mask). - EFOS-protected-range overlap handling (DSR must transition any pre-file-only EFOS that overlaps the span before attaching its mask, otherwise the EFOS observes a post-DSR view). - In-progress compaction cancellation (a compaction picked against the pre-DSR version may hit a manifest blob-ref assertion when it tries to delete a table DSR has replaced with a virtual one). - Copy-compaction `SuffixMasks` propagation (copy-compactions don't iterate through the per-row filter, so the mask must ride along on the destination metadata). - Block-property-aggregate file-level skip optimization. - `SuffixMasks` on `ExternalFile`/`SharedSSTMeta` so DSR-attached masks survive shared/external `Replicate`. Tests in `suffix_mask_*_test.go` cover validation, the `expandSuffixMask` semantics across same-length and variable-length suffix encodings, the per-row filter machinery via SSTable-direct iteration (rowblk + colblk), oracle correctness against a user-space implementation of the same predicate, and the straddling-file splitting via `exciseTable`. The data-driven test disables automatic compactions to keep test sequencing deterministic; later commits add explicit cancellation regression tests with compactions enabled. `excise.go` propagates `SuffixMasks` onto the left and right virtual tables produced by `exciseTable` so that masks attached to a file survive that file being split — required for both the DSR straddling path here and any subsequent `Excise` on a DSR-masked file. --- excise.go | 2 + file_cache.go | 21 + sstable/blockiter/transforms.go | 14 +- suffix_mask.go | 441 +++++++++++++++++++++ suffix_mask_compaction_test.go | 97 +++++ suffix_mask_excise_test.go | 588 ++++++++++++++++++++++++++++ suffix_mask_sst_test.go | 486 ++++++++++++++++++++++++ suffix_mask_test.go | 654 ++++++++++++++++++++++++++++++++ suffix_mask_testutils_test.go | 223 +++++++++++ testdata/delete_suffix_range | 370 ++++++++++++++++++ 10 files changed, 2893 insertions(+), 3 deletions(-) create mode 100644 suffix_mask.go create mode 100644 suffix_mask_compaction_test.go create mode 100644 suffix_mask_excise_test.go create mode 100644 suffix_mask_sst_test.go create mode 100644 suffix_mask_test.go create mode 100644 suffix_mask_testutils_test.go create mode 100644 testdata/delete_suffix_range diff --git a/excise.go b/excise.go index 3c0a0431b71..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 { 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/sstable/blockiter/transforms.go b/sstable/blockiter/transforms.go index c566f984cd0..5360926bdbd 100644 --- a/sstable/blockiter/transforms.go +++ b/sstable/blockiter/transforms.go @@ -25,9 +25,17 @@ import ( // timestamp to hide (e.g. infinity) and Upper would be T (exclusive, so // keys at T remain visible). // -// Files with SyntheticSuffix are handled at the file level by -// DeleteSuffixRange, not by per-row masking: since every key has the -// same suffix, the file is either excised entirely or left untouched. +// 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. diff --git a/suffix_mask.go b/suffix_mask.go new file mode 100644 index 00000000000..2faf36e844e --- /dev/null +++ b/suffix_mask.go @@ -0,0 +1,441 @@ +// 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, 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" +) + +// 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. +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, + ) + } + + if err := d.flushIfOverlapping(span, nil); 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) { + 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 + } + + // 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) + } + } + } + + return versionUpdate{ + VE: ve, + InProgressCompactionsFn: func() []compactionInfo { + return d.getInProgressCompactionInfoLocked(nil) + }, + }, nil + }) + if err != nil { + return err + } + d.updateReadStateLocked(d.opts.DebugCheck) + return 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) + } + } +} + +// 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..2f87e814025 --- /dev/null +++ b/suffix_mask_compaction_test.go @@ -0,0 +1,97 @@ +// 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" + "testing" + + "github.com/cockroachdb/crlib/testutils/leaktest" + "github.com/cockroachdb/crlib/testutils/require" + "github.com/cockroachdb/pebble/internal/manifest" +) + +// TestSuffixMaskClearedAfterCompaction verifies the one novel +// compaction-time invariant the SuffixMask feature introduces: when a +// compaction physically rewrites a masked file, the output table must +// not carry the mask forward. +// +// 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)() + + 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 + )) + + // Sanity: at least one file carries the mask before compaction. + 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.True(t, maskedBefore >= 1) + + // 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..9eb6d7b7267 --- /dev/null +++ b/suffix_mask_excise_test.go @@ -0,0 +1,588 @@ +// 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" +) + +// 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)) + }) + } +} + +// 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..18215aaaa5b --- /dev/null +++ b/suffix_mask_test.go @@ -0,0 +1,654 @@ +// 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/testkeys" + "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) + } + }) +} diff --git a/suffix_mask_testutils_test.go b/suffix_mask_testutils_test.go new file mode 100644 index 00000000000..d20f59a5646 --- /dev/null +++ b/suffix_mask_testutils_test.go @@ -0,0 +1,223 @@ +// 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. + +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, + } + 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/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 +. From 9d5a0172c2376eec8a9e73828f6c7186a38f3300 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:25:01 +0000 Subject: [PATCH 08/16] db: short-circuit DSR on `SyntheticSuffix` files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file with a non-empty `SyntheticSuffix` substitutes the synth in for every point key's effective suffix at iteration time. `RangeKeySet` entries with non-empty original suffix get the same substitution; entries with empty original suffix retain empty (per the `SyntheticSuffix` contract — empty-suffix `RangeKeySet` entries are never masked, matching the broader DSR contract for suffixless keys). `RangeKeyDelete` entries have no per-key suffix and are also never masked. `RangeKeyUnset` is not allowed on a `SyntheticSuffix` file at all (see the assertion in `rowblk_fragment_iter.go::applySpanTransforms`). The mask answer for such a file is therefore uniform across nearly every row: every non-empty stored suffix maps to the same effective suffix. Three cases: 1. synth NOT in mask: no row in the file matches the mask. Skip the file entirely — no version edit, no mask attachment. 2. synth IN mask AND no range keys: every point key is uniformly masked. Excise the file's overlap; a per-row mask would produce the same observable behavior but at iteration-time cost. The new `exciseOverlap` helper handles the fully-contained vs straddling cases. 3. synth IN mask AND has range keys: cannot safely excise — excising would drop `RangeKeyDelete` entries and any empty-suffix `RangeKeySet` entries (both never-masked per the DSR contract). Fall through to per-row mask attachment, which honors empty suffixes via the existing filter machinery. The optimization is an enhancement on top of the core DSR API added in the prior commit: that commit's per-row attachment is correct (and the Path B per-row check in `colblk,cockroachkvs,rowblk` is synth-suffix-aware), so this commit could land later as a pure perf improvement. It lands here because it's tightly coupled to the documented file-level decision tree and is small enough to merit inclusion alongside the API. The conservative fall-through on `HasRangeKeys` is wider than strictly necessary — a synth-suffix file with range keys but no suffixless entries could still be excised — but distinguishing those cases requires `TableMetadata` bits that don't exist yet. A TODO records the optimization opportunity. `TestDeleteSuffixRangeExciseOverlap` covers the case-2 excise path end-to-end; `TestDeleteSuffixRangeSyntheticSuffixWithEmptyRangeKey` in `suffix_mask_synth_rangekey_test.go` regression-tests case 3, verifying that an empty-suffix `RangeKeySet` entry on a synth-suffix file in the mask range remains visible after DSR (i.e., we fell through to per-row instead of excising). --- suffix_mask.go | 111 +++++++++++++++++++++++++++++-- suffix_mask_excise_test.go | 132 +++++++++++++++++++++++++++++++++++++ suffix_mask_test.go | 125 +++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+), 4 deletions(-) diff --git a/suffix_mask.go b/suffix_mask.go index 2faf36e844e..53943fe34d1 100644 --- a/suffix_mask.go +++ b/suffix_mask.go @@ -11,10 +11,18 @@ // 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, 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. +// SSTable overlapping the user-key span, it picks one of two actions +// based on how the file relates to the span and the suffix range: +// +// 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. @@ -124,6 +132,56 @@ func (d *DB) DeleteSuffixRange(ctx context.Context, span KeyRange, lower, upper if !bounds.Overlaps(d.cmp, mBounds) { 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 @@ -388,6 +446,51 @@ func looseMiddleTableBounds( } } +// 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 diff --git a/suffix_mask_excise_test.go b/suffix_mask_excise_test.go index 9eb6d7b7267..49827f73c30 100644 --- a/suffix_mask_excise_test.go +++ b/suffix_mask_excise_test.go @@ -21,6 +21,10 @@ import ( "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 @@ -402,6 +406,134 @@ func TestSuffixMaskMiddleTableSize(t *testing.T) { } } +// 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 diff --git a/suffix_mask_test.go b/suffix_mask_test.go index 18215aaaa5b..9da44789a58 100644 --- a/suffix_mask_test.go +++ b/suffix_mask_test.go @@ -65,7 +65,11 @@ import ( "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/keyspan" "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" ) @@ -652,3 +656,124 @@ func TestDeleteSuffixRangeDataDriven(t *testing.T) { } }) } + +// 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. From 549ddf75c855995bcd693323b32e1d7f056036bc Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:25:45 +0000 Subject: [PATCH 09/16] db: transition file-only EFOSes around DSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-file-only `EventuallyFileOnlySnapshot` (EFOS) reads via `snapshotIterOpts{seqNum: es.seqNum}` — the live LSM filtered by seqnum, NOT a pinned `*Version`. The seqnum filter is not a defense against metadata changes like mask attachment (those don't bump seqnums), so a pre-file-only EFOS whose protected range overlaps a DSR span would observe our mask immediately, violating the EFOS contract that protected-range reads remain consistent across non-additive LSM mutations. The fix has two parts: 1. Extend `flushIfOverlapping`'s overlap check with the protected ranges of any pre-file-only EFOS whose protected ranges overlap our span. This forces a flush of any EFOS-overlapping memtable content, and the flush completion's `maybeTransitionSnapshotsToFileOnlyLocked` transitions the EFOS to file-only — pinning a pre-DSR `*Version` — before we attach the mask. 2. Inside DSR's `UpdateVersionLocked` updateFn, call `maybeTransitionSnapshotsToFileOnlyLocked` explicitly. The flush path covers EFOSes that needed a flush to unblock the transition; this explicit call covers EFOSes that didn't need a flush (no blocking memtable content) but still need to transition before our VE attaches the mask. Without the explicit call, such an EFOS would remain pre-file-only past our `UpdateVersionLocked` and transition later (triggered by an unrelated flush), at which point the version it pins already includes our mask. Both parts together close the EFOS-vs-DSR race the metamorphic test surfaced as cross-config divergence (one config's EFOS transitioned before our mask, another after, producing different read results for the same op stream). Classic `Snapshot` observers cannot be protected the same way (no protected ranges, no transition mechanism); their post-DSR reads observe the masked LSM, matching the documented excise-with-classic-snapshot behavior. A deterministic single-process unit test for this race is awkward — it requires interleaving an EFOS-blocking flush with a DSR's version edit application, which depends on goroutine scheduling. Reverting either change in isolation causes the metamorphic `TestMetaCockroachKVs` to flake at a ~37% rate within ~3 iterations; both together produce ≥120 consecutive clean runs across all metamorphic variants. The metamorphic coverage is the regression test. --- suffix_mask.go | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/suffix_mask.go b/suffix_mask.go index 53943fe34d1..9700bd3ae47 100644 --- a/suffix_mask.go +++ b/suffix_mask.go @@ -102,7 +102,24 @@ func (d *DB) DeleteSuffixRange(ctx context.Context, span KeyRange, lower, upper ) } - if err := d.flushIfOverlapping(span, nil); err != nil { + // 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 } @@ -112,6 +129,19 @@ func (d *DB) DeleteSuffixRange(ctx context.Context, span KeyRange, lower, upper 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), From c2baca54148784b2057369a79e6c1725e10c544f Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:29:19 +0000 Subject: [PATCH 10/16] db: cancel in-progress compactions overlapping a DSR span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Fix: inside DSR's `UpdateVersionLocked` updateFn (which holds the manifest log lock), iterate `d.mu.compact.inProgress` and call `Cancel()` on 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 conflicting version edit. The retry loop in `Compact` re-picks against the post-DSR version. An earlier shape waited for `compactingCount == 0` before entering `UpdateVersionLocked`. That's 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. This commit also adds a test-only hook `Options.private.testingDuringCompactionIOFunc`, invoked from a default table compaction's I/O phase (after the compaction has written its outputs and constructed its `VersionEdit`, but before re-acquiring `DB.mu` to apply it). The hook provides a deterministic window for tests to inject a DSR call that races with the in-progress compaction. `TestDeleteSuffixRangeCancelsOverlappingCompaction` uses the hook to pause a compaction mid-I/O, runs DSR over the same span, and verifies that the compaction's apply observes the cancel cleanly (no manifest assertion fires). Without the cancel logic in this commit, the test deadlocks via the original `compactingCount` wait OR (with the wait removed) trips the `deleted table reference to blob file not known` assertion, depending on which intermediate shape is being exercised. --- compaction.go | 3 + options.go | 9 ++ suffix_mask.go | 31 ++++++ suffix_mask_compaction_test.go | 171 ++++++++++++++++++++++++++++++++- 4 files changed, 210 insertions(+), 4 deletions(-) diff --git a/compaction.go b/compaction.go index 426a4eaec82..27ecae7b22a 100644 --- a/compaction.go +++ b/compaction.go @@ -2725,6 +2725,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/options.go b/options.go index 4fedeebbceb..dc357b37575 100644 --- a/options.go +++ b/options.go @@ -1228,6 +1228,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/suffix_mask.go b/suffix_mask.go index 9700bd3ae47..b4f6b65a9f6 100644 --- a/suffix_mask.go +++ b/suffix_mask.go @@ -271,6 +271,37 @@ func (d *DB) DeleteSuffixRange(ctx context.Context, span KeyRange, lower, upper } } + // 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 { diff --git a/suffix_mask_compaction_test.go b/suffix_mask_compaction_test.go index 2f87e814025..77911ee860a 100644 --- a/suffix_mask_compaction_test.go +++ b/suffix_mask_compaction_test.go @@ -6,18 +6,181 @@ 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" ) -// TestSuffixMaskClearedAfterCompaction verifies the one novel -// compaction-time invariant the SuffixMask feature introduces: when a -// compaction physically rewrites a masked file, the output table must -// not carry the mask forward. +// 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 From 48081aace3fed3a3b614817ef4ef4ae0a1004d96 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:30:22 +0000 Subject: [PATCH 11/16] compaction: propagate `SuffixMasks` through copy compactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runCopyCompaction` produces an output table whose backing is a byte-for-byte copy of an input table; it does NOT iterate over the input through the per-row `SuffixMask` filter. If the input has `SuffixMasks` attached, those masks must ride along on the output's `TableMetadata` — otherwise the copy would silently un-hide rows that the source had masked. This affects the `Download` path (external -> local), which is the only caller of `runCopyCompaction` in production. This is in contrast to `runDefaultTableCompaction`, which iterates inputs through the standard iterator stack (including the per-row mask filter) and so produces a post-filter output that must NOT carry the mask forward. A direct unit test is awkward because `runCopyCompaction` is only chosen for a narrow combination of conditions (external backing, no per-row work). Coverage is via the metamorphic test in the test infrastructure stage, where `Download` interleaved with DSR exercises the path. --- compaction.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compaction.go b/compaction.go index 27ecae7b22a..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) From 6c59cd2cf4c8748018b066a3046656a61ab8035a Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 20 May 2026 19:40:28 +0000 Subject: [PATCH 12/16] db: skip DeleteSuffixRange files via block-property aggregate `DeleteSuffixRange` previously attached a `SuffixMask` to every sstable whose user-key bounds overlapped the requested span, even when the file demonstrably contained no suffixes in the mask's `[lower, upper)` range. The masks were observationally no-ops but incurred a manifest edit, a virtual-table allocation, and per-row filter overhead for the lifetime of the file. Add an optional `Options.SuffixRangeIntersects` hook consulted on each overlapping file before a mask is attached. When the hook returns false, the file is skipped entirely (no version edit, no mask, no excise). When unset, `DeleteSuffixRange` behaves as before. `cockroachkvs.SuffixRangeIntersectsTable` is the ready-to-use implementation: it decodes the file's `MVCCTimeInterval` block- property aggregate (already collected by `cockroachkvs.BlockPropertyCollectors`) and tests it against the mask's wall-time range. It fails open (assumes intersection) on missing aggregates, undecodable inputs, or non-MVCC suffixes. Under invariants builds, a 25% metamorphic bypass attaches a no-op mask anyway, keeping the unfiltered code path exercised even when the optimization would normally elide it. A package-level `suffixMaskSkipBypassDisabled` toggle lets focused tests disable the randomness for deterministic assertions. Tests: - `cockroachkvs.TestSuffixRangeIntersectsTable`: synthesizes aggregates and verifies intersection at boundary cases. - `TestDeleteSuffixRangeSkipsNonOverlappingFiles`: builds files with disjoint wall-time ranges and asserts only the matching file receives a mask (with the bypass disabled). - `TestDeleteSuffixRangeSkipBypassExercisesPath`: statistically verifies the 25% bypass eventually fires and that the resulting no-op mask doesn't change visible scan output. --- cockroachkvs/blockproperties.go | 66 ++++++++++++ cockroachkvs/suffix_mask_test.go | 177 +++++++++++++++++++++++++++++++ options.go | 18 ++++ suffix_mask.go | 62 ++++++++++- suffix_mask_compaction_test.go | 13 ++- suffix_mask_test.go | 171 +++++++++++++++++++++++++++++ suffix_mask_testutils_test.go | 4 + 7 files changed, 508 insertions(+), 3 deletions(-) diff --git a/cockroachkvs/blockproperties.go b/cockroachkvs/blockproperties.go index 37ef3a3d810..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" @@ -155,6 +156,71 @@ func MakeSuffixMaskBlockPropertyFilter(suffixUpperBound []byte) sstable.BlockPro 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/suffix_mask_test.go b/cockroachkvs/suffix_mask_test.go index 369dc24e1d0..6e313fa8c00 100644 --- a/cockroachkvs/suffix_mask_test.go +++ b/cockroachkvs/suffix_mask_test.go @@ -36,12 +36,189 @@ func testEncodeMVCCSuffix(wallTime uint64, logical uint32) []byte { 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 diff --git a/options.go b/options.go index dc357b37575..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 diff --git a/suffix_mask.go b/suffix_mask.go index b4f6b65a9f6..ac2f3a53d05 100644 --- a/suffix_mask.go +++ b/suffix_mask.go @@ -11,9 +11,11 @@ // 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 two actions +// 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 @@ -52,6 +54,7 @@ import ( "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 @@ -79,6 +82,13 @@ import ( // 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) @@ -162,6 +172,24 @@ func (d *DB) DeleteSuffixRange(ctx context.Context, span KeyRange, lower, upper 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; @@ -316,6 +344,38 @@ func (d *DB) DeleteSuffixRange(ctx context.Context, span KeyRange, lower, upper 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( diff --git a/suffix_mask_compaction_test.go b/suffix_mask_compaction_test.go index 77911ee860a..294b19c581a 100644 --- a/suffix_mask_compaction_test.go +++ b/suffix_mask_compaction_test.go @@ -196,6 +196,12 @@ func TestDeleteSuffixRangeCancelsOverlappingCompaction(t *testing.T) { 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() @@ -225,7 +231,10 @@ func TestSuffixMaskClearedAfterCompaction(t *testing.T) { testMakeSuffix(10, 0), // upper bound = oldest wall to keep visible )) - // Sanity: at least one file carries the mask before compaction. + // 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() { @@ -234,7 +243,7 @@ func TestSuffixMaskClearedAfterCompaction(t *testing.T) { } } } - require.True(t, maskedBefore >= 1) + 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)) diff --git a/suffix_mask_test.go b/suffix_mask_test.go index 9da44789a58..78c87c08178 100644 --- a/suffix_mask_test.go +++ b/suffix_mask_test.go @@ -66,7 +66,9 @@ import ( "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" @@ -777,3 +779,172 @@ func TestDeleteSuffixRangeSyntheticSuffixWithEmptyRangeKey(t *testing.T) { // 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) +} diff --git a/suffix_mask_testutils_test.go b/suffix_mask_testutils_test.go index d20f59a5646..9403b80903b 100644 --- a/suffix_mask_testutils_test.go +++ b/suffix_mask_testutils_test.go @@ -34,6 +34,9 @@ // 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 @@ -182,6 +185,7 @@ func suffixMaskTestDB(t *testing.T) (*DB, vfs.FS) { DebugCheck: DebugCheckLevels, Logger: testutils.Logger{T: t}, BlockPropertyCollectors: cockroachkvs.BlockPropertyCollectors, + SuffixRangeIntersects: cockroachkvs.SuffixRangeIntersectsTable, } db, err := Open("", opts) require.NoError(t, err) From 59d2bffda7875284870feb8ce86ee3afe9b31154 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 13:30:19 +0000 Subject: [PATCH 13/16] pebble: carry `SuffixMasks` on `ExternalFile` and `SharedSSTMeta` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DeleteSuffixRange` attaches per-file `SuffixMasks` to a vsst's `TableMetadata` to hide rows whose suffix falls in a configured range. Masks are a read-time presentation of the file (analogous to `SyntheticSuffix`), not a property of the backing bytes — the destination of a `Replicate` must reconstruct the same masks on its vsst to observe the source's view. Previously, the source-side emission paths (`SharedSSTMeta.cloneFromFileMeta` and the `ExternalFile` builder in `ScanInternal`) dropped masks, and the destination-side ingest paths (`ingestSynthesizeShared`, `ingestLoad1External`) had nowhere to receive them. A shared/external Replicate of a masked vsst silently un-hid the masked rows on the destination — a real correctness gap that the metamorphic test worked around with a test-side punt (`t.dsrInOps`) disabling shared/external Replicate when DSR was in the op stream. This change adds `SuffixMasks []sstable.SuffixMask` to both `SharedSSTMeta` and `ExternalFile`, deep-clones the source's mask list on emission, and propagates it onto the destination's `TableMetadata` on ingest. The destination's `IngestExternalFiles`/`IngestAndExcise` paths gain an FMV gate refusing non-empty `SuffixMasks` when the destination is below `FormatSuffixMask` — silent loss of masking would be a correctness regression rather than a missing feature. A new test (`TestExternalFileSuffixMasksRoundTrip`) exercises the full emit-then-ingest cycle: source DB ingests + applies DSR, `ScanInternal` emits an `ExternalFile` carrying the mask, destination DB ingests via `IngestAndExcise` and reproduces the source's hidden/visible pattern. A companion test (`TestExternalFileSuffixMasksFMVGate`) verifies the FMV refusal. Note: no CockroachDB caller currently sets these fields. The feature is added because it conceptually completes the DSR API surface (a vsst property must survive cross-instance Replicate to preserve the user's view) and because it lets the metamorphic test stop punting on shared/external Replicate when DSR is present — see the follow-up commit that removes the `dsrInOps` gate. --- ingest.go | 32 +++++++++ scan_internal.go | 26 ++++++++ suffix_mask_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) 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/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/suffix_mask_test.go b/suffix_mask_test.go index 78c87c08178..72d628f7166 100644 --- a/suffix_mask_test.go +++ b/suffix_mask_test.go @@ -948,3 +948,159 @@ func TestDeleteSuffixRangeSkipBypassExercisesPath(t *testing.T) { } 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) + } +} From 5d85e584c3d6cde6cafd2a7c8e1a47d6617134ab Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 14:33:35 +0000 Subject: [PATCH 14/16] metamorphic: add `DeleteSuffixRange` as an op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `DeleteSuffixRange` into the metamorphic harness as `deleteSuffixRangeOp`: parser/formatter, generator, key-manager update hook, and execution. The op is enabled at weight 5 in `DefaultOpConfig`, `ReadOpConfig`, and `WriteOpConfig` — typical parity with the other span-shaped DB ops. DSR requires `FormatSuffixMask` to execute. Configs in the metamorphic test vary in their starting FMV, so the generator emits a paired `RatchetFormatMajorVersion(FormatSuffixMask)` op immediately before each DSR op. Without the ratchet, configs at lower FMVs would error on DSR while configs at higher FMVs would succeed; the cross-config compare would then trip on the diverging histories. The ratchet is a no-op on configs already at or above `FormatSuffixMask`. A later commit broadens this to ratchet every DB instance (not just the DSR target) once `SuffixMasks` can flow across DBs via Replicate. DSR mutates the LSM non-additively (attaches per-file masks). Classic `Snapshot` observers cannot pin a pre-DSR `*Version` and so are unsafe in DSR's presence, similar to the documented hazard around excise. The `dsrInOps` flag (set in `test.go` if the op stream contains any DSR) is consumed by `newSnapshotOp.run` to upgrade every snapshot to an EFOS — EFOSes can transition to file-only before DSR runs (via the EFOS-protected-range overlap handling in `DB.DeleteSuffixRange`). `keyManager.update` treats DSR as a no-op for the per-key state it tracks: DSR hides at iteration time, not at the physical-record level, so the SingleDelete eligibility classification (which is about physical SET / DELETE records) is unaffected by which keys DSR has masked from view. Tests: - `delete_suffix_range_test.go`: parser format roundtrip, generator invariants, and a focused execution test exercising basic DSR semantics through the metamorphic op harness. - `testdata/parser`: format/parse roundtrip cases. --- metamorphic/config.go | 23 +++- metamorphic/delete_suffix_range_test.go | 133 ++++++++++++++++++++++++ metamorphic/generator.go | 42 ++++++++ metamorphic/key_manager.go | 17 +++ metamorphic/ops.go | 65 +++++++++++- metamorphic/parser.go | 3 + metamorphic/test.go | 13 +++ metamorphic/testdata/parser | 11 ++ 8 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 metamorphic/delete_suffix_range_test.go 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..8d1ab475554 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,47 @@ 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. 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. +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 + } + + g.add(&dbRatchetFormatMajorVersionOp{dbID: dbID, 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..e3bf8a4af7b 100644 --- a/metamorphic/ops.go +++ b/metamorphic/ops.go @@ -439,6 +439,49 @@ 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} + err := db.DeleteSuffixRange(context.Background(), span, o.lower, o.upper) + h.Recordf("%s // %v", o.formattedString(t.testOpts.KeyFormat), err) +} + +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 +1910,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 { @@ -2175,6 +2222,20 @@ func (r *replicateOp) run(t *Test, h historyRecorder) { // separation is disabled. useSharedIngest := t.testOpts.useSharedReplicate && t.testOpts.sharedStorageEnabled useExternalIngest := t.testOpts.useExternalReplicate && t.testOpts.externalStorageEnabled + // If any DeleteSuffixRange op is in the stream, fall back to the iter-based + // replicate path even when shared/external replicate is configured. The + // shared/external paths copy SST files via Shared/ExternalSSTMeta, which + // do not carry the source vsst's SuffixMasks; the destination would + // reconstruct the vsst without a mask, exposing rows the source had + // hidden. The iter-based path filters source-masked rows through + // source.NewIter before copying, so masks are effectively baked into the + // shipped data — keeping cross-config histories observable-equivalent. + // TODO(dt): teach shared/external replicate to carry SuffixMasks through + // an internal-only mechanism and re-enable them when DSR is in play. + if t.dsrInOps { + useSharedIngest = false + useExternalIngest = false + } source := t.getDB(r.source) dest := t.getDB(r.dest) 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 From c59030e1ed5544f1cb57c8a194661325ce16762e Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 13:30:37 +0000 Subject: [PATCH 15/16] metamorphic: exercise shared/external `Replicate` with DSR present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metamorphic test previously masked the shared/external Replicate + DSR cross product two ways: 1. `replicateOp.run` had a `t.dsrInOps` gate that forced the iter-based replicate fallback whenever any `DeleteSuffixRange` op appeared in the stream. The iter-based path filters source-masked rows at iter time, so masks were effectively baked into the shipped data and the destination never had to know about masks. This worked but meant the production shared/external `Replicate` paths were never exercised when DSR was in play. 2. `dbDeleteSuffixRange` emitted a `RatchetFormatMajorVersion(FormatSuffixMask)` op only for the DB that DSR runs on. In a two-instance test, DSR on `db1` ratched only `db1` while `db2` stayed at its config's starting FMV. A subsequent `Replicate` from `db1` (with masks) to `db2` (at FMV < `FormatSuffixMask`) was unreachable behind the gate above. With `SuffixMasks` now plumbed through `ExternalFile`/`SharedSSTMeta` (see prior commit), the workaround in (1) is no longer needed. This change removes the `t.dsrInOps` gate so the shared and external replicate paths actually run when DSR is in the op stream — surfacing any future bugs in the wire-format propagation. The dependency in (2) becomes load-bearing: now that the destination DB's ingest enforces an FMV gate on `SuffixMasks`, a `Replicate` to a non-ratched destination would error. `dbDeleteSuffixRange` is updated to ratchet every DB in `g.dbs` before the DSR. The ratchet is a no-op on configs already at `FormatSuffixMask` or above, and the change is necessary regardless of how many DB instances the test happens to create — masks may flow to any DB via `Replicate`. The `t.dsrInOps` variable itself is retained because the EFOS-forcing logic in `newSnapshotOp` still uses it (DSR mutates the LSM non-additively, so any snapshot taken in a stream containing DSR must be an EFOS to have any chance of pinning a pre-DSR view). --- metamorphic/generator.go | 21 ++++++++++++++++----- metamorphic/ops.go | 14 -------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/metamorphic/generator.go b/metamorphic/generator.go index 8d1ab475554..6d1043c5d35 100644 --- a/metamorphic/generator.go +++ b/metamorphic/generator.go @@ -1237,10 +1237,19 @@ func (g *generator) writerDeleteRange() { // nil-high case (DeleteSuffixRange requires a non-empty upper bound). // // A RatchetFormatMajorVersion(FormatSuffixMask) op is emitted immediately -// before the DSR. 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. +// 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() @@ -1261,7 +1270,9 @@ func (g *generator) dbDeleteSuffixRange() { return } - g.add(&dbRatchetFormatMajorVersionOp{dbID: dbID, vers: pebble.FormatSuffixMask}) + for _, id := range g.dbs { + g.add(&dbRatchetFormatMajorVersionOp{dbID: id, vers: pebble.FormatSuffixMask}) + } g.add(&deleteSuffixRangeOp{ dbID: dbID, start: start, diff --git a/metamorphic/ops.go b/metamorphic/ops.go index e3bf8a4af7b..597bf9f76d3 100644 --- a/metamorphic/ops.go +++ b/metamorphic/ops.go @@ -2222,20 +2222,6 @@ func (r *replicateOp) run(t *Test, h historyRecorder) { // separation is disabled. useSharedIngest := t.testOpts.useSharedReplicate && t.testOpts.sharedStorageEnabled useExternalIngest := t.testOpts.useExternalReplicate && t.testOpts.externalStorageEnabled - // If any DeleteSuffixRange op is in the stream, fall back to the iter-based - // replicate path even when shared/external replicate is configured. The - // shared/external paths copy SST files via Shared/ExternalSSTMeta, which - // do not carry the source vsst's SuffixMasks; the destination would - // reconstruct the vsst without a mask, exposing rows the source had - // hidden. The iter-based path filters source-masked rows through - // source.NewIter before copying, so masks are effectively baked into the - // shipped data — keeping cross-config histories observable-equivalent. - // TODO(dt): teach shared/external replicate to carry SuffixMasks through - // an internal-only mechanism and re-enable them when DSR is in play. - if t.dsrInOps { - useSharedIngest = false - useExternalIngest = false - } source := t.getDB(r.source) dest := t.getDB(r.dest) From cb65f01d9b7608b901bb91ce2ae362fbd24587fd Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 24 May 2026 18:20:54 +0000 Subject: [PATCH 16/16] metamorphic: cross-check DSR against equivalent scan+delete Adds a `useScanDeleteForDSR` test option, randomized per config at 50% frequency, that routes each `deleteSuffixRangeOp` through an explicit scan-and-delete equivalent instead of calling `DB.DeleteSuffixRange`. The recorded op history is identical in both modes (the op records as a DSR regardless), so the metamorphic harness's existing cross-config compare exercises the implementation equivalence as a property: any divergence in subsequent reads catches a bug in either DSR or in the scan-and-delete baseline. The equivalent path scans the span with `IterKeyTypePointsAndRanges`, issues `Delete(key)` per visible point key whose suffix lies in `[lower, upper)` per `ComparePointSuffixes`, and `RangeKeyUnset(start, end, suffix)` per visible range-key entry with a matching non-empty suffix. Empty-suffix entries and suffixless point keys are left alone, matching the documented DSR contract. Equivalence is at the observable-read level, not the LSM-state level: real DSR attaches per-file metadata; the scan-and-delete path writes physical tombstones at a new seqnum. Both implementations should hide exactly the same set of rows from every subsequent read; that's what the cross-config compare verifies. Some implementation-level differences that intentionally do NOT cause divergence (because they don't surface in op results): - LSM file layout: DSR's masked files vs scan+delete's tombstoned rows compact differently. Compaction op results are recorded as success/failure, not LSM contents. - Compaction cancellation: real DSR cancels overlapping in-progress compactions; scan+delete doesn't. Visible behavior agrees because cancelled compactions retry. - Snapshot semantics: a snapshot taken before either DSR or scan+delete sees the pre-mutation state in both. A snapshot taken after either should see the post-mutation state (modulo the EFOS file-only-transition machinery, which DSR handles explicitly and scan+delete gets for free because point tombstones don't perturb snapshots). The equivalent path is approximate for `SyntheticSuffix` files: scan returns synth-substituted keys, so `Delete(iter.Key())` deletes by the substituted user key. DSR with synth-in-mask excises the whole file's overlap. Observable outcome agrees (all matching rows are gone) but the LSM state diverges substantially. This has not produced spurious divergences across the runs exercised so far (11 iterations across TestMeta + TestMetaCockroachKVs, ~50/50 split on the toggle, all clean), but it's the most likely source of any future flake. --- metamorphic/ops.go | 93 +++++++++++++++++++++++++++++++++++++++++- metamorphic/options.go | 16 ++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/metamorphic/ops.go b/metamorphic/ops.go index 597bf9f76d3..4b6d652dc3d 100644 --- a/metamorphic/ops.go +++ b/metamorphic/ops.go @@ -459,10 +459,101 @@ type deleteSuffixRangeOp struct { func (o *deleteSuffixRangeOp) run(t *Test, h historyRecorder) { db := t.getDB(o.dbID) span := pebble.KeyRange{Start: o.start, End: o.end} - err := db.DeleteSuffixRange(context.Background(), span, o.lower, o.upper) + 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, 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