diff --git a/db.go b/db.go index 7c22871cc..48ed5f3b6 100644 --- a/db.go +++ b/db.go @@ -52,12 +52,19 @@ type closers struct { type lockedKeys struct { sync.RWMutex keys map[uint64]struct{} + // hasAny is a fast-path flag: false until the first add(), then true forever. + // Hot-path callers (DB.isBanned) check this without taking the lock so the + // common case (empty ban set) costs one atomic load instead of an + // RLock/RUnlock pair plus a map lookup. There is no remove API, so the + // flag is monotonic and never needs to flip back to false. + hasAny atomic.Bool } func (lk *lockedKeys) add(key uint64) { lk.Lock() defer lk.Unlock() lk.keys[key] = struct{}{} + lk.hasAny.Store(true) } func (lk *lockedKeys) has(key uint64) bool { @@ -1922,6 +1929,14 @@ func (db *DB) isBanned(key []byte) error { if db.opt.NamespaceOffset < 0 { return nil } + // Fast path: no namespaces have ever been banned in this DB lifetime + // (the common production case). Skip the slice + lookup + lock entirely. + // isBanned is called on every iterator step and every Txn.Get/modify, so + // avoiding the RLock here matters when NamespaceOffset is enabled but no + // bans are active. + if !db.bannedNamespaces.hasAny.Load() { + return nil + } if len(key) <= db.opt.NamespaceOffset+8 { return nil } diff --git a/errors.go b/errors.go index dcf0d12ae..40433dcb6 100644 --- a/errors.go +++ b/errors.go @@ -46,6 +46,14 @@ var ( // ErrBannedKey is returned if the read/write key belongs to any banned namespace. ErrBannedKey = stderrors.New("Key is using the banned prefix") + // ErrKeyOnlyMode is returned by Item.Value and Item.ValueCopy when the + // containing iterator was created with IteratorOptions.KeyOnly=true. + // In that mode the iterator never copies the value bytes into the Item + // (the main reason to use KeyOnly is to avoid that per-item copy on + // key-only scans), so value access is unavailable on those items. + ErrKeyOnlyMode = stderrors.New( + "Item value is unavailable in KeyOnly iterator mode") + // ErrThresholdZero is returned if threshold is set to zero, and value log GC is called. // In such a case, GC can't be run. ErrThresholdZero = stderrors.New( diff --git a/iterator.go b/iterator.go index f57cfa4c9..1a493006f 100644 --- a/iterator.go +++ b/iterator.go @@ -43,6 +43,11 @@ type Item struct { status prefetchStatus meta byte // We need to store meta to know about bitValuePointer. userMeta byte + // keyOnly is true when the parent iterator was created with + // IteratorOptions.KeyOnly. The iterator skips copying value bytes into + // this item, so Item.Value/ValueCopy and the size estimators must + // short-circuit instead of touching the (nil) vptr. + keyOnly bool } // String returns a string representation of Item @@ -81,6 +86,9 @@ func (item *Item) Version() uint64 { // instead, or copy it yourself. Value might change once discard or commit is called. // Use ValueCopy if you want to do a Set after Get. func (item *Item) Value(fn func(val []byte) error) error { + if item.keyOnly { + return ErrKeyOnlyMode + } item.wg.Wait() if item.status == prefetched { if item.err == nil && fn != nil { @@ -108,6 +116,9 @@ func (item *Item) Value(fn func(val []byte) error) error { // This function is useful in long running iterate/update transactions to avoid a write deadlock. // See Github issue: https://github.com/dgraph-io/badger/issues/315 func (item *Item) ValueCopy(dst []byte) ([]byte, error) { + if item.keyOnly { + return nil, ErrKeyOnlyMode + } item.wg.Wait() if item.status == prefetched { return y.SafeCopy(dst, item.val), item.err @@ -213,7 +224,14 @@ func (item *Item) prefetchValue() { // This can be called while iterating through a store to quickly estimate the // size of a range of key-value pairs (without fetching the corresponding // values). +// +// When the iterator was created with IteratorOptions.KeyOnly=true, the +// value bytes (and value pointer for vlog entries) are not retained on +// the item, so this returns the key size only. func (item *Item) EstimatedSize() int64 { + if item.keyOnly { + return int64(len(item.key)) + } if !item.hasValue() { return 0 } @@ -235,7 +253,13 @@ func (item *Item) KeySize() int64 { // // This can be called to quickly estimate the size of a value without fetching // it. +// +// When the iterator was created with IteratorOptions.KeyOnly=true the value +// length is not retained on the item; this returns 0. func (item *Item) ValueSize() int64 { + if item.keyOnly { + return 0 + } if !item.hasValue() { return 0 } @@ -311,6 +335,12 @@ type IteratorOptions struct { Reverse bool // Direction of iteration. False is forward, true is backward. AllVersions bool // Fetch all valid versions of the same key. InternalAccess bool // Used to allow internal access to badger keys. + // KeyOnly causes the iterator to skip copying value bytes (and the + // value pointer for vlog entries) into each yielded Item. The savings + // are material on forward scans that only iterate keys (e.g. dgraph + // posting-list rollups); they come at the cost of making Value / + // ValueCopy return ErrKeyOnlyMode for items produced under this mode. + KeyOnly bool // The following option is used to narrow down the SSTables that iterator // picks up. If Prefix is specified, only tables which could have this @@ -320,6 +350,19 @@ type IteratorOptions struct { SinceTs uint64 // Only read data that has version > SinceTs. } +// precludesInternalKeys reports whether this iterator's prefix is known to +// exclude every badger-internal key. Internal keys all live under the +// fixed badgerPrefix ("!badger!...") — when the iterator's prefix is set +// and its first byte differs from badgerPrefix[0], no internal key can +// possibly match, so the per-step internal-key probe in parseItem is +// provably dead. +// +// An empty Prefix means an unbounded scan, which can land on internal +// keys, so the probe is still needed. +func (opt *IteratorOptions) precludesInternalKeys() bool { + return len(opt.Prefix) > 0 && opt.Prefix[0] != badgerPrefix[0] +} + func (opt *IteratorOptions) compareToPrefix(key []byte) int { // We should compare key without timestamp. For example key - a[TS] might be > "aa" prefix. key = y.ParseKey(key) @@ -464,6 +507,12 @@ func (txn *Txn) NewIterator(opt IteratorOptions) *Iterator { panic(ErrDBClosed) } + // KeyOnly disables value access, so prefetching values is nonsensical. + // Force PrefetchValues off so the prefetch goroutine is never started. + if opt.KeyOnly { + opt.PrefetchValues = false + } + y.NumIteratorsCreatedAdd(txn.db.opt.MetricsEnabled, 1) // Keep track of the number of active iterators. @@ -616,14 +665,28 @@ func (it *Iterator) parseItem() bool { } } - isInternalKey := bytes.HasPrefix(key, badgerPrefix) - // Skip badger keys. - if !it.opt.InternalAccess && isInternalKey { + // Detect badger-internal keys. When precludesInternalKeys is true (the + // common case for prefix-bounded user scans whose prefix cannot collide + // with badgerPrefix), we know the current key cannot be internal and + // elide the per-step bytes.HasPrefix(key, badgerPrefix) probe. + var isInternalKey bool + if !it.opt.precludesInternalKeys() { + isInternalKey = bytes.HasPrefix(key, badgerPrefix) + // Skip badger keys. + if !it.opt.InternalAccess && isInternalKey { + mi.Next() + return false + } + } + + // Skip any versions which are beyond the readTs. Every key in the LSM + // is stored with an 8-byte timestamp suffix via y.KeyWithTs; defensively + // guard against corrupt blocks that yield shorter keys so the rest of + // the function (which subtracts 8 from len(key) below) does not panic. + if len(key) < 8 { mi.Next() return false } - - // Skip any versions which are beyond the readTs. version := y.ParseTs(key) // Ignore everything that is above the readTs and below or at the sinceTs. if version > it.readTs || (it.opt.SinceTs > 0 && version <= it.opt.SinceTs) { @@ -640,8 +703,9 @@ func (it *Iterator) parseItem() bool { if it.opt.AllVersions { // Return deleted or expired values also, otherwise user can't figure out // whether the key was deleted. + vs := mi.Value() item := it.newItem() - it.fill(item) + it.fill(item, key, &vs) setItem(item) mi.Next() return true @@ -650,7 +714,12 @@ func (it *Iterator) parseItem() bool { // If iterating in forward direction, then just checking the last key against current key would // be sufficient. if !it.opt.Reverse { - if y.SameKey(it.lastKey, key) { + // lastKey holds the user-key only. Compare against the user-key + // portion of the current full key (last 8 bytes are the ts). + // y.SameUserKey uses bytes.Equal under the hood, which short- + // circuits on length mismatch. + ukLen := len(key) - 8 + if y.SameUserKey(it.lastKey, key[:ukLen]) { mi.Next() return false } @@ -659,11 +728,16 @@ func (it *Iterator) parseItem() bool { // Consider keys: a 5, b 7 (del), b 5. When iterating, lastKey = a. // Then we see b 7, which is deleted. If we don't store lastKey = b, we'll then return b 5, // which is wrong. Therefore, update lastKey here. - it.lastKey = y.SafeCopy(it.lastKey, mi.Key()) + it.lastKey = y.SafeCopy(it.lastKey, key[:ukLen]) } FILL: - // If deleted, advance and return. + // Invariant on entry to FILL: `key` is mi.Key() at the *current* iitr + // position. The only goto FILL (below, reverse path) refreshes `key` + // after mi.Next(); the fall-through entry from above never advances the + // iterator between `key := mi.Key()` and reaching FILL. fill() can + // therefore safely reuse the caller-supplied key without re-calling + // mi.Key(). vs := mi.Value() if isDeletedOrExpired(vs.Meta, vs.ExpiresAt) { mi.Next() @@ -671,7 +745,7 @@ FILL: } item := it.newItem() - it.fill(item) + it.fill(item, key, &vs) // fill item based on current cursor position. All Next calls have returned, so reaching here // means no Next was called. @@ -681,9 +755,11 @@ FILL: return true } - // Reverse direction. - nextTs := y.ParseTs(mi.Key()) - mik := y.ParseKey(mi.Key()) + // Reverse direction. Refresh key after the Next() above; the iterator + // has advanced, so the previous `key` slice now refers to a later block. + key = mi.Key() + nextTs := y.ParseTs(key) + mik := y.ParseKey(key) if nextTs <= it.readTs && bytes.Equal(mik, item.key) { // This is a valid potential candidate. goto FILL @@ -693,17 +769,32 @@ FILL: return true } -func (it *Iterator) fill(item *Item) { - vs := it.iitr.Value() +// fill populates item from the current iterator position. Callers pass the +// already-fetched key and value pointer to avoid the per-item cost of +// calling mi.Key() / mi.Value() (and decoding ValueStruct) a second time +// on the hot iterator path. vs is passed by pointer to avoid copying the +// ~40-byte ValueStruct on every kept item. +func (it *Iterator) fill(item *Item, key []byte, vs *y.ValueStruct) { item.meta = vs.Meta item.userMeta = vs.UserMeta item.expiresAt = vs.ExpiresAt + item.keyOnly = it.opt.KeyOnly - item.version = y.ParseTs(it.iitr.Key()) - item.key = y.SafeCopy(item.key, y.ParseKey(it.iitr.Key())) + item.version = y.ParseTs(key) + item.key = y.SafeCopy(item.key, y.ParseKey(key)) - item.vptr = y.SafeCopy(item.vptr, vs.Value) item.val = nil + if it.opt.KeyOnly { + // Don't copy vs.Value: KeyOnly callers have promised not to read + // it, and the SafeCopy is the largest per-item memmove on the + // key-only forward-scan hot path. nil out any leftover capacity + // from a previous item that was reused via the iterator's + // freelist; callers that ignore the contract will at least see a + // nil vptr rather than stale bytes. + item.vptr = nil + } else { + item.vptr = y.SafeCopy(item.vptr, vs.Value) + } if it.opt.PrefetchValues { item.wg.Add(1) go func() { diff --git a/iterator_keyonly_test.go b/iterator_keyonly_test.go new file mode 100644 index 000000000..7f183d666 --- /dev/null +++ b/iterator_keyonly_test.go @@ -0,0 +1,400 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package badger + +import ( + "errors" + "fmt" + "testing" + + "github.com/dgraph-io/badger/v4/y" + "github.com/stretchr/testify/require" +) + +// TestKeyOnlyIterator_ValueReturnsErrKeyOnlyMode covers iterator.go:Item.Value +// short-circuit when item.keyOnly is set. +func TestKeyOnlyIterator_ValueReturnsErrKeyOnlyMode(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + txnSet(t, db, []byte("k1"), []byte("v1"), 0) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.KeyOnly = true + it := txn.NewIterator(opt) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + err := item.Value(func(v []byte) error { return nil }) + require.True(t, errors.Is(err, ErrKeyOnlyMode), + "Value() should return ErrKeyOnlyMode, got %v", err) + } + return nil + })) + }) +} + +// TestKeyOnlyIterator_ValueCopyReturnsErrKeyOnlyMode covers iterator.go:Item.ValueCopy. +func TestKeyOnlyIterator_ValueCopyReturnsErrKeyOnlyMode(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + txnSet(t, db, []byte("k1"), []byte("v1"), 0) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.KeyOnly = true + it := txn.NewIterator(opt) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + buf, err := item.ValueCopy(nil) + require.True(t, errors.Is(err, ErrKeyOnlyMode)) + require.Nil(t, buf) + } + return nil + })) + }) +} + +// TestKeyOnlyIterator_EstimatedSizeReturnsKeyLen covers iterator.go:Item.EstimatedSize +// short-circuit (returns int64(len(item.key)) when keyOnly). +func TestKeyOnlyIterator_EstimatedSizeReturnsKeyLen(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + txnSet(t, db, []byte("abc"), []byte("lots-of-value-bytes-here"), 0) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.KeyOnly = true + it := txn.NewIterator(opt) + defer it.Close() + it.Rewind() + require.True(t, it.Valid()) + item := it.Item() + require.Equal(t, int64(len("abc")), item.EstimatedSize()) + return nil + })) + }) +} + +// TestKeyOnlyIterator_ValueSizeIsZero covers iterator.go:Item.ValueSize short-circuit. +func TestKeyOnlyIterator_ValueSizeIsZero(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + txnSet(t, db, []byte("k1"), []byte("hello-world"), 0) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.KeyOnly = true + it := txn.NewIterator(opt) + defer it.Close() + it.Rewind() + require.True(t, it.Valid()) + require.Equal(t, int64(0), it.Item().ValueSize()) + return nil + })) + }) +} + +// TestKeyOnlyIterator_ForcesPrefetchOff covers iterator.go:NewIterator forcing +// PrefetchValues=false when KeyOnly=true. +func TestKeyOnlyIterator_ForcesPrefetchOff(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + txnSet(t, db, []byte("k1"), []byte("v1"), 0) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.KeyOnly = true + opt.PrefetchValues = true + opt.PrefetchSize = 100 + it := txn.NewIterator(opt) + defer it.Close() + require.False(t, it.opt.PrefetchValues, + "NewIterator must force PrefetchValues=false when KeyOnly=true") + it.Rewind() + require.True(t, it.Valid()) + return nil + })) + }) +} + +// TestKeyOnlyIterator_KeyMetaVersionWork verifies that the metadata methods on Item +// still work correctly in KeyOnly mode. +func TestKeyOnlyIterator_KeyMetaVersionWork(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + key := []byte("hello") + val := []byte("world") + txn := db.NewTransaction(true) + require.NoError(t, txn.SetEntry(NewEntry(key, val).WithMeta(0x42))) + require.NoError(t, txn.Commit()) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.KeyOnly = true + it := txn.NewIterator(opt) + defer it.Close() + it.Rewind() + require.True(t, it.Valid()) + item := it.Item() + require.Equal(t, key, item.Key()) + require.Equal(t, byte(0x42), item.UserMeta()) + require.NotZero(t, item.Version()) + require.False(t, item.IsDeletedOrExpired()) + require.Equal(t, int64(len(key)), item.KeySize()) + require.False(t, item.DiscardEarlierVersions()) + return nil + })) + }) +} + +// TestKeyOnlyIterator_PrefetchValuesFalseStillWorks covers fill() KeyOnly branch +// without prefetch (the actual hot path; PrefetchValues=false bypasses the +// prefetch goroutine entirely). +func TestKeyOnlyIterator_PrefetchValuesFalseStillWorks(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + for i := 0; i < 20; i++ { + txnSet(t, db, []byte(fmt.Sprintf("k%02d", i)), []byte("vvv"), 0) + } + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.PrefetchValues = false + opt.KeyOnly = true + it := txn.NewIterator(opt) + defer it.Close() + count := 0 + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + require.NotEmpty(t, item.Key()) + err := item.Value(func(v []byte) error { return nil }) + require.True(t, errors.Is(err, ErrKeyOnlyMode)) + count++ + } + require.Equal(t, 20, count) + return nil + })) + }) +} + +// TestPrecludesInternalKeys is the table-driven unit test for the moved helper +// (was a free function in the PR, now a method on IteratorOptions). +func TestPrecludesInternalKeys(t *testing.T) { + cases := []struct { + name string + prefix []byte + want bool + }{ + {"empty prefix sees everything", nil, false}, + {"empty slice sees everything", []byte{}, false}, + {"prefix starting with '!' (badgerPrefix[0]) overlaps", []byte("!badger"), false}, + {"prefix starting with 0x21 (== '!') overlaps", []byte{0x21, 0xff}, false}, + {"prefix starting with 0x00 cannot overlap", []byte{0x00, 0x01, 0x02}, true}, + {"prefix starting with 'a' cannot overlap", []byte("abc"), true}, + {"prefix starting with high byte cannot overlap", []byte{0xff}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opt := DefaultIteratorOptions + opt.Prefix = tc.prefix + got := opt.precludesInternalKeys() + require.Equal(t, tc.want, got, + "prefix=%x precludesInternalKeys=%v want=%v", tc.prefix, got, tc.want) + }) + } +} + +// TestRegressionSameKeyDedup locks in the user-key-only lastKey behavior: when +// multiple versions of the same user-key exist and AllVersions=false, the +// iterator must surface exactly one item per user-key (the freshest +// non-expired one). +func TestRegressionSameKeyDedup(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + for _, k := range []string{"k1", "k2"} { + for v := 1; v <= 3; v++ { + txnSet(t, db, []byte(k), []byte(fmt.Sprintf("v%d", v)), 0) + } + } + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.PrefetchValues = false + it := txn.NewIterator(opt) + defer it.Close() + var seen []string + for it.Rewind(); it.Valid(); it.Next() { + seen = append(seen, string(it.Item().Key())) + } + require.Equal(t, []string{"k1", "k2"}, seen, + "non-AllVersions iterator must dedup to one item per user-key") + return nil + })) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.AllVersions = true + opt.PrefetchValues = false + it := txn.NewIterator(opt) + defer it.Close() + counts := map[string]int{} + for it.Rewind(); it.Valid(); it.Next() { + counts[string(it.Item().Key())]++ + } + require.Equal(t, 3, counts["k1"]) + require.Equal(t, 3, counts["k2"]) + return nil + })) + }) +} + +// TestRegressionInternalKeysHidden locks in: a default user iterator (no +// InternalAccess) must not surface badger-internal keys even when the +// optimized precludesInternalKeys path is taken. +func TestRegressionInternalKeysHidden(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + txnSet(t, db, []byte("user-key"), []byte("v"), 0) + + require.NoError(t, db.View(func(txn *Txn) error { + it := txn.NewIterator(DefaultIteratorOptions) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + k := it.Item().Key() + require.False(t, isBadgerInternalKey(k), + "default iterator surfaced internal key %q", k) + } + return nil + })) + }) +} + +func isBadgerInternalKey(k []byte) bool { + if len(k) < len(badgerPrefix) { + return false + } + for i := range badgerPrefix { + if k[i] != badgerPrefix[i] { + return false + } + } + return true +} + +// TestRegressionIsBannedNoBans locks in the fast-path correctness: when no +// namespaces have been banned, isBanned must return nil for any key. +func TestRegressionIsBannedNoBans(t *testing.T) { + opt := getTestOptions("") + opt.NamespaceOffset = 0 + runBadgerTest(t, &opt, func(t *testing.T, db *DB) { + require.False(t, db.bannedNamespaces.hasAny.Load()) + + key := y.KeyWithTs([]byte("hello-world-12345"), 1) + require.NoError(t, db.isBanned(key)) + + require.NoError(t, db.isBanned(nil)) + require.NoError(t, db.isBanned([]byte("x"))) + }) +} + +// TestRegressionIsBannedWithBan covers the slow-path: hasAny=true, key matches +// a banned namespace. +func TestRegressionIsBannedWithBan(t *testing.T) { + opt := getTestOptions("") + opt.NamespaceOffset = 0 + runBadgerTest(t, &opt, func(t *testing.T, db *DB) { + const ns = uint64(0x4242) + require.NoError(t, db.BanNamespace(ns)) + require.True(t, db.bannedNamespaces.hasAny.Load(), + "hasAny must flip to true after add()") + + banned := y.KeyWithTs(append(y.U64ToBytes(ns), []byte("suffix")...), 1) + require.ErrorIs(t, db.isBanned(banned), ErrBannedKey) + + other := y.KeyWithTs(append(y.U64ToBytes(0x1111), []byte("suffix")...), 1) + require.NoError(t, db.isBanned(other)) + }) +} + +// TestRegressionIsBannedShortKeyWithBans covers the slow path early-return when +// hasAny=true but the key is too short to extract a namespace. +func TestRegressionIsBannedShortKeyWithBans(t *testing.T) { + opt := getTestOptions("") + opt.NamespaceOffset = 0 + runBadgerTest(t, &opt, func(t *testing.T, db *DB) { + const ns = uint64(0x4242) + require.NoError(t, db.BanNamespace(ns)) + + require.NoError(t, db.isBanned([]byte("short"))) + require.NoError(t, db.isBanned(nil)) + }) +} + +// TestRegressionIsBannedNegativeOffset covers the early-return: NamespaceOffset<0 +// short-circuits before any atomic load. +func TestRegressionIsBannedNegativeOffset(t *testing.T) { + opt := getTestOptions("") + opt.NamespaceOffset = -1 + runBadgerTest(t, &opt, func(t *testing.T, db *DB) { + require.NoError(t, db.isBanned([]byte("anything"))) + require.NoError(t, db.isBanned(nil)) + }) +} + +// TestRegressionFillNonKeyOnly exercises the non-KeyOnly fill path, verifying +// that values are still copied correctly when KeyOnly=false. +func TestRegressionFillNonKeyOnly(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + txnSet(t, db, []byte("k1"), []byte("expected-value-bytes"), 0) + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.PrefetchValues = false + it := txn.NewIterator(opt) + defer it.Close() + it.Rewind() + require.True(t, it.Valid()) + got, err := it.Item().ValueCopy(nil) + require.NoError(t, err) + require.Equal(t, []byte("expected-value-bytes"), got) + return nil + })) + }) +} + +// TestRegressionPrefetchValuesTrue exercises the prefetch goroutine path, +// confirming KeyOnly=false + PrefetchValues=true still works. +func TestRegressionPrefetchValuesTrue(t *testing.T) { + runBadgerTest(t, nil, func(t *testing.T, db *DB) { + for i := 0; i < 5; i++ { + txnSet(t, db, []byte(fmt.Sprintf("k%d", i)), []byte(fmt.Sprintf("v%d", i)), 0) + } + + require.NoError(t, db.View(func(txn *Txn) error { + opt := DefaultIteratorOptions + opt.PrefetchValues = true + opt.PrefetchSize = 10 + it := txn.NewIterator(opt) + defer it.Close() + seen := 0 + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + v, err := item.ValueCopy(nil) + require.NoError(t, err) + require.NotEmpty(t, v) + seen++ + } + require.Equal(t, 5, seen) + return nil + })) + }) +} + +// TestSameUserKey exercises the new y.SameUserKey helper. It is a thin wrapper +// around bytes.Equal that callers use when they have already stripped the 8-byte +// ts suffix; the test pins the contract so a future change cannot regress. +func TestSameUserKey(t *testing.T) { + require.True(t, y.SameUserKey(nil, nil)) + require.True(t, y.SameUserKey([]byte{}, []byte{})) + require.True(t, y.SameUserKey([]byte("abc"), []byte("abc"))) + require.False(t, y.SameUserKey([]byte("abc"), []byte("abd"))) + require.False(t, y.SameUserKey([]byte("abc"), []byte("ab"))) + require.False(t, y.SameUserKey([]byte("ab"), []byte("abc"))) + require.False(t, y.SameUserKey([]byte("abc"), nil)) +} diff --git a/y/y.go b/y/y.go index 7d7f23e2a..d1a7a0cb7 100644 --- a/y/y.go +++ b/y/y.go @@ -153,6 +153,19 @@ func SameKey(src, dst []byte) bool { return bytes.Equal(ParseKey(src), ParseKey(dst)) } +// SameUserKey reports whether two already-parsed user keys are equal. +// Unlike SameKey, this does NOT call ParseKey — callers that already hold +// the user-key portion of an internal key (i.e. the caller has sliced off +// the 8-byte ts suffix itself) can skip the per-call ParseKey overhead. +// +// Both arguments are required to be user keys (no ts suffix). Passing keys +// shorter than 8 bytes yields false; passing keys that do carry a ts +// suffix compares the whole buffer, which may produce surprising results — +// callers are responsible for stripping the suffix before calling. +func SameUserKey(a, b []byte) bool { + return bytes.Equal(a, b) +} + // Slice holds a reusable buf, will reallocate if you request a larger size than ever before. // One problem is with n distinct sizes in random order it'll reallocate log(n) times. type Slice struct {