From c6860d32ed05a9c934c8f9e8811c251ffb993d7d Mon Sep 17 00:00:00 2001 From: Radu Berinde Date: Tue, 26 May 2026 08:59:09 -0700 Subject: [PATCH] rowblk: fix IsLowerBound to account for synthetic suffix `Iter.firstUserKey` has the synthetic prefix prepended but the on-disk suffix is preserved; suffix replacement is only applied per-returned key via `maybeReplaceSuffix`. Comparing `firstUserKey` directly against the probe could return true even though the iterator's actual first key (after synthetic suffix replacement) sorts strictly less than the probe, violating the `blockiter.Data.IsLowerBound` contract. When a synthetic suffix is in effect, only compare the prefix portion of `firstUserKey` and require it to be strictly greater than the probe. The contract permits false negatives, so this conservative comparison is sound and avoids reasoning about the unknown suffix. Adds `TestIsLowerBoundRandomized` which cross-checks `IsLowerBound` against `First()` across random blocks and transform combinations, and documents the transform state of `firstUserKey`. --- sstable/rowblk/rowblk_iter.go | 13 +++++ sstable/rowblk/rowblk_iter_test.go | 76 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/sstable/rowblk/rowblk_iter.go b/sstable/rowblk/rowblk_iter.go index ff81a21a4ef..93c728cf16f 100644 --- a/sstable/rowblk/rowblk_iter.go +++ b/sstable/rowblk/rowblk_iter.go @@ -187,6 +187,10 @@ type Iter struct { cachedBuf []byte handle block.BufferHandle // for block iteration for already loaded blocks. + // + // firstUserKey is the user key of the first KV in the block. It has the + // synthetic prefix applied (if any), but does NOT have the synthetic suffix + // applied — the on-disk suffix is preserved. firstUserKey []byte lazyValueHandling struct { getValue block.GetInternalValueForPrefixAndValueHandler @@ -541,6 +545,15 @@ func (i *Iter) cacheEntry() { // IsLowerBound implements the block.DataBlockIterator interface. func (i *Iter) IsLowerBound(k []byte) bool { // Note: we ignore HideObsoletePoints, but false negatives are allowed. + if i.transforms.HasSyntheticSuffix() { + // firstUserKey has the synthetic prefix applied, but its suffix is the + // original on-disk suffix — not the synthetic suffix that the iterator will + // actually return. Instead of applying the synthetic suffix, we + // conservatively require firstUserKey's prefix portion to be strictly + // greater than k (false negatives are allowed). + firstPrefix := i.firstUserKey[:i.split(i.firstUserKey)] + return i.cmp(firstPrefix, k) > 0 + } return i.cmp(i.firstUserKey, k) >= 0 } diff --git a/sstable/rowblk/rowblk_iter_test.go b/sstable/rowblk/rowblk_iter_test.go index 348b6039797..4e66271ceca 100644 --- a/sstable/rowblk/rowblk_iter_test.go +++ b/sstable/rowblk/rowblk_iter_test.go @@ -7,8 +7,11 @@ package rowblk import ( "bytes" "fmt" + "math/rand/v2" + "slices" "strings" "testing" + "time" "unsafe" "github.com/cockroachdb/datadriven" @@ -465,3 +468,76 @@ func TestBlockSyntheticSuffix(t *testing.T) { func ikey(s string) base.InternalKey { return base.InternalKey{UserKey: []byte(s)} } + +func TestIsLowerBoundRand(t *testing.T) { + cmp := testkeys.Comparer.Compare + suffixCmp := testkeys.Comparer.ComparePointSuffixes + split := testkeys.Comparer.Split + + prefixes := []string{"a", "b", "c", "d", "e", "p/", "zzz/"} + suffixes := []string{"", "@1", "@5", "@10", "@20", "@100"} + + seed := uint64(time.Now().UnixNano()) + t.Logf("seed: %d", seed) + rng := rand.New(rand.NewPCG(seed, seed)) + randKey := func() string { + return prefixes[rng.IntN(len(prefixes))] + suffixes[rng.IntN(len(suffixes))] + } + + for iter := 0; iter < 1000; iter++ { + // Construct a block with a random combination of prefixes and suffixes. + n := 1 + rng.IntN(20) + keySet := make(map[string]struct{}, n) + for len(keySet) < n { + keySet[randKey()] = struct{}{} + } + keys := make([]string, 0, n) + for k := range keySet { + keys = append(keys, k) + } + slices.SortFunc(keys, func(a, b string) int { return cmp([]byte(a), []byte(b)) }) + + w := &Writer{RestartInterval: 1 + rng.IntN(4)} + for _, k := range keys { + require.NoError(t, w.Add(ikey(k), nil)) + } + blk := w.Finish() + + // Pick a random prefix and suffix for the transform. + var synthPrefix, synthSuffix []byte + if rand.IntN(2) == 0 { + synthPrefix = []byte(prefixes[rng.IntN(len(prefixes))]) + } + if rand.IntN(2) == 0 { + synthSuffix = []byte(suffixes[rng.IntN(len(suffixes))]) + } + transforms := block.IterTransforms{ + SyntheticPrefixAndSuffix: block.MakeSyntheticPrefixAndSuffix(synthPrefix, synthSuffix), + } + + it, err := NewIter(cmp, suffixCmp, split, blk, transforms) + require.NoError(t, err) + + kv := it.First() + require.NotNil(t, kv) + // firstKey is the key after the transform. + firstKey := slices.Clone(kv.K.UserKey) + + for probe := 0; probe < 50; probe++ { + probeKey := []byte(randKey()) + if rand.IntN(2) == 0 { + // The transformed block keys are random keys with synthPrefix + // prepended. To generate more interesting probe keys, sometimes prepend + // this prefix to the random key. + probeKey = append(slices.Clip(synthPrefix), probeKey...) + } + if it.IsLowerBound(probeKey) { + if cmp(firstKey, probeKey) < 0 { + t.Fatalf("IsLowerBound(%q)=true but First()=%q < probe (transforms=%+v, keys=%v)", + probeKey, firstKey, transforms, keys) + } + } + } + require.NoError(t, it.Close()) + } +}