Skip to content

Commit dcc1f5d

Browse files
pebble: fix incorrect TrySeekUsingNext across an indexed-batch refresh
When an indexed-batch-backed `Iterator` observes that its batch has been mutated (e.g. on the first seek after `SetOptions`), it sets `batchJustRefreshed`, which intentionally keeps `TrySeekUsingNext` enabled for the re-seek of the internal iterator while disabling the top-level no-op seek optimization. This is unsafe when the internal iterator was left advanced *past* the last key returned to the user — most commonly `i.pos == iterPosNext`, which happens while collecting `MERGE` operands (the iterator steps to the next user key to confirm there are no more operands). In that state, the no-op optimization is what normally re-returns the cached result; with it disabled by the refresh, the re-seek falls through to the internal iterator with `TrySeekUsingNext`, which resumes from the advanced position and skips the last-returned key whenever the new seek key is at or before it. The result is a visible key being silently skipped. This was found by the metamorphic test as a non-deterministic divergence between two configurations: it requires the internal iterator to be parked past the key (layout-dependent) and `TrySeekUsingNext` to not have been randomly disabled that run. Fix it by disabling `TrySeekUsingNext` on a `BatchJustRefreshed` seek unless the internal iterator is positioned exactly at the last-returned key (`iterPosCurForward`). The common case (no merge advance) keeps the optimization. The fix lives in `Iterator.SeekGEWithLimit` so it is iterator-stack agnostic. A datadriven regression test is added in `testdata/iter_histories/set_options_batch_refresh`. This required `combined-iter` to support a `key-types` argument and the test harness to propagate `forceEnableSeekOpt` to the merging iterator, so the seek-using-next path is exercised deterministically. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
1 parent 4227dcd commit dcc1f5d

3 files changed

Lines changed: 86 additions & 1 deletion

File tree

iterator.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,21 @@ func (i *Iterator) SeekGEWithLimit(key []byte, limit []byte) IterValidityState {
13811381
if testingDisableSeekOpt(key, uintptr(unsafe.Pointer(i))) && !i.forceEnableSeekOpt {
13821382
flags = flags.DisableTrySeekUsingNext()
13831383
}
1384+
// When the batch was just refreshed, the top-level no-op seek
1385+
// optimization above is skipped (it requires !BatchJustRefreshed),
1386+
// so we will re-seek the internal iterator below. TrySeekUsingNext
1387+
// is only safe to pass to the internal iterator when it is
1388+
// positioned exactly at the last key we returned
1389+
// (iterPosCurForward). If instead it was advanced past that key
1390+
// (most commonly iterPosNext, while consuming MERGE operands; also
1391+
// the paused positions), resuming with TrySeekUsingNext would start
1392+
// from that advanced position and skip the last-returned key — which
1393+
// this seek may still need to return, since BatchJustRefreshed seeks
1394+
// can be to a key at or before it. Disable TrySeekUsingNext in that
1395+
// case and fall back to a full re-seek.
1396+
if flags.BatchJustRefreshed() && i.pos != iterPosCurForward {
1397+
flags = flags.DisableTrySeekUsingNext()
1398+
}
13841399
if !flags.BatchJustRefreshed() && i.pos == iterPosCurForwardPaused && i.cmp(key, i.iterKV.K.UserKey) <= 0 {
13851400
// Have some work to do, but don't need to seek, and we can
13861401
// start doing findNextEntry from i.iterKey.

iterator_histories_test.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,20 @@ func TestIterHistories(t *testing.T) {
4747
parser := itertest.NewParser()
4848
newIter := func(name string, reader Reader, o *IterOptions) *Iterator {
4949
it, _ := reader.NewIter(o)
50-
iters[name] = it
50+
// These tests assert deterministic optimization behavior across
51+
// SetOptions/seek calls; opt out of invariant-only randomization that
52+
// suppresses seek-using-next or forces SetOptions reconstruction. The
53+
// merging iterator has its own seek-using-next randomization, so it
54+
// must be opted out as well.
55+
it.forceEnableSeekOpt = true
56+
if it.merging != nil {
57+
it.merging.forceEnableSeekOpt = true
58+
}
59+
// Only track named iterators; unnamed ones are closed inline by
60+
// runIterCmd and would otherwise be double-closed by cleanup.
61+
if name != "" {
62+
iters[name] = it
63+
}
5164
return it
5265
}
5366
var opts *Options
@@ -309,6 +322,17 @@ func TestIterHistories(t *testing.T) {
309322
o.LowerBound = []byte(arg.Vals[0])
310323
case "upper":
311324
o.UpperBound = []byte(arg.Vals[0])
325+
case "key-types":
326+
switch arg.Vals[0] {
327+
case "point":
328+
o.KeyTypes = IterKeyTypePointsOnly
329+
case "range":
330+
o.KeyTypes = IterKeyTypeRangesOnly
331+
case "both":
332+
o.KeyTypes = IterKeyTypePointsAndRanges
333+
default:
334+
return fmt.Sprintf("unknown key-type %q", arg.Vals[0])
335+
}
312336
case "name":
313337
name = arg.Vals[0]
314338
case "reader":
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Regression test: TrySeekUsingNext must not be passed to the internal iterator
2+
# across an indexed-batch refresh when the internal iterator has been advanced
3+
# past the last-returned key (e.g. iterPosNext while collecting MERGE operands).
4+
#
5+
# Setup: the DB holds a MERGE at "c" and a SET at "e". A points-only iterator is
6+
# opened over an indexed batch (whose keys are all > c, e). The first SeekGE("a")
7+
# returns the merge at "c"; collecting its operand advances the internal memtable
8+
# iterator past "c" to "e" (leaving the iterator at iterPosNext). The batch is
9+
# then mutated and SetOptions refreshes the batch view, which disables the
10+
# top-level no-op seek optimization. The subsequent SeekGE("b") is eligible for
11+
# TrySeekUsingNext (b > the previous seek key "a") but b <= the cached result
12+
# "c"; it must re-find "c" rather than resuming from "e" and skipping it.
13+
14+
reset
15+
----
16+
17+
batch commit
18+
merge c v1
19+
set e ve
20+
----
21+
committed 2 keys
22+
23+
batch name=b
24+
set z vz
25+
----
26+
wrote 1 keys to batch "b"
27+
28+
combined-iter name=i reader=b key-types=point
29+
seek-ge a
30+
----
31+
c: (v1, .)
32+
33+
# Mutate the batch so the following SetOptions detects a changed batch view and
34+
# sets batchJustRefreshed.
35+
mutate batch=b
36+
set y vy
37+
----
38+
39+
# SetOptions (unchanged options) refreshes the batch view and disables the
40+
# no-op seek optimization, then SeekGE("b") must still return "c".
41+
iter iter=i
42+
set-options
43+
seek-ge b
44+
----
45+
.
46+
c: (v1, .)

0 commit comments

Comments
 (0)