Repository: cockroachdb/pebble
Pull request #1737 (2022-06-03 19:51:23)

internal/metamorphic: add block-property filter coverage 
**sstable: fix additional bounds check bugs**

In 4406dabc a bounds-checking bug was fixed in the two-level index
iterator's SeekLT method. The same bug existed within Last and
skipBackward and was missed.

**db: refactor levelIter/mergingIter boundary interface**

The levelIter and mergingIter are tightly coupled in a few places, with the
levelIter passing additional context to the mergingIter by writing into fields
owned by the mergingIter. This commit is a small refactor to organize these
fields together into a `levelIterBoundaryContext` struct. This is in
preparation for adding an additional field.

**db: don't filter range deletions based on point-key block filters**

Previously, if an iterator configured with a block-property filter encountered
a table that contained all excluded point keys, the iterator would skip the
table entirely, including its range deletions. An iterator configured with a
block-property filter could observe keys outside the filtered range that no
longer exist.

This behavior was fine from CockroachDB's perspective, which always pairs a
filtered iterator with an unfiltered iterator, seeking the latter in tandem.
The behavior is likely to be surprising in non-CockroachDB use cases.
Additionally, the behavior is an obstacle to range-key masking which must
respect range deletions for keys not covered by the masking range key.

This change requires additional coordination between the mergingIter and
levelIter to ensure that a levelIter doesn't close a file while other levels
still require its range deletions. This coordination takes the form a new
`isIgnorableBoundaryKey` field. The levelIter may return a file's smallest or
largest boundary key [without any value], setting `isIgnorableBoundaryKey` to
true to signal the key doesn't repesent a real KV pair. This scheme is also
extended to SeekPrefixGE, which previously synthesized a RANGEDEL key to serve
a similar purpose.

**internal/metamorphic: add block-property filter coverage**

Add test coverage for block-property filters to the metamorphic tests.
Division of keys into blocks are nondeterministic, so this introduces
nondeterminism into Iterator behavior. To account for this, the metamorphic
test's iterator wrapper is adjusted to skip any keys that are eligible for
filtering.

Fix https://github.com/cockroachdb/pebble/issues/1725.

cockroach-teamcity: 

<!-- Reviewable:start -->
This change is [<img src="https://reviewable.io/review_button.png" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/cockroachdb/pebble/1737)
<!-- Reviewable:end -->


Patch:
diff --git a/compaction.go b/compaction.go
index d1cf40d6..bf4c592a 100644
--- a/compaction.go
+++ b/compaction.go
@@ -419,12 +419,6 @@ type compaction struct {
 	bytesIterated uint64
 	// bytesWritten contains the number of bytes that have been written to outputs.
 	bytesWritten int64
-	// atomicBytesIterated points to the variable to increment during iteration.
-	// atomicBytesIterated must be read/written atomically. Flushing will increment
-	// the shared variable which compaction will read. This allows for the
-	// compaction routine to know how many bytes have been flushed before the flush
-	// is applied.
-	atomicBytesIterated *uint64
 
 	// The boundaries of the input data.
 	smallest InternalKey
@@ -512,22 +506,21 @@ func (c *compaction) makeInfo(jobID int) CompactionInfo {
 	return info
 }
 
-func newCompaction(pc *pickedCompaction, opts *Options, bytesCompacted *uint64) *compaction {
+func newCompaction(pc *pickedCompaction, opts *Options) *compaction {
 	c := &compaction{
-		kind:                compactionKindDefault,
-		cmp:                 pc.cmp,
-		equal:               opts.equal(),
-		formatKey:           opts.Comparer.FormatKey,
-		score:               pc.score,
-		inputs:              pc.inputs,
-		smallest:            pc.smallest,
-		largest:             pc.largest,
-		logger:              opts.Logger,
-		version:             pc.version,
-		maxOutputFileSize:   pc.maxOutputFileSize,
-		maxOverlapBytes:     pc.maxOverlapBytes,
-		atomicBytesIterated: bytesCompacted,
-		l0SublevelInfo:      pc.l0SublevelInfo,
+		kind:              compactionKindDefault,
+		cmp:               pc.cmp,
+		equal:             opts.equal(),
+		formatKey:         opts.Comparer.FormatKey,
+		score:             pc.score,
+		inputs:            pc.inputs,
+		smallest:          pc.smallest,
+		largest:           pc.largest,
+		logger:            opts.Logger,
+		version:           pc.version,
+		maxOutputFileSize: pc.maxOutputFileSize,
+		maxOverlapBytes:   pc.maxOverlapBytes,
+		l0SublevelInfo:    pc.l0SublevelInfo,
 	}
 	c.startLevel = &c.inputs[0]
 	c.outputLevel = &c.inputs[1]
@@ -643,21 +636,18 @@ func adjustGrandparentOverlapBytesForFlush(c *compaction, flushingBytes uint64)
 	}
 }
 
-func newFlush(
-	opts *Options, cur *version, baseLevel int, flushing flushableList, bytesFlushed *uint64,
-) *compaction {
+func newFlush(opts *Options, cur *version, baseLevel int, flushing flushableList) *compaction {
 	c := &compaction{
-		kind:                compactionKindFlush,
-		cmp:                 opts.Comparer.Compare,
-		equal:               opts.equal(),
-		formatKey:           opts.Comparer.FormatKey,
-		logger:              opts.Logger,
-		version:             cur,
-		inputs:              []compactionLevel{{level: -1}, {level: 0}},
-		maxOutputFileSize:   math.MaxUint64,
-		maxOverlapBytes:     math.MaxUint64,
-		flushing:            flushing,
-		atomicBytesIterated: bytesFlushed,
+		kind:              compactionKindFlush,
+		cmp:               opts.Comparer.Compare,
+		equal:             opts.equal(),
+		formatKey:         opts.Comparer.FormatKey,
+		logger:            opts.Logger,
+		version:           cur,
+		inputs:            []compactionLevel{{level: -1}, {level: 0}},
+		maxOutputFileSize: math.MaxUint64,
+		maxOverlapBytes:   math.MaxUint64,
+		flushing:          flushing,
 	}
 	c.startLevel = &c.inputs[0]
 	c.outputLevel = &c.inputs[1]
@@ -1388,33 +1378,6 @@ func (d *DB) removeInProgressCompaction(c *compaction) {
 	d.mu.versions.currentVersion().L0Sublevels.InitCompactingFileInfo(l0InProgress)
 }
 
-func (d *DB) getCompactionPacerInfo() compactionPacerInfo {
-	bytesFlushed := atomic.LoadUint64(&d.atomic.bytesFlushed)
-
-	d.mu.Lock()
-	estimatedMaxWAmp := d.mu.versions.picker.getEstimatedMaxWAmp()
-	pacerInfo := compactionPacerInfo{
-		slowdownThreshold:   uint64(estimatedMaxWAmp * float64(d.opts.MemTableSize)),
-		totalCompactionDebt: d.mu.versions.picker.estimatedCompactionDebt(bytesFlushed),
-	}
-	for _, m := range d.mu.mem.queue {
-		pacerInfo.totalDirtyBytes += m.inuseBytes()
-	}
-	d.mu.Unlock()
-
-	return pacerInfo
-}
-
-func (d *DB) getFlushPacerInfo() flushPacerInfo {
-	var pacerInfo flushPacerInfo
-	d.mu.Lock()
-	for _, m := range d.mu.mem.queue {
-		pacerInfo.inuseBytes += m.inuseBytes()
-	}
-	d.mu.Unlock()
-	return pacerInfo
-}
-
 func (d *DB) calculateDiskAvailableBytes() uint64 {
 	if space, err := d.opts.FS.GetDiskUsage(d.dirname); err == nil {
 		atomic.StoreUint64(&d.atomic.diskAvailBytes, space.AvailBytes)
@@ -1595,7 +1558,7 @@ func (d *DB) flush1() (bytesFlushed uint64, err error) {
 	}
 
 	c := newFlush(d.opts, d.mu.versions.currentVersion(),
-		d.mu.versions.picker.getBaseLevel(), d.mu.mem.queue[:n], &d.atomic.bytesFlushed)
+		d.mu.versions.picker.getBaseLevel(), d.mu.mem.queue[:n])
 	d.addInProgressCompaction(c)
 
 	jobID := d.mu.nextJobID
@@ -1606,17 +1569,7 @@ func (d *DB) flush1() (bytesFlushed uint64, err error) {
 	})
 	startTime := d.timeNow()
 
-	flushPacer := (pacer)(nilPacer)
-	if d.opts.private.enablePacing {
-		// TODO(peter): Flush pacing is disabled until we figure out why it impacts
-		// throughput.
-		flushPacer = newFlushPacer(flushPacerEnv{
-			limiter:      d.flushLimiter,
-			memTableSize: uint64(d.opts.MemTableSize),
-			getInfo:      d.getFlushPacerInfo,
-		})
-	}
-	ve, pendingOutputs, err := d.runCompaction(jobID, c, flushPacer)
+	ve, pendingOutputs, err := d.runCompaction(jobID, c)
 
 	info := FlushInfo{
 		JobID:    jobID,
@@ -1654,15 +1607,12 @@ func (d *DB) flush1() (bytesFlushed uint64, err error) {
 		}
 	}
 
+	bytesFlushed = c.bytesIterated
 	d.maybeUpdateDeleteCompactionHints(c)
 	d.removeInProgressCompaction(c)
 	d.mu.versions.incrementCompactions(c.kind, c.extraLevels)
 	d.mu.versions.incrementCompactionBytes(-c.bytesWritten)
 
-	// Refresh bytes flushed count.
-	bytesFlushed = atomic.LoadUint64(&d.atomic.bytesFlushed)
-	atomic.StoreUint64(&d.atomic.bytesFlushed, 0)
-
 	var flushed flushableList
 	if err == nil {
 		flushed = d.mu.mem.queue[:n]
@@ -1755,7 +1705,6 @@ func (d *DB) maybeScheduleCompactionPicker(
 	}
 
 	env := compactionEnv{
-		bytesCompacted:          &d.atomic.bytesCompacted,
 		earliestSnapshotSeqNum:  d.mu.snapshots.earliest(),
 		earliestUnflushedSeqNum: d.getEarliestUnflushedSeqNumLocked(),
 	}
@@ -1783,7 +1732,7 @@ func (d *DB) maybeScheduleCompactionPicker(
 		env.inProgressCompactions = d.getInProgressCompactionInfoLocked(nil)
 		pc, retryLater := d.mu.versions.picker.pickManual(env, manual)
 		if pc != nil {
-			c := newCompaction(pc, d.opts, env.bytesCompacted)
+			c := newCompaction(pc, d.opts)
 			d.mu.compact.manual = d.mu.compact.manual[1:]
 			d.mu.compact.compactingCount++
 			d.addInProgressCompaction(c)
@@ -1810,7 +1759,7 @@ func (d *DB) maybeScheduleCompactionPicker(
 		if pc == nil {
 			break
 		}
-		c := newCompaction(pc, d.opts, env.bytesCompacted)
+		c := newCompaction(pc, d.opts)
 		d.mu.compact.compactingCount++
 		d.addInProgressCompaction(c)
 		go d.compact(c, nil)
@@ -2124,17 +2073,7 @@ func (d *DB) compact1(c *compaction, errChannel chan error) (err error) {
 	d.opts.EventListener.CompactionBegin(info)
 	startTime := d.timeNow()
 
-	compactionPacer := (pacer)(nilPacer)
-	if d.opts.private.enablePacing {
-		// TODO(peter): Compaction pacing is disabled until we figure out why it
-		// impacts throughput.
-		compactionPacer = newCompactionPacer(compactionPacerEnv{
-			limiter:      d.compactionLimiter,
-			memTableSize: uint64(d.opts.MemTableSize),
-			getInfo:      d.getCompactionPacerInfo,
-		})
-	}
-	ve, pendingOutputs, err := d.runCompaction(jobID, c, compactionPacer)
+	ve, pendingOutputs, err := d.runCompaction(jobID, c)
 
 	info.Duration = d.timeNow().Sub(startTime)
 	if err == nil {
@@ -2185,7 +2124,7 @@ func (d *DB) compact1(c *compaction, errChannel chan error) (err error) {
 // d.mu must be held when calling this, but the mutex may be dropped and
 // re-acquired during the course of this method.
 func (d *DB) runCompaction(
-	jobID int, c *compaction, pacer pacer,
+	jobID int, c *compaction,
 ) (ve *versionEdit, pendingOutputs []*fileMetadata, retErr error) {
 	// As a sanity check, confirm that the smallest / largest keys for new and
 	// deleted files in the new versionEdit pass a validation function before
@@ -2632,14 +2571,6 @@ func (d *DB) runCompaction(
 	}
 	splitter := &splitterGroup{cmp: c.cmp, splitters: outputSplitters}
 
-	// NB: we avoid calling maybeThrottle on a nilPacer because the cost of
-	// dynamic dispatch in the hot loop below is pronounced in CPU profiles (see
-	// #1030). Additionally, even the cost of this interface comparison is
-	// pronounced in CPU profiles, so we hoist the entire thing out of the hot
-	// loop. This allows the branch predictor to do its job and make the pacer
-	// interactions ~free when a nilPacer is used.
-	isNilPacer := pacer == nilPacer
-
 	// Each outer loop iteration produces one output file. An iteration that
 	// produces a file containing point keys (and optionally range tombstones)
 	// guarantees that the input iterator advanced. An iteration that produces
@@ -2657,12 +2588,6 @@ func (d *DB) runCompaction(
 				break
 			}
 
-			atomic.StoreUint64(c.atomicBytesIterated, c.bytesIterated)
-			if !isNilPacer {
-				if err := pacer.maybeThrottle(c.bytesIterated); err != nil {
-					return nil, pendingOutputs, err
-				}
-			}
 			switch key.Kind() {
 			case InternalKeyKindRangeDelete:
 				// Range tombstones are handled specially. They are fragmented,
diff --git a/compaction_picker.go b/compaction_picker.go
index f9b54dc1..b38e1a8e 100644
--- a/compaction_picker.go
+++ b/compaction_picker.go
@@ -22,7 +22,6 @@ const minIntraL0Count = 4
 const levelMultiplier = 10
 
 type compactionEnv struct {
-	bytesCompacted          *uint64
 	earliestUnflushedSeqNum uint64
 	earliestSnapshotSeqNum  uint64
 	inProgressCompactions   []compactionInfo
diff --git a/db.go b/db.go
index 1ce35c84..76c48339 100644
--- a/db.go
+++ b/db.go
@@ -220,16 +220,6 @@ type DB struct {
 		memTableCount    int64
 		memTableReserved int64 // number of bytes reserved in the cache for memtables
 
-		// bytesFlushed is the number of bytes flushed in the current flush. This
-		// must be read/written atomically since it is accessed by both the flush
-		// and compaction routines.
-		bytesFlushed uint64
-
-		// bytesCompacted is the number of bytes compacted in the current compaction.
-		// This is used as a dummy variable to increment during compaction, and the
-		// value is not used anywhere.
-		bytesCompacted uint64
-
 		// The size of the current log file (i.e. db.mu.log.queue[len(queue)-1].
 		logSize uint64
 
@@ -279,9 +269,7 @@ type DB struct {
 	closed   *atomic.Value
 	closedCh chan struct{}
 
-	compactionLimiter limiter
-	flushLimiter      limiter
-	deletionLimiter   limiter
+	deletionLimiter limiter
 
 	// Async deletion jobs spawned by cleaners increment this WaitGroup, and
 	// call Done when completed. Once `d.mu.cleaning` is false, the db.Close()
@@ -1078,10 +1066,7 @@ func constructPointIter(
 
 		li.init(dbi.opts, dbi.cmp, dbi.split, dbi.newIters, files, level, nil)
 		li.initRangeDel(&mlevels[mlevelsIndex].rangeDelIter)
-		li.initSmallestLargestUserKey(&mlevels[mlevelsIndex].smallestUserKey,
-			&mlevels[mlevelsIndex].largestUserKey,
-			&mlevels[mlevelsIndex].isLargestUserKeyRangeDelSentinel)
-		li.initIsSyntheticIterBoundsKey(&mlevels[mlevelsIndex].isSyntheticIterBoundsKey)
+		li.initBoundaryContext(&mlevels[mlevelsIndex].levelIterBoundaryContext)
 		mlevels[mlevelsIndex].iter = li
 
 		levelsIndex++
diff --git a/internal.go b/internal.go
index 174bfbeb..e07931b2 100644
--- a/internal.go
+++ b/internal.go
@@ -34,3 +34,7 @@ type InternalKey = base.InternalKey
 type internalIterator = base.InternalIterator
 
 type internalIteratorWithStats = base.InternalIteratorWithStats
+
+// ErrCorruption is a marker to indicate that data in a file (WAL, MANIFEST,
+// sstable) isn't in the expected format.
+var ErrCorruption = base.ErrCorruption
diff --git a/internal/metamorphic/generator.go b/internal/metamorphic/generator.go
index 00361a15..69fa33b8 100644
--- a/internal/metamorphic/generator.go
+++ b/internal/metamorphic/generator.go
@@ -497,12 +497,30 @@ func (g *generator) newIter() {
 		lower, upper = upper, lower
 	}
 	keyTypes, maskSuffix := g.randKeyTypesAndMask()
+
+	// With a low probability, enable automatic filtering of keys with suffixes
+	// not in the provided range. This filtering occurs both through
+	// block-property filtering and explicitly within the iterator operations to
+	// ensure determinism.
+	var filterMin, filterMax uint64
+	if g.rng.Intn(10) == 1 {
+		max := g.cfg.writeSuffixDist.Max()
+		filterMin, filterMax = g.rng.Uint64n(max)+1, g.rng.Uint64n(max)+1
+		if filterMin > filterMax {
+			filterMin, filterMax = filterMax, filterMin
+		} else if filterMin == filterMax {
+			filterMax = filterMin + 1
+		}
+	}
+
 	g.add(&newIterOp{
 		readerID:           readerID,
 		iterID:             iterID,
 		lower:              lower,
 		upper:              upper,
 		keyTypes:           keyTypes,
+		filterMin:          filterMin,
+		filterMax:          filterMax,
 		rangeKeyMaskSuffix: maskSuffix,
 	})
 }
diff --git a/internal/metamorphic/ops.go b/internal/metamorphic/ops.go
index 1a80231a..4ce33818 100644
--- a/internal/metamorphic/ops.go
+++ b/internal/metamorphic/ops.go
@@ -559,6 +559,17 @@ type newIterOp struct {
 	upper    []byte
 	keyTypes uint32 // pebble.IterKeyType
 
+	// If filterMax is >0, this iterator will filter out any keys that have
+	// suffixes that don't fall within the range [filterMin,filterMax).
+	// Additionally, the iterator will be constructed with a block-property
+	// filter that filters out blocks accordingly. Not all OPTIONS hook up the
+	// corresponding block property collector, so block-filtering may still be
+	// effectively disabled in some runs. The iterator operations themselves
+	// however will always skip past any points that should be filtered to
+	// ensure determinism.
+	filterMin uint64
+	filterMax uint64
+
 	// rangeKeyMaskSuffix may be set if keyTypes is IterKeyTypePointsAndRanges
 	// to configure IterOptions.RangeKeyMasking.Suffix.
 	rangeKeyMaskSuffix []byte
@@ -581,6 +592,12 @@ func (o *newIterOp) run(t *test, h *history) {
 			Suffix: o.rangeKeyMaskSuffix,
 		},
 	}
+	if o.filterMax > 0 {
+		opts.PointKeyFilters = []pebble.BlockPropertyFilter{
+			newBlockPropertyFilter(o.filterMin, o.filterMax),
+		}
+	}
+
 	var i *pebble.Iterator
 	for {
 		i = r.NewIter(opts)
@@ -590,7 +607,7 @@ func (o *newIterOp) run(t *test, h *history) {
 		// close this iter and retry NewIter
 		_ = i.Close()
 	}
-	t.setIter(o.iterID, i)
+	t.setIter(o.iterID, i, o.filterMin, o.filterMax)
 
 	// Trash the bounds to ensure that Pebble doesn't rely on the stability of
 	// the user-provided bounds.
@@ -601,8 +618,8 @@ func (o *newIterOp) run(t *test, h *history) {
 }
 
 func (o *newIterOp) String() string {
-	return fmt.Sprintf("%s = %s.NewIter(%q, %q, %d /* key types */, %q /* masking suffix */)",
-		o.iterID, o.readerID, o.lower, o.upper, o.keyTypes, o.rangeKeyMaskSuffix)
+	return fmt.Sprintf("%s = %s.NewIter(%q, %q, %d /* key types */, %d, %d, %q /* masking suffix */)",
+		o.iterID, o.readerID, o.lower, o.upper, o.keyTypes, o.filterMin, o.filterMax, o.rangeKeyMaskSuffix)
 }
 
 // newIterUsingCloneOp models a Iterator.Clone operation.
@@ -618,7 +635,7 @@ func (o *newIterUsingCloneOp) run(t *test, h *history) {
 	if err != nil {
 		panic(err)
 	}
-	t.setIter(o.iterID, i)
+	t.setIter(o.iterID, i, iter.filterMin, iter.filterMax)
 	h.Recordf("%s // %v", o, i.Error())
 }
 
@@ -663,6 +680,17 @@ type iterSetOptionsOp struct {
 	upper    []byte
 	keyTypes uint32 // pebble.IterKeyType
 
+	// If filterMax is >0, this iterator will filter out any keys that have
+	// suffixes that don't fall within the range [filterMin,filterMax).
+	// Additionally, the iterator will be constructed with a block-property
+	// filter that filters out blocks accordingly. Not all OPTIONS hook up the
+	// corresponding block property collector, so block-filtering may still be
+	// effectively disabled in some runs. The iterator operations themselves
+	// however will always skip past any points that should be filtered to
+	// ensure determinism.
+	filterMin uint64
+	filterMax uint64
+
 	// rangeKeyMaskSuffix may be set if keyTypes is IterKeyTypePointsAndRanges
 	// to configure IterOptions.RangeKeyMasking.Suffix.
 	rangeKeyMaskSuffix []byte
@@ -686,6 +714,11 @@ func (o *iterSetOptionsOp) run(t *test, h *history) {
 			Suffix: o.rangeKeyMaskSuffix,
 		},
 	}
+	if o.filterMax > 0 {
+		opts.PointKeyFilters = []pebble.BlockPropertyFilter{
+			newBlockPropertyFilter(o.filterMin, o.filterMax),
+		}
+	}
 
 	i.SetOptions(opts)
 
@@ -694,12 +727,15 @@ func (o *iterSetOptionsOp) run(t *test, h *history) {
 	rand.Read(lower[:])
 	rand.Read(upper[:])
 
+	// Adjust the iterator's filters.
+	i.filterMin, i.filterMax = o.filterMin, o.filterMax
+
 	h.Recordf("%s // %v", o, i.Error())
 }
 
 func (o *iterSetOptionsOp) String() string {
-	return fmt.Sprintf("%s.SetOptions(%q, %q, %d /* key types */, %q /* masking suffix */)",
-		o.iterID, o.lower, o.upper, o.keyTypes, o.rangeKeyMaskSuffix)
+	return fmt.Sprintf("%s.SetOptions(%q, %q, %d /* key types */, %d, %d, %q /* masking suffix */)",
+		o.iterID, o.lower, o.upper, o.keyTypes, o.filterMin, o.filterMax, o.rangeKeyMaskSuffix)
 }
 
 // iterSeekGEOp models an Iterator.SeekGE[WithLimit] operation.
diff --git a/internal/metamorphic/options.go b/internal/metamorphic/options.go
index 53724523..96d18223 100644
--- a/internal/metamorphic/options.go
+++ b/internal/metamorphic/options.go
@@ -7,13 +7,16 @@ package metamorphic
 import (
 	"bytes"
 	"fmt"
+	"math"
 	"os"
 	"path/filepath"
 
 	"github.com/cockroachdb/pebble"
 	"github.com/cockroachdb/pebble/bloom"
+	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/internal/cache"
 	"github.com/cockroachdb/pebble/internal/testkeys"
+	"github.com/cockroachdb/pebble/sstable"
 	"github.com/cockroachdb/pebble/vfs"
 	"golang.org/x/exp/rand"
 )
@@ -49,6 +52,10 @@ func parseOptions(opts *testOptions, data string) error {
 			case "TestOptions.initial_state_path":
 				opts.initialStatePath = value
 				return true
+			case "TestOptions.use_block_property_filter":
+				opts.useBlockPropertyCollector = true
+				opts.opts.BlockPropertyCollectors = blockPropertyCollectorConstructors
+				return true
 			default:
 				return false
 			}
@@ -78,6 +85,9 @@ func optionsToString(opts *testOptions) string {
 	if opts.initialStateDesc != "" {
 		fmt.Fprintf(&buf, "  initial_state_desc=%s\n", opts.initialStateDesc)
 	}
+	if opts.useBlockPropertyCollector {
+		fmt.Fprintf(&buf, "  use_block_property_filter=%t\n", opts.useBlockPropertyCollector)
+	}
 
 	s := opts.opts.String()
 	if buf.Len() == 0 {
@@ -113,6 +123,9 @@ type testOptions struct {
 	// A human-readable string describing the initial state of the database.
 	// Empty if the test run begins from an empty database state.
 	initialStateDesc string
+	// Use a block property collector, which may be used by block property
+	// filters.
+	useBlockPropertyCollector bool
 }
 
 func standardOptions() []*testOptions {
@@ -212,6 +225,10 @@ func standardOptions() []*testOptions {
 [Options]
   max_writer_concurrency=2
   force_writer_parallelism=true
+`,
+		23: `
+[TestOptions]
+  use_block_property_filter=true
 `,
 	}
 
@@ -282,6 +299,10 @@ func randomOptions(rng *rand.Rand) *testOptions {
 	}
 	testOpts.ingestUsingApply = rng.Intn(2) != 0
 	testOpts.replaceSingleDelete = rng.Intn(2) != 0
+	testOpts.useBlockPropertyCollector = rng.Intn(2) != 0
+	if testOpts.useBlockPropertyCollector {
+		testOpts.opts.BlockPropertyCollectors = blockPropertyCollectorConstructors
+	}
 	return testOpts
 }
 
@@ -340,3 +361,76 @@ func moveLogs(fs vfs.FS, srcDir, dstDir string) error {
 	}
 	return nil
 }
+
+const blockPropertyCollectorName = `pebble.internal.metamorphic.suffixes`
+
+var blockPropertyCollectorConstructors = []func() pebble.BlockPropertyCollector{
+	func() pebble.BlockPropertyCollector {
+		return sstable.NewBlockIntervalCollector(
+			blockPropertyCollectorName,
+			&suffixIntervalCollector{},
+			nil)
+	},
+}
+
+// newBlockPropertyFilter constructs a new block-property filter that ignores
+// blocks containing exclusively suffixed keys where all the suffixes fall
+// outside of the range [filterMin, filterMax).
+//
+// The filter only filters based on data derived from the key. The iteration
+// results of this block property filter are deterministic for unsuffixed keys
+// and keys with suffixes within the range [filterMin, filterMax). For keys with
+// suffixes outside the range, iteration is nondeterministic. To accommodate
+// this, the metamorphic test iterators automatically skip keys outside the
+// configured filter span.
+func newBlockPropertyFilter(filterMin, filterMax uint64) pebble.BlockPropertyFilter {
+	return sstable.NewBlockIntervalFilter(blockPropertyCollectorName, filterMin, filterMax)
+}
+
+var _ sstable.DataBlockIntervalCollector = (*suffixIntervalCollector)(nil)
+
+// suffixIntervalCollector maintains an interval over the timestamps in
+// MVCC-like suffixes for keys (e.g. foo@123).
+type suffixIntervalCollector struct {
+	initialized  bool
+	lower, upper uint64
+}
+
+// Add implements DataBlockIntervalCollector by adding the timestamp(s) in the
+// suffix(es) of this record to the current interval.
+//
+// Note that range sets and unsets may have multiple suffixes. Range key deletes
+// do not have a suffix. All other point keys have a single suffix.
+func (c *suffixIntervalCollector) Add(key base.InternalKey, value []byte) error {
+	i := testkeys.Comparer.Split(key.UserKey)
+	if i == len(key.UserKey) {
+		c.initialized = true
+		c.lower, c.upper = 0, math.MaxUint64
+		return nil
+	}
+	ts, err := testkeys.ParseSuffix(key.UserKey[i:])
+	if err != nil {
+		return err
+	}
+	uts := uint64(ts)
+	if !c.initialized {
+		c.lower, c.upper = uts, uts+1
+		c.initialized = true
+		return nil
+	}
+	if uts < c.lower {
+		c.lower = uts
+	}
+	if uts >= c.upper {
+		c.upper = uts + 1
+	}
+	return nil
+}
+
+// FinishDataBlock implements DataBlockIntervalCollector.
+func (c *suffixIntervalCollector) FinishDataBlock() (lower, upper uint64, err error) {
+	l, u := c.lower, c.upper
+	c.lower, c.upper = 0, 0
+	c.initialized = false
+	return l, u, nil
+}
diff --git a/internal/metamorphic/parser.go b/internal/metamorphic/parser.go
index 49c8c17e..a12d1ef3 100644
--- a/internal/metamorphic/parser.go
+++ b/internal/metamorphic/parser.go
@@ -77,7 +77,7 @@ func opArgs(op op) (receiverID *objID, targetID *objID, args []interface{}) {
 	case *newIndexedBatchOp:
 		return nil, &t.batchID, nil
 	case *newIterOp:
-		return &t.readerID, &t.iterID, []interface{}{&t.lower, &t.upper, &t.keyTypes, &t.rangeKeyMaskSuffix}
+		return &t.readerID, &t.iterID, []interface{}{&t.lower, &t.upper, &t.keyTypes, &t.filterMin, &t.filterMax, &t.rangeKeyMaskSuffix}
 	case *newIterUsingCloneOp:
 		return &t.existingIterID, &t.iterID, []interface{}{&t.refreshBatch}
 	case *newSnapshotOp:
@@ -97,7 +97,7 @@ func opArgs(op op) (receiverID *objID, targetID *objID, args []interface{}) {
 	case *iterSetBoundsOp:
 		return &t.iterID, nil, []interface{}{&t.lower, &t.upper}
 	case *iterSetOptionsOp:
-		return &t.iterID, nil, []interface{}{&t.lower, &t.upper, &t.keyTypes, &t.rangeKeyMaskSuffix}
+		return &t.iterID, nil, []interface{}{&t.lower, &t.upper, &t.keyTypes, &t.filterMin, &t.filterMax, &t.rangeKeyMaskSuffix}
 	case *singleDeleteOp:
 		return &t.writerID, nil, []interface{}{&t.key, &t.maybeReplaceDelete}
 	case *rangeKeyDeleteOp:
@@ -262,6 +262,14 @@ func (p *parser) parseArgs(op op, methodName string, args []interface{}) {
 			}
 			*t = uint32(val)
 
+		case *uint64:
+			_, lit := p.scanToken(token.INT)
+			val, err := strconv.ParseUint(lit, 0, 64)
+			if err != nil {
+				panic(err)
+			}
+			*t = uint64(val)
+
 		case *[]byte:
 			_, lit := p.scanToken(token.STRING)
 			s, err := strconv.Unquote(lit)
diff --git a/internal/metamorphic/retryable.go b/internal/metamorphic/retryable.go
index e9f6833a..0a201c66 100644
--- a/internal/metamorphic/retryable.go
+++ b/internal/metamorphic/retryable.go
@@ -8,6 +8,7 @@ import (
 	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble"
 	"github.com/cockroachdb/pebble/internal/errorfs"
+	"github.com/cockroachdb/pebble/internal/testkeys"
 )
 
 // withRetries executes fn, retrying it whenever an errorfs.ErrInjected error
@@ -28,6 +29,27 @@ func withRetries(fn func() error) error {
 type retryableIter struct {
 	iter    *pebble.Iterator
 	lastKey []byte
+
+	// When filterMax is >0, this iterator filters out keys with suffixes
+	// outside of the range [filterMin, filterMax). Keys without suffixes are
+	// surfaced. This is used to ensure determinism regardless of whether
+	// block-property filters filter keys or not.
+	filterMin, filterMax uint64
+}
+
+func (i *retryableIter) shouldFilter() bool {
+	k := i.iter.Key()
+	n := testkeys.Comparer.Split(k)
+	if n == len(k) {
+		// No suffix, don't filter it.
+		return false
+	}
+	v, err := testkeys.ParseSuffix(k[n:])
+	if err != nil {
+		panic(err)
+	}
+	ts := uint64(v)
+	return ts < i.filterMin || ts >= i.filterMax
 }
 
 func (i *retryableIter) needRetry() bool {
@@ -61,7 +83,12 @@ func (i *retryableIter) Error() error {
 
 func (i *retryableIter) First() bool {
 	var valid bool
-	i.withRetry(func() { valid = i.iter.First() })
+	i.withRetry(func() {
+		valid = i.iter.First()
+	})
+	if valid && i.shouldFilter() {
+		valid = i.Next()
+	}
 	return valid
 }
 
@@ -84,60 +111,98 @@ func (i *retryableIter) RangeKeys() []pebble.RangeKeyData {
 func (i *retryableIter) Last() bool {
 	var valid bool
 	i.withRetry(func() { valid = i.iter.Last() })
+	if valid && i.shouldFilter() {
+		valid = i.Prev()
+	}
 	return valid
 }
 
 func (i *retryableIter) Next() bool {
 	var valid bool
-	i.withRetry(func() { valid = i.iter.Next() })
+	i.withRetry(func() {
+		valid = i.iter.Next()
+		for valid && i.shouldFilter() {
+			valid = i.iter.Next()
+		}
+	})
 	return valid
 }
 
 func (i *retryableIter) NextWithLimit(limit []byte) pebble.IterValidityState {
 	var validity pebble.IterValidityState
-	i.withRetry(func() { validity = i.iter.NextWithLimit(limit) })
+	i.withRetry(func() {
+		validity = i.iter.NextWithLimit(limit)
+		for validity == pebble.IterValid && i.shouldFilter() {
+			validity = i.iter.NextWithLimit(limit)
+		}
+	})
 	return validity
 }
 
 func (i *retryableIter) Prev() bool {
 	var valid bool
-	i.withRetry(func() { valid = i.iter.Prev() })
+	i.withRetry(func() {
+		valid = i.iter.Prev()
+		for valid && i.shouldFilter() {
+			valid = i.iter.Prev()
+		}
+	})
 	return valid
 }
 
 func (i *retryableIter) PrevWithLimit(limit []byte) pebble.IterValidityState {
 	var validity pebble.IterValidityState
-	i.withRetry(func() { validity = i.iter.PrevWithLimit(limit) })
+	i.withRetry(func() {
+		validity = i.iter.PrevWithLimit(limit)
+		for validity == pebble.IterValid && i.shouldFilter() {
+			validity = i.iter.PrevWithLimit(limit)
+		}
+	})
 	return validity
 }
 
 func (i *retryableIter) SeekGE(key []byte) bool {
 	var valid bool
 	i.withRetry(func() { valid = i.iter.SeekGE(key) })
+	if valid && i.shouldFilter() {
+		valid = i.Next()
+	}
 	return valid
 }
 
 func (i *retryableIter) SeekGEWithLimit(key []byte, limit []byte) pebble.IterValidityState {
 	var validity pebble.IterValidityState
 	i.withRetry(func() { validity = i.iter.SeekGEWithLimit(key, limit) })
+	if validity == pebble.IterValid && i.shouldFilter() {
+		validity = i.NextWithLimit(limit)
+	}
 	return validity
 }
 
 func (i *retryableIter) SeekLT(key []byte) bool {
 	var valid bool
 	i.withRetry(func() { valid = i.iter.SeekLT(key) })
+	if valid && i.shouldFilter() {
+		valid = i.Prev()
+	}
 	return valid
 }
 
 func (i *retryableIter) SeekLTWithLimit(key []byte, limit []byte) pebble.IterValidityState {
 	var validity pebble.IterValidityState
 	i.withRetry(func() { validity = i.iter.SeekLTWithLimit(key, limit) })
+	if validity == pebble.IterValid && i.shouldFilter() {
+		validity = i.PrevWithLimit(limit)
+	}
 	return validity
 }
 
 func (i *retryableIter) SeekPrefixGE(key []byte) bool {
 	var valid bool
 	i.withRetry(func() { valid = i.iter.SeekPrefixGE(key) })
+	if valid && i.shouldFilter() {
+		valid = i.Next()
+	}
 	return valid
 }
 
diff --git a/level_checker.go b/level_checker.go
index ad508e7b..ed9a8d63 100644
--- a/level_checker.go
+++ b/level_checker.go
@@ -47,9 +47,9 @@ import (
 
 // The per-level structure used by simpleMergingIter.
 type simpleMergingIterLevel struct {
-	iter            internalIterator
-	rangeDelIter    keyspan.FragmentIterator
-	smallestUserKey []byte
+	iter         internalIterator
+	rangeDelIter keyspan.FragmentIterator
+	levelIterBoundaryContext
 
 	iterKey   *InternalKey
 	iterValue []byte
@@ -636,7 +636,7 @@ func checkLevelsInternal(c *checkConfig) (err error) {
 		li.init(iterOpts, c.cmp, nil /* split */, c.newIters, manifestIter,
 			manifest.L0Sublevel(sublevel), nil)
 		li.initRangeDel(&mlevelAlloc[0].rangeDelIter)
-		li.initSmallestLargestUserKey(&mlevelAlloc[0].smallestUserKey, nil, nil)
+		li.initBoundaryContext(&mlevelAlloc[0].levelIterBoundaryContext)
 		mlevelAlloc[0].iter = li
 		mlevelAlloc = mlevelAlloc[1:]
 	}
@@ -650,7 +650,7 @@ func checkLevelsInternal(c *checkConfig) (err error) {
 		li.init(iterOpts, c.cmp, nil /* split */, c.newIters,
 			current.Levels[level].Iter(), manifest.Level(level), nil)
 		li.initRangeDel(&mlevelAlloc[0].rangeDelIter)
-		li.initSmallestLargestUserKey(&mlevelAlloc[0].smallestUserKey, nil, nil)
+		li.initBoundaryContext(&mlevelAlloc[0].levelIterBoundaryContext)
 		mlevelAlloc[0].iter = li
 		mlevelAlloc = mlevelAlloc[1:]
 	}
diff --git a/level_iter.go b/level_iter.go
index d3dedeaa..37f07137 100644
--- a/level_iter.go
+++ b/level_iter.go
@@ -73,7 +73,17 @@ type levelIter struct {
 	//   be relevant to the iteration.
 	iter     internalIteratorWithStats
 	iterFile *fileMetadata
-	newIters tableNewIters
+	// filteredIter is an optional interface that may be implemented by internal
+	// iterators that perform filtering of keys. When a new file's iterator is
+	// opened, it's tested to see if it implements filteredIter. If it does,
+	// it's stored here to allow the level iterator to recognize when keys were
+	// omitted from iteration results due to filtering. This is important when a
+	// file contains range deletions that may delete keys from other files. The
+	// levelIter must not advance to the next file until the mergingIter has
+	// advanced beyond the file's bounds. See
+	// levelIterBoundaryContext.isIgnorableBoundaryKey.
+	filteredIter filteredIter
+	newIters     tableNewIters
 	// When rangeDelIterPtr != nil, the caller requires that *rangeDelIterPtr must
 	// point to a range del iterator corresponding to the current file. When this
 	// iterator returns nil, *rangeDelIterPtr should also be set to nil. Whenever
@@ -90,7 +100,7 @@ type levelIter struct {
 	// stats accumulates the stats of iters that have been closed.
 	stats InternalIteratorStats
 
-	// Pointer into this level's entry in `mergingIterLevel::smallestUserKey,largestUserKey`.
+	// Pointer into this level's entry in `mergingIterLevel::levelIterBoundaryContext`.
 	// We populate it with the corresponding bounds for the currently opened file. It is used for
 	// two purposes (described for forward iteration. The explanation for backward iteration is
 	// similar.)
@@ -136,14 +146,7 @@ type levelIter struct {
 	// - `*Boundary` is only populated when the iterator is positioned exactly on the sentinel key.
 	// - `*Boundary` can hold either the lower- or upper-bound, depending on the iterator direction.
 	// - `*Boundary` is not exposed to the next higher-level iterator, i.e., `mergingIter`.
-	smallestUserKey, largestUserKey  *[]byte
-	isLargestUserKeyRangeDelSentinel *bool
-
-	// Set to true iff the key returned by this iterator is a synthetic key
-	// derived from the iterator bounds. This is used to prevent the
-	// mergingIter from being stuck at such a synthetic key if it becomes the
-	// top element of the heap.
-	isSyntheticIterBoundsKey *bool
+	boundaryContext *levelIterBoundaryContext
 
 	// bytesIterated keeps track of the number of bytes iterated during compaction.
 	bytesIterated *uint64
@@ -154,6 +157,29 @@ type levelIter struct {
 	disableInvariants bool
 }
 
+// filteredIter is an additional interface implemented by iterators that may
+// skip over point keys during iteration. The sstable.Iterator implements this
+// interface.
+type filteredIter interface {
+	// MaybeFilteredKeys may be called when an iterator is exhausted, indicating
+	// whether or not the iterator's last positioning method may have skipped
+	// any keys due to low-level filters.
+	//
+	// When an iterator is configured to use block-property filters, the
+	// low-level iterator may skip over blocks or whole sstables of keys.
+	// Implementations that implement skipping must implement this interface.
+	// Higher-level iterators require it to preserve invariants (eg, a levelIter
+	// used in a mergingIter must keep the file's range-del iterator open until
+	// the mergingIter has moved past the file's bounds, even if all of the
+	// file's point keys were filtered).
+	//
+	// MaybeFilteredKeys may always return false positives, that is it may
+	// return true when no keys were filtered. It should only be called when the
+	// iterator is exhausted. It must never return false negatives when the
+	// iterator is exhausted.
+	MaybeFilteredKeys() bool
+}
+
 // levelIter implements the base.InternalIterator interface.
 var _ base.InternalIterator = (*levelIter)(nil)
 
@@ -203,16 +229,8 @@ func (l *levelIter) initRangeDel(rangeDelIter *keyspan.FragmentIterator) {
 	l.rangeDelIterPtr = rangeDelIter
 }
 
-func (l *levelIter) initSmallestLargestUserKey(
-	smallestUserKey, largestUserKey *[]byte, isLargestUserKeyRangeDelSentinel *bool,
-) {
-	l.smallestUserKey = smallestUserKey
-	l.largestUserKey = largestUserKey
-	l.isLargestUserKeyRangeDelSentinel = isLargestUserKeyRangeDelSentinel
-}
-
-func (l *levelIter) initIsSyntheticIterBoundsKey(isSyntheticIterBoundsKey *bool) {
-	l.isSyntheticIterBoundsKey = isSyntheticIterBoundsKey
+func (l *levelIter) initBoundaryContext(context *levelIterBoundaryContext) {
+	l.boundaryContext = context
 }
 
 func (l *levelIter) findFileGE(key []byte) *fileMetadata {
@@ -288,8 +306,9 @@ const (
 func (l *levelIter) loadFile(file *fileMetadata, dir int) loadFileReturnIndicator {
 	l.smallestBoundary = nil
 	l.largestBoundary = nil
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 	if l.iterFile == file {
 		if l.err != nil {
@@ -348,20 +367,25 @@ func (l *levelIter) loadFile(file *fileMetadata, dir int) loadFileReturnIndicato
 		if l.err != nil {
 			return noFileLoaded
 		}
+		if rangeDelIter != nil {
+			if fi, ok := iter.(filteredIter); ok {
+				l.filteredIter = fi
+			} else {
+				l.filteredIter = nil
+			}
+		} else {
+			l.filteredIter = nil
+		}
 		if l.rangeDelIterPtr != nil {
 			*l.rangeDelIterPtr = rangeDelIter
 			l.rangeDelIterCopy = rangeDelIter
 		} else if rangeDelIter != nil {
 			rangeDelIter.Close()
 		}
-		if l.smallestUserKey != nil {
-			*l.smallestUserKey = file.SmallestPointKey.UserKey
-		}
-		if l.largestUserKey != nil {
-			*l.largestUserKey = file.LargestPointKey.UserKey
-		}
-		if l.isLargestUserKeyRangeDelSentinel != nil {
-			*l.isLargestUserKeyRangeDelSentinel = file.LargestPointKey.IsExclusiveSentinel()
+		if l.boundaryContext != nil {
+			l.boundaryContext.smallestUserKey = file.Smallest.UserKey
+			l.boundaryContext.largestUserKey = file.Largest.UserKey
+			l.boundaryContext.isLargestUserKeyRangeDelSentinel = file.Largest.IsExclusiveSentinel()
 		}
 		return newFileLoaded
 	}
@@ -389,8 +413,9 @@ func (l *levelIter) verify(key *InternalKey, val []byte) (*InternalKey, []byte)
 
 func (l *levelIter) SeekGE(key []byte, trySeekUsingNext bool) (*InternalKey, []byte) {
 	l.err = nil // clear cached iteration error
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 
 	// NB: the top-level Iterator has already adjusted key based on
@@ -414,8 +439,9 @@ func (l *levelIter) SeekPrefixGE(
 	prefix, key []byte, trySeekUsingNext bool,
 ) (*base.InternalKey, []byte) {
 	l.err = nil // clear cached iteration error
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 
 	// NB: the top-level Iterator has already adjusted key based on
@@ -435,24 +461,27 @@ func (l *levelIter) SeekPrefixGE(
 	// When SeekPrefixGE returns nil, we have not necessarily reached the end of
 	// the sstable. All we know is that a key with prefix does not exist in the
 	// current sstable. We do know that the key lies within the bounds of the
-	// table as findFileGE found the table where key <= meta.Largest. We treat
-	// this case the same as SeekGE where an upper-bound resides within the
-	// sstable and generate a synthetic boundary key.
+	// table as findFileGE found the table where key <= meta.Largest. We return
+	// the table's bound with isIgnorableBoundaryKey set.
 	if l.rangeDelIterPtr != nil && *l.rangeDelIterPtr != nil {
 		if l.tableOpts.UpperBound != nil {
 			l.syntheticBoundary.UserKey = l.tableOpts.UpperBound
 			l.syntheticBoundary.Trailer = InternalKeyRangeDeleteSentinel
 			l.largestBoundary = &l.syntheticBoundary
-			if l.isSyntheticIterBoundsKey != nil {
-				*l.isSyntheticIterBoundsKey = true
+			if l.boundaryContext != nil {
+				l.boundaryContext.isSyntheticIterBoundsKey = true
+				l.boundaryContext.isIgnorableBoundaryKey = false
 			}
 			return l.verify(l.largestBoundary, nil)
 		}
-		l.syntheticBoundary = l.iterFile.LargestPointKey
-		l.syntheticBoundary.SetKind(InternalKeyKindRangeDelete)
-		l.largestBoundary = &l.syntheticBoundary
-		if l.isSyntheticIterBoundsKey != nil {
-			*l.isSyntheticIterBoundsKey = false
+		// Return the file's largest bound, ensuring this file stays open until
+		// the mergingIter advances beyond the file's bounds. We set
+		// isIgnorableBoundaryKey to signal that the actual key returned should
+		// be ignored, and does not represent a real key in the database.
+		l.largestBoundary = &l.iterFile.LargestPointKey
+		if l.boundaryContext != nil {
+			l.boundaryContext.isSyntheticIterBoundsKey = false
+			l.boundaryContext.isIgnorableBoundaryKey = true
 		}
 		return l.verify(l.largestBoundary, nil)
 	}
@@ -472,8 +501,9 @@ func (l *levelIter) SeekPrefixGE(
 
 func (l *levelIter) SeekLT(key []byte) (*InternalKey, []byte) {
 	l.err = nil // clear cached iteration error
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 
 	// NB: the top-level Iterator has already adjusted key based on
@@ -489,8 +519,9 @@ func (l *levelIter) SeekLT(key []byte) (*InternalKey, []byte) {
 
 func (l *levelIter) First() (*InternalKey, []byte) {
 	l.err = nil // clear cached iteration error
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 
 	// NB: the top-level Iterator will call SeekGE if IterOptions.LowerBound is
@@ -506,8 +537,9 @@ func (l *levelIter) First() (*InternalKey, []byte) {
 
 func (l *levelIter) Last() (*InternalKey, []byte) {
 	l.err = nil // clear cached iteration error
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 
 	// NB: the top-level Iterator will call SeekLT if IterOptions.UpperBound is
@@ -525,8 +557,9 @@ func (l *levelIter) Next() (*InternalKey, []byte) {
 	if l.err != nil || l.iter == nil {
 		return nil, nil
 	}
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 
 	switch {
@@ -565,8 +598,9 @@ func (l *levelIter) Prev() (*InternalKey, []byte) {
 	if l.err != nil || l.iter == nil {
 		return nil, nil
 	}
-	if l.isSyntheticIterBoundsKey != nil {
-		*l.isSyntheticIterBoundsKey = false
+	if l.boundaryContext != nil {
+		l.boundaryContext.isSyntheticIterBoundsKey = false
+		l.boundaryContext.isIgnorableBoundaryKey = false
 	}
 
 	switch {
@@ -637,8 +671,8 @@ func (l *levelIter) skipEmptyFileForward() (*InternalKey, []byte) {
 					l.syntheticBoundary.UserKey = l.tableOpts.UpperBound
 					l.syntheticBoundary.Trailer = InternalKeyRangeDeleteSentinel
 					l.largestBoundary = &l.syntheticBoundary
-					if l.isSyntheticIterBoundsKey != nil {
-						*l.isSyntheticIterBoundsKey = true
+					if l.boundaryContext != nil {
+						l.boundaryContext.isSyntheticIterBoundsKey = true
 					}
 					return l.largestBoundary, nil
 				}
@@ -653,6 +687,32 @@ func (l *levelIter) skipEmptyFileForward() (*InternalKey, []byte) {
 				l.largestBoundary = &l.iterFile.LargestPointKey
 				return l.largestBoundary, nil
 			}
+			// If the last point iterator positioning op might've skipped keys,
+			// it's possible the file's range deletions are still relevant to
+			// other levels. Return the largest boundary as a special ignorable
+			// marker to avoid advancing to the next file.
+			//
+			// The sstable iterator cannot guarantee that keys were skipped. A
+			// SeekGE that lands on a index separator k only knows that the
+			// block at the index entry contains keys ≤ k. We can't know whether
+			// there were actually keys between the seek key and the index
+			// separator key. If the block is then excluded due to block
+			// property filters, the iterator does not know whether keys were
+			// actually skipped by the block's exclusion.
+			//
+			// Since MaybeFilteredKeys cannot guarantee that keys were skipped,
+			// it's possible l.iterFile.Largest was already returned. Returning
+			// l.iterFile.Largest again is a violation of the strict
+			// monotonicity normally provided. The mergingIter's heap can
+			// tolerate this repeat key and in this case will keep the level at
+			// the top of the heap and immediately skip the entry, advancing to
+			// the next file.
+			if *l.rangeDelIterPtr != nil && l.filteredIter != nil &&
+				l.filteredIter.MaybeFilteredKeys() {
+				l.largestBoundary = &l.iterFile.Largest
+				l.boundaryContext.isIgnorableBoundaryKey = true
+				return l.largestBoundary, nil
+			}
 		}
 
 		// Current file was exhausted. Move to the next file.
@@ -698,8 +758,8 @@ func (l *levelIter) skipEmptyFileBackward() (*InternalKey, []byte) {
 					l.syntheticBoundary.UserKey = l.tableOpts.LowerBound
 					l.syntheticBoundary.Trailer = InternalKeyRangeDeleteSentinel
 					l.smallestBoundary = &l.syntheticBoundary
-					if l.isSyntheticIterBoundsKey != nil {
-						*l.isSyntheticIterBoundsKey = true
+					if l.boundaryContext != nil {
+						l.boundaryContext.isSyntheticIterBoundsKey = true
 					}
 					return l.smallestBoundary, nil
 				}
@@ -714,6 +774,31 @@ func (l *levelIter) skipEmptyFileBackward() (*InternalKey, []byte) {
 				l.smallestBoundary = &l.iterFile.SmallestPointKey
 				return l.smallestBoundary, nil
 			}
+			// If the last point iterator positioning op skipped keys, it's
+			// possible the file's range deletions are still relevant to other
+			// levels. Return the smallest boundary as a special ignorable key
+			// to avoid advancing to the next file.
+			//
+			// The sstable iterator cannot guarantee that keys were skipped.  A
+			// SeekGE that lands on a index separator k only knows that the
+			// block at the index entry contains keys ≤ k. We can't know whether
+			// there were actually keys between the seek key and the index
+			// separator key. If the block is then excluded due to block
+			// property filters, the iterator does not know whether keys were
+			// actually skipped by the block's exclusion.
+			//
+			// Since MaybeFilteredKeys cannot guarantee that keys were skipped,
+			// it's possible l.iterFile.Smallest was already returned. Returning
+			// l.iterFile.Smallest again is a violation of the strict
+			// monotonicity normally provided. The mergingIter's heap can
+			// tolerate this repeat key and in this case will keep the level at
+			// the top of the heap and immediately skip the entry, advancing to
+			// the next file.
+			if *l.rangeDelIterPtr != nil && l.filteredIter != nil && l.filteredIter.MaybeFilteredKeys() {
+				l.smallestBoundary = &l.iterFile.Smallest
+				l.boundaryContext.isIgnorableBoundaryKey = true
+				return l.smallestBoundary, nil
+			}
 		}
 
 		// Current file was exhausted. Move to the previous file.
diff --git a/merging_iter.go b/merging_iter.go
index 363cc5ba..e9cf3b2e 100644
--- a/merging_iter.go
+++ b/merging_iter.go
@@ -26,12 +26,10 @@ type mergingIterLevel struct {
 	iterKey   *InternalKey
 	iterValue []byte
 
-	// smallestUserKey, largestUserKey, isLargestUserKeyRangeDelSentinel are set using the sstable
-	// boundary keys when using levelIter. See levelIter comment and the Range Deletions comment
-	// below.
-	smallestUserKey, largestUserKey  []byte
-	isLargestUserKeyRangeDelSentinel bool
-	isSyntheticIterBoundsKey         bool
+	// levelIterBoundaryContext's fields are set when using levelIter, in order
+	// to surface sstable boundary keys and file-level context. See levelIter
+	// comment and the Range Deletions comment below.
+	levelIterBoundaryContext
 
 	// tombstone caches the tombstone rangeDelIter is currently pointed at. If
 	// tombstone is nil, there are no further tombstones within the
@@ -42,6 +40,27 @@ type mergingIterLevel struct {
 	tombstone *keyspan.Span
 }
 
+type levelIterBoundaryContext struct {
+	// smallestUserKey and largestUserKey are populated with the smallest and
+	// largest boundaries of the current file.
+	smallestUserKey, largestUserKey []byte
+	// isLargestUserKeyRangeDelSentinel is set to true when a file's largest
+	// boundary is an exclusive range deletion sentinel. If true, the file does
+	// not contain any keys with the provided user key, and the largestUserKey
+	// bound is exclusive.
+	isLargestUserKeyRangeDelSentinel bool
+	// isSyntheticIterBoundsKey is set to true iff the key returned by the level
+	// iterator is a synthetic key derived from the iterator bounds. This is
+	// used to prevent the mergingIter from being stuck at such a synthetic key
+	// if it becomes the top element of the heap.
+	isSyntheticIterBoundsKey bool
+	// isIgnorableBoundaryKey is set to true iff the key returned by the level
+	// iterator is a file boundary key that should be ignored. This is used to
+	// keep a levelIter file's range deletion iterator open as long as other
+	// levels within the merging iterator require it.
+	isIgnorableBoundaryKey bool
+}
+
 // mergingIter provides a merged view of multiple iterators from different
 // levels of the LSM.
 //
@@ -672,6 +691,7 @@ func (m *mergingIter) findNextEntry() (*InternalKey, []byte) {
 			continue
 		}
 		if item.key.Visible(m.snapshot) &&
+			(!m.levels[item.index].isIgnorableBoundaryKey) &&
 			(item.key.Kind() != InternalKeyKindRangeDelete || !m.elideRangeTombstones) {
 			return &item.key, item.value
 		}
@@ -820,6 +840,7 @@ func (m *mergingIter) findPrevEntry() (*InternalKey, []byte) {
 			continue
 		}
 		if item.key.Visible(m.snapshot) &&
+			(!m.levels[item.index].isIgnorableBoundaryKey) &&
 			(item.key.Kind() != InternalKeyKindRangeDelete || !m.elideRangeTombstones) {
 			return &item.key, item.value
 		}
diff --git a/open.go b/open.go
index 4010a55f..ed217ce9 100644
--- a/open.go
+++ b/open.go
@@ -124,12 +124,6 @@ func Open(dirname string, opts *Options) (db *DB, _ error) {
 		apply:         d.commitApply,
 		write:         d.commitWrite,
 	})
-	d.compactionLimiter = rate.NewLimiter(
-		rate.Limit(d.opts.private.minCompactionRate),
-		d.opts.private.minCompactionRate)
-	d.flushLimiter = rate.NewLimiter(
-		rate.Limit(d.opts.private.minFlushRate),
-		d.opts.private.minFlushRate)
 	d.deletionLimiter = rate.NewLimiter(
 		rate.Limit(d.opts.Experimental.MinDeletionRate),
 		d.opts.Experimental.MinDeletionRate)
@@ -706,8 +700,8 @@ func (d *DB) replayWAL(
 	// mem is nil here.
 	if !d.opts.ReadOnly {
 		c := newFlush(d.opts, d.mu.versions.currentVersion(),
-			1 /* base level */, toFlush, &d.atomic.bytesFlushed)
-		newVE, _, err := d.runCompaction(jobID, c, nilPacer)
+			1 /* base level */, toFlush)
+		newVE, _, err := d.runCompaction(jobID, c)
 		if err != nil {
 			return 0, err
 		}
diff --git a/options.go b/options.go
index d0d23887..c47d087a 100644
--- a/options.go
+++ b/options.go
@@ -715,24 +715,9 @@ type Options struct {
 		// RocksDB 6.2.1.
 		strictWALTail bool
 
-		// TODO(peter): A private option to enable flush/compaction pacing. Only used
-		// by tests. Compaction/flush pacing is disabled until we fix the impact on
-		// throughput.
-		enablePacing bool
-
 		// A private option to disable stats collection.
 		disableTableStats bool
 
-		// minCompactionRate sets the minimum rate at which compactions occur. The
-		// default is 4 MB/s. Currently disabled as this option has no effect while
-		// private.enablePacing is false.
-		minCompactionRate int
-
-		// minFlushRate sets the minimum rate at which the MemTables are flushed. The
-		// default is 1 MB/s. Currently disabled as this option has no effect while
-		// private.enablePacing is false.
-		minFlushRate int
-
 		// fsCloser holds a closer that should be invoked after a DB using these
 		// Options is closed. This is used to automatically stop the
 		// long-running goroutine associated with the disk-health-checking FS.
@@ -847,12 +832,6 @@ func (o *Options) EnsureDefaults() *Options {
 		o.Merger = DefaultMerger
 	}
 	o.private.strictWALTail = true
-	if o.private.minCompactionRate == 0 {
-		o.private.minCompactionRate = 4 << 20 // 4 MB/s
-	}
-	if o.private.minFlushRate == 0 {
-		o.private.minFlushRate = 1 << 20 // 1 MB/s
-	}
 	if o.MaxConcurrentCompactions <= 0 {
 		o.MaxConcurrentCompactions = 1
 	}
@@ -973,9 +952,7 @@ func (o *Options) String() string {
 	fmt.Fprintf(&buf, "  max_open_files=%d\n", o.MaxOpenFiles)
 	fmt.Fprintf(&buf, "  mem_table_size=%d\n", o.MemTableSize)
 	fmt.Fprintf(&buf, "  mem_table_stop_writes_threshold=%d\n", o.MemTableStopWritesThreshold)
-	fmt.Fprintf(&buf, "  min_compaction_rate=%d\n", o.private.minCompactionRate)
 	fmt.Fprintf(&buf, "  min_deletion_rate=%d\n", o.Experimental.MinDeletionRate)
-	fmt.Fprintf(&buf, "  min_flush_rate=%d\n", o.private.minFlushRate)
 	fmt.Fprintf(&buf, "  merger=%s\n", o.Merger.Name)
 	fmt.Fprintf(&buf, "  read_compaction_rate=%d\n", o.Experimental.ReadCompactionRate)
 	fmt.Fprintf(&buf, "  read_sampling_multiplier=%d\n", o.Experimental.ReadSamplingMultiplier)
@@ -1175,11 +1152,13 @@ func (o *Options) Parse(s string, hooks *ParseHooks) error {
 			case "mem_table_stop_writes_threshold":
 				o.MemTableStopWritesThreshold, err = strconv.Atoi(value)
 			case "min_compaction_rate":
-				o.private.minCompactionRate, err = strconv.Atoi(value)
+				// Do nothing; option existed in older versions of pebble, and
+				// may be meaningful again eventually.
 			case "min_deletion_rate":
 				o.Experimental.MinDeletionRate, err = strconv.Atoi(value)
 			case "min_flush_rate":
-				o.private.minFlushRate, err = strconv.Atoi(value)
+				// Do nothing; option existed in older versions of pebble, and
+				// may be meaningful again eventually.
 			case "strict_wal_tail":
 				o.private.strictWALTail, err = strconv.ParseBool(value)
 			case "merger":
diff --git a/pacer.go b/pacer.go
index f35e368e..8d3d17f8 100644
--- a/pacer.go
+++ b/pacer.go
@@ -26,221 +26,6 @@ type pacer interface {
 	maybeThrottle(bytesIterated uint64) error
 }
 
-// compactionInternalPacer contains fields and methods common to compactionPacer
-// and flushPacer.
-type compactionInternalPacer struct {
-	limiter limiter
-
-	iterCount             uint64
-	prevBytesIterated     uint64
-	refreshBytesThreshold uint64
-	slowdownThreshold     uint64
-}
-
-// limit applies rate limiting if the current byte level is below the configured
-// threshold.
-func (p *compactionInternalPacer) limit(amount, currentLevel uint64) error {
-	if currentLevel <= p.slowdownThreshold {
-		burst := p.limiter.Burst()
-		for amount > uint64(burst) {
-			d := p.limiter.DelayN(time.Now(), burst)
-			if d == rate.InfDuration {
-				return errors.Errorf("pacing failed")
-			}
-			time.Sleep(d)
-			amount -= uint64(burst)
-		}
-		d := p.limiter.DelayN(time.Now(), int(amount))
-		if d == rate.InfDuration {
-			return errors.Errorf("pacing failed")
-		}
-		time.Sleep(d)
-	} else {
-		burst := p.limiter.Burst()
-		for amount > uint64(burst) {
-			p.limiter.AllowN(time.Now(), burst)
-			amount -= uint64(burst)
-		}
-		p.limiter.AllowN(time.Now(), int(amount))
-	}
-	return nil
-}
-
-// compactionPacerInfo contains information necessary for compaction pacing.
-type compactionPacerInfo struct {
-	// slowdownThreshold is the low watermark for compaction debt. If compaction debt is
-	// below this threshold, we slow down compactions. If compaction debt is above this
-	// threshold, we let compactions continue as fast as possible. We want to keep
-	// compaction speed as slow as possible to match the speed of flushes. This threshold
-	// is set so that a single flush cannot contribute enough compaction debt to overshoot
-	// the threshold.
-	slowdownThreshold   uint64
-	totalCompactionDebt uint64
-	// totalDirtyBytes is the number of dirty bytes in memtables. The compaction
-	// pacer can monitor changes to this value to determine if user writes have
-	// stopped.
-	totalDirtyBytes uint64
-}
-
-// compactionPacerEnv defines the environment in which the compaction rate limiter
-// is applied.
-type compactionPacerEnv struct {
-	limiter      limiter
-	memTableSize uint64
-
-	getInfo func() compactionPacerInfo
-}
-
-// compactionPacer rate limits compactions depending on compaction debt. The rate
-// limiter is applied at a rate that keeps compaction debt at a steady level. If
-// compaction debt increases at a rate that is faster than the system can handle,
-// no rate limit is applied.
-type compactionPacer struct {
-	compactionInternalPacer
-	env                 compactionPacerEnv
-	totalCompactionDebt uint64
-	totalDirtyBytes     uint64
-}
-
-func newCompactionPacer(env compactionPacerEnv) *compactionPacer {
-	return &compactionPacer{
-		env: env,
-		compactionInternalPacer: compactionInternalPacer{
-			limiter: env.limiter,
-		},
-	}
-}
-
-// maybeThrottle slows down compactions to match memtable flush rate. The DB
-// provides a compaction debt estimate and a slowdown threshold. We subtract the
-// compaction debt estimate by the bytes iterated in the current compaction. If
-// the new compaction debt estimate is below the threshold, the rate limiter is
-// applied. If the new compaction debt is above the threshold, the rate limiter
-// is not applied.
-func (p *compactionPacer) maybeThrottle(bytesIterated uint64) error {
-	if bytesIterated == 0 {
-		return nil
-	}
-
-	// Recalculate total compaction debt and the slowdown threshold only once
-	// every 1000 iterations or when the refresh threshold is hit since it
-	// requires grabbing DB.mu which is expensive.
-	if p.iterCount == 0 || bytesIterated > p.refreshBytesThreshold {
-		pacerInfo := p.env.getInfo()
-		p.slowdownThreshold = pacerInfo.slowdownThreshold
-		p.totalCompactionDebt = pacerInfo.totalCompactionDebt
-		p.refreshBytesThreshold = bytesIterated + (p.env.memTableSize * 5 / 100)
-		p.iterCount = 1000
-		if p.totalDirtyBytes == pacerInfo.totalDirtyBytes {
-			// The total dirty bytes in the memtables have not changed since the
-			// previous call: user writes have completely stopped. Allow the
-			// compaction to proceed as fast as possible until the next
-			// recalculation. We adjust the recalculation threshold so that we can be
-			// nimble in the face of new user writes.
-			p.totalCompactionDebt += p.slowdownThreshold
-			p.iterCount = 100
-		}
-		p.totalDirtyBytes = pacerInfo.totalDirtyBytes
-	}
-	p.iterCount--
-
-	var curCompactionDebt uint64
-	if p.totalCompactionDebt > bytesIterated {
-		curCompactionDebt = p.totalCompactionDebt - bytesIterated
-	}
-
-	compactAmount := bytesIterated - p.prevBytesIterated
-	p.prevBytesIterated = bytesIterated
-
-	// We slow down compactions when the compaction debt falls below the slowdown
-	// threshold, which is set dynamically based on the number of non-empty levels.
-	// This will only occur if compactions can keep up with the pace of flushes. If
-	// bytes are flushed faster than how fast compactions can occur, compactions
-	// proceed at maximum (unthrottled) speed.
-	return p.limit(compactAmount, curCompactionDebt)
-}
-
-// flushPacerInfo contains information necessary for compaction pacing.
-type flushPacerInfo struct {
-	inuseBytes uint64
-}
-
-// flushPacerEnv defines the environment in which the compaction rate limiter is
-// applied.
-type flushPacerEnv struct {
-	limiter      limiter
-	memTableSize uint64
-
-	getInfo func() flushPacerInfo
-}
-
-// flushPacer rate limits memtable flushing to match the speed of incoming user
-// writes. If user writes come in faster than the memtable can be flushed, no
-// rate limit is applied.
-type flushPacer struct {
-	compactionInternalPacer
-	env                flushPacerEnv
-	inuseBytes         uint64
-	adjustedInuseBytes uint64
-}
-
-func newFlushPacer(env flushPacerEnv) *flushPacer {
-	return &flushPacer{
-		env: env,
-		compactionInternalPacer: compactionInternalPacer{
-			limiter:           env.limiter,
-			slowdownThreshold: env.memTableSize * 105 / 100,
-		},
-	}
-}
-
-// maybeThrottle slows down memtable flushing to match user write rate. The DB
-// provides the total number of bytes in all the memtables. We subtract this total
-// by the number of bytes flushed in the current flush to get a "dirty byte" count.
-// If the dirty byte count is below the watermark (105% memtable size), the rate
-// limiter is applied. If the dirty byte count is above the watermark, the rate
-// limiter is not applied.
-func (p *flushPacer) maybeThrottle(bytesIterated uint64) error {
-	if bytesIterated == 0 {
-		return nil
-	}
-
-	// Recalculate inuse memtable bytes only once every 1000 iterations or when
-	// the refresh threshold is hit since getting the inuse memtable byte count
-	// requires grabbing DB.mu which is expensive.
-	if p.iterCount == 0 || bytesIterated > p.refreshBytesThreshold {
-		pacerInfo := p.env.getInfo()
-		p.iterCount = 1000
-		p.refreshBytesThreshold = bytesIterated + (p.env.memTableSize * 5 / 100)
-		p.adjustedInuseBytes = pacerInfo.inuseBytes
-		if p.inuseBytes == pacerInfo.inuseBytes {
-			// The inuse bytes in the memtables have not changed since the previous
-			// call: user writes have completely stopped. Allow the flush to proceed
-			// as fast as possible until the next recalculation. We adjust the
-			// recalculation threshold so that we can be nimble in the face of new
-			// user writes.
-			p.adjustedInuseBytes += p.slowdownThreshold
-			p.iterCount = 100
-		}
-		p.inuseBytes = pacerInfo.inuseBytes
-	}
-	p.iterCount--
-
-	// dirtyBytes is the inuse number of bytes in the memtables minus the number of
-	// bytes flushed. It represents unflushed bytes in all the memtables, even the
-	// ones which aren't being flushed such as the mutable memtable.
-	dirtyBytes := p.adjustedInuseBytes - bytesIterated
-	flushAmount := bytesIterated - p.prevBytesIterated
-	p.prevBytesIterated = bytesIterated
-
-	// We slow down memtable flushing when the dirty bytes indicator falls
-	// below the low watermark, which is 105% memtable size. This will only
-	// occur if memtable flushing can keep up with the pace of incoming
-	// writes. If writes come in faster than how fast the memtable can flush,
-	// flushing proceeds at maximum (unthrottled) speed.
-	return p.limit(flushAmount, dirtyBytes)
-}
-
 // deletionPacerInfo contains any info from the db necessary to make deletion
 // pacing decisions.
 type deletionPacerInfo struct {
diff --git a/sstable/block_property.go b/sstable/block_property.go
index 47c04a58..f8a52eee 100644
--- a/sstable/block_property.go
+++ b/sstable/block_property.go
@@ -46,6 +46,34 @@ import (
 // identical behavior to table properties by returning the nil slice from
 // FinishDataBlock and FinishIndexBlock, and interpret them as the universal
 // set in BlockPropertyFilter, and return a non-universal set in FinishTable.
+//
+// Block property filtering is nondeterministic because the separation of keys
+// into blocks is nondeterministic. Clients use block-property filters to
+// implement efficient application of a filter F that applies to key-value pairs
+// (abbreviated as kv-filter). Consider correctness defined as surfacing exactly
+// the same key-value pairs that would be surfaced if one applied the filter F
+// above normal iteration. With this correctness definition, block property
+// filtering may introduce two kinds of errors:
+//
+//   a) Block property filtering that uses a kv-filter may produce additional
+//      key-value pairs that don't satisfy the filter because of the separation
+//      of keys into blocks. Clients may remove these extra key-value pairs by
+//      re-applying the kv filter while reading results back from Pebble.
+//
+//   b) Block property filtering may surface deleted key-value pairs if the
+//      the kv filter is not a strict function of the key's user key. A block
+//      containing k.DEL may be filtered, while a block containing the deleted
+//      key k.SET may not be filtered, if the kv filter applies to one but not
+//      the other.
+//
+//      This error may be avoided trivially by using a kv filter that is a pure
+//      function of the the user key. A filter that examines values or key kinds
+//      requires care to ensure F(k.SET, <value>) = F(k.DEL) = F(k.SINGLEDEL).
+//
+// The combination of range deletions and filtering by table-level properties
+// add another opportunity for deleted point keys to be surfaced. The pebble
+// Iterator stack takes care to correctly apply filtered tables' range deletions
+// to lower tables, preventing this form of nondeterministic error.
 
 // BlockPropertyCollector is used when writing a sstable.
 // - All calls to Add are included in the next FinishDataBlock, after which
diff --git a/sstable/reader.go b/sstable/reader.go
index bbee49a4..205172df 100644
--- a/sstable/reader.go
+++ b/sstable/reader.go
@@ -90,6 +90,17 @@ type block []byte
 type Iterator interface {
 	base.InternalIterator
 
+	// MaybeFilteredKeys may be called when an iterator is exhausted to indicate
+	// whether or not the last positioning method may have skipped any keys due
+	// to block-property filters. This is used by the Pebble levelIter to
+	// control when an iterator steps to the next sstable.
+	//
+	// MaybeFilteredKeys may always return false positives, that is it may
+	// return true when no keys were filtered. It should only be called when the
+	// iterator is exhausted. It must never return false negatives when the
+	// iterator is exhausted.
+	MaybeFilteredKeys() bool
+
 	SetCloseHook(fn func(i Iterator) error)
 }
 
@@ -201,6 +212,11 @@ type singleLevelIterator struct {
 	// neither. It is used for invariant checking.
 	exhaustedBounds int8
 
+	// maybeFilteredKeysSingleLevel indicates whether the last iterator
+	// positioning operation may have skipped any data blocks due to
+	// block-property filters when positioning the index.
+	maybeFilteredKeysSingleLevel bool
+
 	// useFilter specifies whether the filter block in this sstable, if present,
 	// should be used for prefix seeks or not. In some cases it is beneficial
 	// to skip a filter block even if it exists (eg. if probability of a match
@@ -374,6 +390,7 @@ func (i *singleLevelIterator) loadBlock() loadBlockResult {
 			return loadBlockFailed
 		}
 		if !intersects {
+			i.maybeFilteredKeysSingleLevel = true
 			return loadBlockIrrelevant
 		}
 	}
@@ -504,6 +521,7 @@ func (i *singleLevelIterator) SeekGE(key []byte, trySeekUsingNext bool) (*Intern
 		// Already exhausted, so return nil.
 		return nil, nil
 	}
+
 	i.exhaustedBounds = 0
 	i.err = nil // clear cached iteration error
 	boundsCmp := i.boundsCmp
@@ -517,6 +535,20 @@ func (i *singleLevelIterator) SeekGE(key []byte, trySeekUsingNext bool) (*Intern
 func (i *singleLevelIterator) seekGEHelper(
 	key []byte, boundsCmp int, trySeekUsingNext bool,
 ) (*InternalKey, []byte) {
+	// Invariant: trySeekUsingNext => !i.data.isDataInvalidated() && i.exhaustedBounds != +1
+
+	// SeekGE performs various step-instead-of-seeking optimizations: eg enabled
+	// by trySeekUsingNext, or by monotonically increasing bounds (i.boundsCmp).
+	// Care must be taken to ensure that when performing these optimizations and
+	// the iterator becomes exhausted, i.maybeFilteredKeys is set appropriately.
+	// Consider a previous SeekGE that filtered keys from k until the current
+	// iterator position.
+	//
+	// If the previous SeekGE exhausted the iterator, it's possible keys greater
+	// than or equal to the current search key were filtered. We must not reuse
+	// the current iterator position without remembering the previous value of
+	// maybeFilteredKeys.
+
 	var dontSeekWithinBlock bool
 	if !i.data.isDataInvalidated() && !i.index.isDataInvalidated() && i.data.valid() && i.index.valid() &&
 		boundsCmp > 0 && i.cmp(key, i.index.Key().UserKey) <= 0 {
@@ -568,12 +600,19 @@ func (i *singleLevelIterator) seekGEHelper(
 				return currKey, value
 			}
 		}
+
 		// Slow-path.
+
+		// Since we're re-seeking the iterator, the previous value of
+		// maybeFilteredKeysSingleLevel is irrelevant. If we filter out blocks
+		// during seeking, loadBlock will set it to true.
+		i.maybeFilteredKeysSingleLevel = false
+
 		var ikey *InternalKey
 		if ikey, _ = i.index.SeekGE(key, false /* trySeekUsingNext */); ikey == nil {
-			// The target key is greater than any key in the sstable. Invalidate the
-			// block iterator so that a subsequent call to Prev() will return the last
-			// key in the table.
+			// The target key is greater than any key in the index block.
+			// Invalidate the block iterator so that a subsequent call to Prev()
+			// will return the last key in the table.
 			i.data.invalidate()
 			return nil, nil
 		}
@@ -675,6 +714,19 @@ func (i *singleLevelIterator) SeekLT(key []byte) (*InternalKey, []byte) {
 	boundsCmp := i.boundsCmp
 	// Seek optimization only applies until iterator is first positioned after SetBounds.
 	i.boundsCmp = 0
+
+	// Seeking operations perform various step-instead-of-seeking optimizations:
+	// eg by considering monotonically increasing bounds (i.boundsCmp). Care
+	// must be taken to ensure that when performing these optimizations and the
+	// iterator becomes exhausted i.maybeFilteredKeysSingleLevel is set
+	// appropriately.  Consider a previous SeekLT that filtered keys from k
+	// until the current iterator position.
+	//
+	// If the previous SeekLT did exhausted the iterator, it's possible keys
+	// less than the current search key were filtered. We must not reuse the
+	// current iterator position without remembering the previous value of
+	// maybeFilteredKeysSingleLevel.
+
 	i.positionedUsingLatestBounds = true
 
 	var dontSeekWithinBlock bool
@@ -699,6 +751,8 @@ func (i *singleLevelIterator) SeekLT(key []byte) (*InternalKey, []byte) {
 			dontSeekWithinBlock = true
 		}
 	} else {
+		// Slow-path.
+		i.maybeFilteredKeysSingleLevel = false
 		var ikey *InternalKey
 		if ikey, _ = i.index.SeekGE(key, false /* trySeekUsingNext */); ikey == nil {
 			ikey, _ = i.index.Last()
@@ -757,6 +811,7 @@ func (i *singleLevelIterator) First() (*InternalKey, []byte) {
 		panic("singleLevelIterator.First() used despite lower bound")
 	}
 	i.positionedUsingLatestBounds = true
+	i.maybeFilteredKeysSingleLevel = false
 	return i.firstInternal()
 }
 
@@ -814,6 +869,7 @@ func (i *singleLevelIterator) Last() (*InternalKey, []byte) {
 		panic("singleLevelIterator.Last() used despite upper bound")
 	}
 	i.positionedUsingLatestBounds = true
+	i.maybeFilteredKeysSingleLevel = false
 	return i.lastInternal()
 }
 
@@ -869,6 +925,7 @@ func (i *singleLevelIterator) Next() (*InternalKey, []byte) {
 		panic("Next called even though exhausted upper bound")
 	}
 	i.exhaustedBounds = 0
+	i.maybeFilteredKeysSingleLevel = false
 	// Seek optimization only applies until iterator is first positioned after SetBounds.
 	i.boundsCmp = 0
 
@@ -892,6 +949,7 @@ func (i *singleLevelIterator) Prev() (*InternalKey, []byte) {
 		panic("Prev called even though exhausted lower bound")
 	}
 	i.exhaustedBounds = 0
+	i.maybeFilteredKeysSingleLevel = false
 	// Seek optimization only applies until iterator is first positioned after SetBounds.
 	i.boundsCmp = 0
 
@@ -1000,6 +1058,13 @@ func (i *singleLevelIterator) Error() error {
 	return i.err
 }
 
+// MaybeFilteredKeys may be called when an iterator is exhausted to indicate
+// whether or not the last positioning method may have skipped any keys due to
+// block-property filters.
+func (i *singleLevelIterator) MaybeFilteredKeys() bool {
+	return i.maybeFilteredKeysSingleLevel
+}
+
 // SetCloseHook sets a function that will be called when the iterator is
 // closed.
 func (i *singleLevelIterator) SetCloseHook(fn func(i Iterator) error) {
@@ -1174,7 +1239,11 @@ func (i *compactionIterator) skipForward(key *InternalKey, val []byte) (*Interna
 
 type twoLevelIterator struct {
 	singleLevelIterator
-	topLevelIndex blockIter
+	// maybeFilteredKeysSingleLevel indicates whether the last iterator
+	// positioning operation may have skipped any index blocks due to
+	// block-property filters when positioning the top-level-index.
+	maybeFilteredKeysTwoLevel bool
+	topLevelIndex             blockIter
 }
 
 // twoLevelIterator implements the base.InternalIterator interface.
@@ -1205,6 +1274,7 @@ func (i *twoLevelIterator) loadIndex() loadBlockResult {
 			return loadBlockFailed
 		}
 		if !intersects {
+			i.maybeFilteredKeysTwoLevel = true
 			return loadBlockIrrelevant
 		}
 	}
@@ -1250,6 +1320,21 @@ func (i *twoLevelIterator) String() string {
 	return i.reader.fileNum.String()
 }
 
+// MaybeFilteredKeys may be called when an iterator is exhausted to indicate
+// whether or not the last positioning method may have skipped any keys due to
+// block-property filters.
+func (i *twoLevelIterator) MaybeFilteredKeys() bool {
+	// While reading sstables with two-level indexes, knowledge of whether we've
+	// filtered keys is tracked separately for each index level. The
+	// seek-using-next optimizations have different criteria. We can only reset
+	// maybeFilteredKeys back to false during a seek when NOT using the
+	// fast-path that uses the current iterator position.
+	//
+	// If either level might have filtered keys to arrive at the current
+	// iterator position, return MaybeFilteredKeys=true.
+	return i.maybeFilteredKeysTwoLevel || i.maybeFilteredKeysSingleLevel
+}
+
 // SeekGE implements internalIterator.SeekGE, as documented in the pebble
 // package. Note that SeekGE only checks the upper bound. It is up to the
 // caller to ensure that key is greater than or equal to the lower bound.
@@ -1257,10 +1342,24 @@ func (i *twoLevelIterator) SeekGE(key []byte, trySeekUsingNext bool) (*InternalK
 	i.exhaustedBounds = 0
 	i.err = nil // clear cached iteration error
 
+	// SeekGE performs various step-instead-of-seeking optimizations: eg enabled
+	// by trySeekUsingNext, or by monotonically increasing bounds (i.boundsCmp).
+	// Care must be taken to ensure that when performing these optimizations and
+	// the iterator becomes exhausted, i.maybeFilteredKeys is set appropriately.
+	// Consider a previous SeekGE that filtered keys from k until the current
+	// iterator position.
+	//
+	// If the previous SeekGE exhausted the iterator while seeking within the
+	// two-level index, it's possible keys greater than or equal to the current
+	// search key were filtered through skipped index blocks. We must not reuse
+	// the position of the two-level index iterator without remembering the
+	// previous value of maybeFilteredKeys.
+
 	var dontSeekWithinSingleLevelIter bool
-	if i.topLevelIndex.isDataInvalidated() || !i.topLevelIndex.valid() || (i.boundsCmp <= 0 && !trySeekUsingNext) ||
-		i.cmp(key, i.topLevelIndex.Key().UserKey) > 0 {
+	if i.topLevelIndex.isDataInvalidated() || !i.topLevelIndex.valid() ||
+		(i.boundsCmp <= 0 && !trySeekUsingNext) || i.cmp(key, i.topLevelIndex.Key().UserKey) > 0 {
 		// Slow-path: need to position the topLevelIndex.
+		i.maybeFilteredKeysTwoLevel = false
 		trySeekUsingNext = false
 		var ikey *InternalKey
 		if ikey, _ = i.topLevelIndex.SeekGE(key, false /* trySeekUsingNext */); ikey == nil {
@@ -1352,9 +1451,23 @@ func (i *twoLevelIterator) SeekPrefixGE(
 	// Bloom filter matches.
 	i.exhaustedBounds = 0
 
+	// SeekPrefixGE performs various step-instead-of-seeking optimizations: eg
+	// enabled by trySeekUsingNext, or by monotonically increasing bounds
+	// (i.boundsCmp).  Care must be taken to ensure that when performing these
+	// optimizations and the iterator becomes exhausted,
+	// i.maybeFilteredKeysTwoLevel is set appropriately.  Consider a previous
+	// SeekPrefixGE that filtered keys from k until the current iterator
+	// position.
+	//
+	// If the previous SeekPrefixGE exhausted the iterator while seeking within
+	// the two-level index, it's possible keys greater than or equal to the
+	// current search key were filtered through skipped index blocks. We must
+	// not reuse the position of the two-level index iterator without
+	// remembering the previous value of maybeFilteredKeysTwoLevel.
+
 	var dontSeekWithinSingleLevelIter bool
-	if i.topLevelIndex.isDataInvalidated() || !i.topLevelIndex.valid() || i.boundsCmp <= 0 ||
-		i.cmp(key, i.topLevelIndex.Key().UserKey) > 0 {
+	if i.topLevelIndex.isDataInvalidated() || !i.topLevelIndex.valid() ||
+		i.boundsCmp <= 0 || i.cmp(key, i.topLevelIndex.Key().UserKey) > 0 {
 		// Slow-path: need to position the topLevelIndex.
 		//
 		// TODO(sumeer): improve this slow-path to be able to use Next, when
@@ -1364,6 +1477,7 @@ func (i *twoLevelIterator) SeekPrefixGE(
 		// the topLevelIndex can continue using the same second level index
 		// block, and in that case we don't need to invalidate and reload the
 		// singleLevelIterator state.
+		i.maybeFilteredKeysTwoLevel = false
 		trySeekUsingNext = false
 		var ikey *InternalKey
 		if ikey, _ = i.topLevelIndex.SeekGE(key, false /* trySeekUsingNext */); ikey == nil {
@@ -1424,6 +1538,7 @@ func (i *twoLevelIterator) SeekLT(key []byte) (*InternalKey, []byte) {
 	// whether the topLevelIndex is positioned after the position that would
 	// be returned by doing i.topLevelIndex.SeekGE(). To know this we would
 	// need to know the index key preceding the current one.
+	i.maybeFilteredKeysTwoLevel = false
 	if ikey, _ = i.topLevelIndex.SeekGE(key, false /* trySeekUsingNext */); ikey == nil {
 		if ikey, _ = i.topLevelIndex.Last(); ikey == nil {
 			i.data.invalidate()
@@ -1482,6 +1597,7 @@ func (i *twoLevelIterator) First() (*InternalKey, []byte) {
 		panic("twoLevelIterator.First() used despite lower bound")
 	}
 	i.exhaustedBounds = 0
+	i.maybeFilteredKeysTwoLevel = false
 	i.err = nil // clear cached iteration error
 	// Seek optimization only applies until iterator is first positioned after SetBounds.
 	i.boundsCmp = 0
@@ -1524,6 +1640,7 @@ func (i *twoLevelIterator) Last() (*InternalKey, []byte) {
 		panic("twoLevelIterator.Last() used despite upper bound")
 	}
 	i.exhaustedBounds = 0
+	i.maybeFilteredKeysTwoLevel = false
 	i.err = nil // clear cached iteration error
 	// Seek optimization only applies until iterator is first positioned after SetBounds.
 	i.boundsCmp = 0
@@ -1549,7 +1666,7 @@ func (i *twoLevelIterator) Last() (*InternalKey, []byte) {
 		// the previous entry starts with keys <= ikey.UserKey since even
 		// though this is the current block's separator, the same user key
 		// can span multiple index blocks.
-		if i.lower != nil && i.cmp(ikey.UserKey, i.upper) < 0 {
+		if i.lower != nil && i.cmp(ikey.UserKey, i.lower) < 0 {
 			i.exhaustedBounds = -1
 		}
 	}
@@ -1564,6 +1681,7 @@ func (i *twoLevelIterator) Last() (*InternalKey, []byte) {
 func (i *twoLevelIterator) Next() (*InternalKey, []byte) {
 	// Seek optimization only applies until iterator is first positioned after SetBounds.
 	i.boundsCmp = 0
+	i.maybeFilteredKeysTwoLevel = false
 	if i.err != nil {
 		return nil, nil
 	}
@@ -1578,6 +1696,7 @@ func (i *twoLevelIterator) Next() (*InternalKey, []byte) {
 func (i *twoLevelIterator) Prev() (*InternalKey, []byte) {
 	// Seek optimization only applies until iterator is first positioned after SetBounds.
 	i.boundsCmp = 0
+	i.maybeFilteredKeysTwoLevel = false
 	if i.err != nil {
 		return nil, nil
 	}
@@ -1654,7 +1773,7 @@ func (i *twoLevelIterator) skipBackward() (*InternalKey, []byte) {
 			// the previous entry starts with keys <= ikey.UserKey since even
 			// though this is the current block's separator, the same user key
 			// can span multiple index blocks.
-			if i.lower != nil && i.cmp(ikey.UserKey, i.upper) < 0 {
+			if i.lower != nil && i.cmp(ikey.UserKey, i.lower) < 0 {
 				i.exhaustedBounds = -1
 				// Next iteration will return.
 			}
diff --git a/table_cache.go b/table_cache.go
index e792152e..c1c0d0fc 100644
--- a/table_cache.go
+++ b/table_cache.go
@@ -28,6 +28,25 @@ import (
 var emptyIter = &errorIter{err: nil}
 var emptyKeyspanIter = &errorKeyspanIter{err: nil}
 
+// filteredAll is a singleton internalIterator implementation used when an
+// sstable does contain point keys, but all the keys are filtered by the active
+// PointKeyFilters set in the iterator's IterOptions.
+//
+// filteredAll implements filteredIter, ensuring the level iterator recognizes
+// when it may need to return file boundaries to keep the rangeDelIter open
+// during mergingIter operation.
+var filteredAll = &filteredAllKeysIter{errorIter: errorIter{err: nil}}
+
+var _ filteredIter = filteredAll
+
+type filteredAllKeysIter struct {
+	errorIter
+}
+
+func (s *filteredAllKeysIter) MaybeFilteredKeys() bool {
+	return true
+}
+
 var tableCacheLabels = pprof.Labels("pebble", "table-cache")
 
 // tableCacheOpts contains the db specific fields
@@ -356,11 +375,29 @@ func (c *tableCacheShard) newIters(
 		c.unrefValue(v)
 		return nil, nil, err
 	}
+
+	// NB: range-del iterator does not maintain a reference to the table, nor
+	// does it need to read from it after creation.
+	rangeDelIter, err := v.reader.NewRawRangeDelIter()
+	if err != nil {
+		c.unrefValue(v)
+		return nil, nil, err
+	}
+
 	if !ok {
 		c.unrefValue(v)
-		// Return the empty iterator. This iterator has no mutable state, so
+		// Return an empty iterator. This iterator has no mutable state, so
 		// using a singleton is fine.
-		return emptyIter, nil, err
+		// NB: We still return the potentially non-empty rangeDelIter. This
+		// ensures the iterator observes the file's range deletions even if the
+		// block property filters exclude all the file's point keys. The range
+		// deletions may still delete keys lower in the LSM in files that DO
+		// match the active filters.
+		//
+		// The point iterator returned must implement the filteredIter
+		// interface, so that the level iterator surfaces file boundaries when
+		// range deletions are present.
+		return filteredAll, rangeDelIter, err
 	}
 
 	var iter sstable.Iterator
@@ -375,6 +412,9 @@ func (c *tableCacheShard) newIters(
 			opts.GetLowerBound(), opts.GetUpperBound(), filterer, useFilter)
 	}
 	if err != nil {
+		if rangeDelIter != nil {
+			_ = rangeDelIter.Close()
+		}
 		c.unrefValue(v)
 		return nil, nil, err
 	}
@@ -389,19 +429,7 @@ func (c *tableCacheShard) newIters(
 		c.mu.iters[iter] = debug.Stack()
 		c.mu.Unlock()
 	}
-
-	// NB: range-del iterator does not maintain a reference to the table, nor
-	// does it need to read from it after creation.
-	rangeDelIter, err := v.reader.NewRawRangeDelIter()
-	if err != nil {
-		_ = iter.Close()
-		return nil, nil, err
-	}
-	if rangeDelIter != nil {
-		return iter, rangeDelIter, nil
-	}
-	// NB: Translate a nil range-del iterator into a nil interface.
-	return iter, nil, nil
+	return iter, rangeDelIter, nil
 }
 
 func (c *tableCacheShard) newRangeKeyIter(
@@ -420,7 +448,12 @@ func (c *tableCacheShard) newRangeKeyIter(
 
 	ok := true
 	var err error
-	if opts != nil {
+	// 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
+	// done here, rather than deferring to the block-property collector in order
+	// to maintain parity with point keys and the treatment of RANGEDELs.
+	if opts != nil && v.reader.Properties.NumRangeKeyDels == 0 {
 		ok, _, err = c.checkAndIntersectFilters(v, nil, opts.RangeKeyFilters)
 	}
 	if err != nil {


Test patch:
diff --git a/compaction_picker_test.go b/compaction_picker_test.go
index 4703e638..d815db85 100644
--- a/compaction_picker_test.go
+++ b/compaction_picker_test.go
@@ -522,13 +522,12 @@ func TestCompactionPickerL0(t *testing.T) {
 			}
 
 			pc = picker.pickAuto(compactionEnv{
-				bytesCompacted:          new(uint64),
 				earliestUnflushedSeqNum: math.MaxUint64,
 				inProgressCompactions:   inProgressCompactions,
 			})
 			var result strings.Builder
 			if pc != nil {
-				c := newCompaction(pc, opts, new(uint64))
+				c := newCompaction(pc, opts)
 				fmt.Fprintf(&result, "L%d -> L%d\n", pc.startLevel.level, pc.outputLevel.level)
 				fmt.Fprintf(&result, "L%d: %s\n", pc.startLevel.level, fileNums(pc.startLevel.files))
 				if !pc.outputLevel.files.Empty() {
@@ -764,13 +763,12 @@ func TestCompactionPickerConcurrency(t *testing.T) {
 			}
 
 			pc := picker.pickAuto(compactionEnv{
-				bytesCompacted:          new(uint64),
 				earliestUnflushedSeqNum: math.MaxUint64,
 				inProgressCompactions:   inProgressCompactions,
 			})
 			var result strings.Builder
 			if pc != nil {
-				c := newCompaction(pc, opts, new(uint64))
+				c := newCompaction(pc, opts)
 				fmt.Fprintf(&result, "L%d -> L%d\n", pc.startLevel.level, pc.outputLevel.level)
 				fmt.Fprintf(&result, "L%d: %s\n", pc.startLevel.level, fileNums(pc.startLevel.files))
 				if !pc.outputLevel.files.Empty() {
@@ -915,7 +913,6 @@ func TestCompactionPickerPickReadTriggered(t *testing.T) {
 
 		case "pick-auto":
 			pc := picker.pickAuto(compactionEnv{
-				bytesCompacted:          new(uint64),
 				earliestUnflushedSeqNum: math.MaxUint64,
 				readCompactionEnv: readCompactionEnv{
 					readCompactions: &rcList,
@@ -1233,7 +1230,6 @@ func TestCompactionOutputFileSize(t *testing.T) {
 
 		case "pick-auto":
 			pc := picker.pickAuto(compactionEnv{
-				bytesCompacted:          new(uint64),
 				earliestUnflushedSeqNum: math.MaxUint64,
 				earliestSnapshotSeqNum:  math.MaxUint64,
 			})
diff --git a/compaction_test.go b/compaction_test.go
index 6ae851c7..5f2fde88 100644
--- a/compaction_test.go
+++ b/compaction_test.go
@@ -518,11 +518,9 @@ func TestPickCompaction(t *testing.T) {
 		tc.picker.opts = opts
 		tc.picker.vers = tc.version
 		vs.picker = &tc.picker
-		env := compactionEnv{bytesCompacted: new(uint64)}
-
-		pc, got := vs.picker.pickAuto(env), ""
+		pc, got := vs.picker.pickAuto(compactionEnv{}), ""
 		if pc != nil {
-			c := newCompaction(pc, opts, env.bytesCompacted)
+			c := newCompaction(pc, opts)
 			got0 := fileNums(c.startLevel.files)
 			got1 := fileNums(c.outputLevel.files)
 			got2 := fileNums(c.grandparents)
@@ -938,14 +936,11 @@ func TestCompaction(t *testing.T) {
 		DebugCheck:            DebugCheckLevels,
 		L0CompactionThreshold: 8,
 	}
-	opts.private.enablePacing = true
 	opts.testingRandomized()
 	d, err := Open("", opts)
 	if err != nil {
 		t.Fatalf("Open: %v", err)
 	}
-	mockLimiter := mockCountLimiter{burst: int(math.MaxInt32)}
-	d.compactionLimiter = &mockLimiter
 
 	get1 := func(iter internalIterator) (ret string) {
 		b := &bytes.Buffer{}
@@ -1047,17 +1042,9 @@ func TestCompaction(t *testing.T) {
 			t.Errorf("%q: %v", tc.key, err)
 		}
 	}
-
 	if err := d.Close(); err != nil {
 		t.Fatalf("db Close: %v", err)
 	}
-
-	if mockLimiter.allowCount != 0 {
-		t.Errorf("limiter allow: got %d, want %d", mockLimiter.allowCount, 0)
-	}
-	if mockLimiter.waitCount == 0 {
-		t.Errorf("limiter wait: got %d, want >%d", mockLimiter.waitCount, 0)
-	}
 }
 
 func TestValidateVersionEdit(t *testing.T) {
@@ -1760,7 +1747,7 @@ func TestCompactionOutputLevel(t *testing.T) {
 				d.ScanArgs(t, "start", &start)
 				d.ScanArgs(t, "base", &base)
 				pc := newPickedCompaction(opts, version, start, defaultOutputLevel(start, base), base)
-				c := newCompaction(pc, opts, new(uint64))
+				c := newCompaction(pc, opts)
 				return fmt.Sprintf("output=%d\nmax-output-file-size=%d\n",
 					c.outputLevel.level, c.maxOutputFileSize)
 
diff --git a/data_test.go b/data_test.go
index 53049384..36ff73ae 100644
--- a/data_test.go
+++ b/data_test.go
@@ -717,7 +717,7 @@ func runDBDefineCmd(td *datadriven.TestData, opts *Options) (*DB, error) {
 			flushed:   make(chan struct{}),
 		}}
 		c := newFlush(d.opts, d.mu.versions.currentVersion(),
-			d.mu.versions.picker.getBaseLevel(), toFlush, &d.atomic.bytesFlushed)
+			d.mu.versions.picker.getBaseLevel(), toFlush)
 		c.disableSpanElision = true
 		// NB: define allows the test to exactly specify which keys go
 		// into which sstables. If the test has a small target file
@@ -727,7 +727,7 @@ func runDBDefineCmd(td *datadriven.TestData, opts *Options) (*DB, error) {
 		// to the user-defined boundaries.
 		c.maxOutputFileSize = math.MaxUint64
 
-		newVE, _, err := d.runCompaction(0, c, nilPacer)
+		newVE, _, err := d.runCompaction(0, c)
 		if err != nil {
 			return err
 		}
diff --git a/internal/metamorphic/test.go b/internal/metamorphic/test.go
index 76193f3f..672599da 100644
--- a/internal/metamorphic/test.go
+++ b/internal/metamorphic/test.go
@@ -215,11 +215,16 @@ func (t *test) setBatch(id objID, b *pebble.Batch) {
 	t.batches[id.slot()] = b
 }
 
-func (t *test) setIter(id objID, i *pebble.Iterator) {
+func (t *test) setIter(id objID, i *pebble.Iterator, filterMin, filterMax uint64) {
 	if id.tag() != iterTag {
 		panic(fmt.Sprintf("invalid iter ID: %s", id))
 	}
-	t.iters[id.slot()] = &retryableIter{iter: i, lastKey: nil}
+	t.iters[id.slot()] = &retryableIter{
+		iter:      i,
+		lastKey:   nil,
+		filterMin: filterMin,
+		filterMax: filterMax,
+	}
 }
 
 func (t *test) setSnapshot(id objID, s *pebble.Snapshot) {
diff --git a/merging_iter_test.go b/merging_iter_test.go
index 7f783e6a..7ec636df 100644
--- a/merging_iter_test.go
+++ b/merging_iter_test.go
@@ -251,9 +251,7 @@ func TestMergingIterCornerCases(t *testing.T) {
 				i := len(levelIters)
 				levelIters = append(levelIters, mergingIterLevel{iter: li})
 				li.initRangeDel(&levelIters[i].rangeDelIter)
-				li.initSmallestLargestUserKey(
-					&levelIters[i].smallestUserKey, &levelIters[i].largestUserKey, &levelIters[i].isLargestUserKeyRangeDelSentinel)
-				li.initIsSyntheticIterBoundsKey(&levelIters[i].isSyntheticIterBoundsKey)
+				li.initBoundaryContext(&levelIters[i].levelIterBoundaryContext)
 			}
 			miter := &mergingIter{}
 			miter.init(nil /* opts */, cmp, func(a []byte) int { return len(a) }, levelIters...)
@@ -585,10 +583,7 @@ func buildMergingIter(readers [][]*sstable.Reader, levelSlices []manifest.LevelS
 			func(a []byte) int { return len(a) }, newIters, levelSlices[i].Iter(),
 			manifest.Level(level), nil)
 		l.initRangeDel(&mils[level].rangeDelIter)
-		l.initSmallestLargestUserKey(
-			&mils[level].smallestUserKey, &mils[level].largestUserKey,
-			&mils[level].isLargestUserKeyRangeDelSentinel)
-		l.initIsSyntheticIterBoundsKey(&mils[level].isSyntheticIterBoundsKey)
+		l.initBoundaryContext(&mils[level].levelIterBoundaryContext)
 		mils[level].iter = l
 	}
 	m := &mergingIter{}
diff --git a/options_test.go b/options_test.go
index 94423a34..d907fa59 100644
--- a/options_test.go
+++ b/options_test.go
@@ -88,9 +88,7 @@ func TestOptionsString(t *testing.T) {
   max_open_files=1000
   mem_table_size=4194304
   mem_table_stop_writes_threshold=2
-  min_compaction_rate=4194304
   min_deletion_rate=0
-  min_flush_rate=1048576
   merger=pebble.concatenate
   read_compaction_rate=16000
   read_sampling_multiplier=16
diff --git a/pacer_test.go b/pacer_test.go
index 09209cdb..18c5cdaa 100644
--- a/pacer_test.go
+++ b/pacer_test.go
@@ -15,26 +15,6 @@ import (
 	"github.com/cockroachdb/pebble/internal/datadriven"
 )
 
-type mockCountLimiter struct {
-	waitCount  int
-	allowCount int
-	burst      int
-}
-
-func (m *mockCountLimiter) DelayN(now time.Time, n int) time.Duration {
-	m.waitCount += n
-	return 0
-}
-
-func (m *mockCountLimiter) AllowN(now time.Time, n int) bool {
-	m.allowCount += n
-	return true
-}
-
-func (m *mockCountLimiter) Burst() int {
-	return m.burst
-}
-
 type mockPrintLimiter struct {
 	buf   bytes.Buffer
 	burst int
@@ -64,9 +44,7 @@ func TestCompactionPacerMaybeThrottle(t *testing.T) {
 				}
 
 				burst := uint64(1)
-				dirtyBytes := uint64(1)
 				var bytesIterated uint64
-				var currentTotal uint64
 				var slowdownThreshold uint64
 				var freeBytes, liveBytes, obsoleteBytes uint64
 				if len(d.Input) > 0 {
@@ -86,10 +64,6 @@ func TestCompactionPacerMaybeThrottle(t *testing.T) {
 							burst = varValue
 						case "bytesIterated":
 							bytesIterated = varValue
-						case "currentTotal":
-							currentTotal = varValue
-						case "dirtyBytes":
-							dirtyBytes = varValue
 						case "slowdownThreshold":
 							slowdownThreshold = varValue
 						case "freeBytes":
@@ -106,45 +80,6 @@ func TestCompactionPacerMaybeThrottle(t *testing.T) {
 
 				mockLimiter := mockPrintLimiter{burst: int(burst)}
 				switch d.CmdArgs[0].Key {
-				case "compaction":
-					getInfo := func() compactionPacerInfo {
-						return compactionPacerInfo{
-							slowdownThreshold:   slowdownThreshold,
-							totalCompactionDebt: currentTotal,
-							totalDirtyBytes:     dirtyBytes,
-						}
-					}
-					compactionPacer := newCompactionPacer(compactionPacerEnv{
-						limiter:      &mockLimiter,
-						memTableSize: 100,
-						getInfo:      getInfo,
-					})
-
-					err := compactionPacer.maybeThrottle(bytesIterated)
-					if err != nil {
-						return err.Error()
-					}
-
-					return mockLimiter.buf.String()
-				case "flush":
-					getInfo := func() flushPacerInfo {
-						return flushPacerInfo{
-							inuseBytes: currentTotal,
-						}
-					}
-					flushPacer := newFlushPacer(flushPacerEnv{
-						limiter:      &mockLimiter,
-						memTableSize: 100,
-						getInfo:      getInfo,
-					})
-					flushPacer.slowdownThreshold = slowdownThreshold
-
-					err := flushPacer.maybeThrottle(bytesIterated)
-					if err != nil {
-						return err.Error()
-					}
-
-					return mockLimiter.buf.String()
 				case "deletion":
 					getInfo := func() deletionPacerInfo {
 						return deletionPacerInfo{
diff --git a/sstable/block_property_test.go b/sstable/block_property_test.go
index a1972d9d..ce8c3115 100644
--- a/sstable/block_property_test.go
+++ b/sstable/block_property_test.go
@@ -7,6 +7,7 @@ package sstable
 import (
 	"bytes"
 	"fmt"
+	"io"
 	"math"
 	"math/rand"
 	"sort"
@@ -1171,7 +1172,10 @@ func TestBlockProperties(t *testing.T) {
 			if err != nil {
 				return err.Error()
 			}
-			return runIterCmd(td, iter)
+			return runIterCmd(td, iter, runIterCmdEveryOp(func(w io.Writer) {
+				// After every op, point the value of MaybeFilteredKeys.
+				fmt.Fprintf(w, " MaybeFilteredKeys()=%t", iter.MaybeFilteredKeys())
+			}))
 
 		default:
 			return fmt.Sprintf("unknown command: %s", td.Cmd)
diff --git a/sstable/data_test.go b/sstable/data_test.go
index 6ca69479..5fdc9a8f 100644
--- a/sstable/data_test.go
+++ b/sstable/data_test.go
@@ -7,6 +7,7 @@ package sstable
 import (
 	"bytes"
 	"fmt"
+	"io"
 	"strconv"
 	"strings"
 
@@ -243,7 +244,22 @@ func scanGlobalSeqNum(td *datadriven.TestData) (uint64, error) {
 	return 0, nil
 }
 
-func runIterCmd(td *datadriven.TestData, origIter Iterator) string {
+type runIterCmdOption func(*runIterCmdOptions)
+
+type runIterCmdOptions struct {
+	everyOp func(io.Writer)
+}
+
+func runIterCmdEveryOp(everyOp func(io.Writer)) runIterCmdOption {
+	return func(opts *runIterCmdOptions) { opts.everyOp = everyOp }
+}
+
+func runIterCmd(td *datadriven.TestData, origIter Iterator, opt ...runIterCmdOption) string {
+	var opts runIterCmdOptions
+	for _, o := range opt {
+		o(&opts)
+	}
+
 	iter := newIterAdapter(origIter)
 	defer iter.Close()
 
@@ -339,6 +355,9 @@ func runIterCmd(td *datadriven.TestData, origIter Iterator) string {
 		} else {
 			fmt.Fprintf(&b, ".")
 		}
+		if opts.everyOp != nil {
+			opts.everyOp(&b)
+		}
 		b.WriteString("\n")
 	}
 	return b.String()
diff --git a/sstable/testdata/block_properties b/sstable/testdata/block_properties
index 93f7628e..8cf2db12 100644
--- a/sstable/testdata/block_properties
+++ b/sstable/testdata/block_properties
@@ -387,7 +387,285 @@ g#72057594037927935,17:
 # Regression test for a bug in boundary checking when skipping over irrelevant
 # index blocks in a two-level indexed sstable.
 
+iter lower=a point-key-filter=(suffix-point-keys-only,1,2)
+seek-lt h
+last
+----
+<a@1:1> MaybeFilteredKeys()=true
+<a@1:1> MaybeFilteredKeys()=true
+
+# Same as above, but each index block holds 2 keys. This exercises a variant of
+# the above bug. Specifically, the bounds check performed /within/ skipBackward,
+# instead of within SeekLT and Last.
+
+build collectors=(suffix-point-keys-only) index-block-size=48 block-size=1
+a@1.SET.1:foo
+b@10.SET.2:bar
+c@15.SET.3:baz
+d@25.SET.4:bax
+e@3.SET.5:box
+f@5.SET.3:mop
+----
+point:    [a@1#1,1,f@5#3,1]
+rangedel: [#0,0,#0,0]
+rangekey: [#0,0,#0,0]
+seqnums:  [1,5]
+
+block-props
+----
+c#72057594037927935,17:
+  0: [1, 11)
+  b#72057594037927935,17:
+    0: [1, 2)
+  c#72057594037927935,17:
+    0: [10, 11)
+e#72057594037927935,17:
+  0: [15, 26)
+  d#72057594037927935,17:
+    0: [15, 16)
+  e#72057594037927935,17:
+    0: [25, 26)
+g#72057594037927935,17:
+  0: [3, 6)
+  f#72057594037927935,17:
+    0: [3, 4)
+  g#72057594037927935,17:
+    0: [5, 6)
+
+# Regression test for a bug in boundary checking when skipping over irrelevant
+# index blocks in a two-level indexed sstable.
+
 iter lower=a upper=z point-key-filter=(suffix-point-keys-only,1,2)
 seek-lt h
 ----
-<a@1:1>
+<a@1:1> MaybeFilteredKeys()=true
+
+# Test MaybeFilteredKeys().
+
+# Use timestamp range [1,9), which matches a@1, e@3 and f@5 and filters
+# a continuous section of three keys b@10, c@15 and d@25.
+
+iter point-key-filter=(suffix-point-keys-only,1,9)
+first
+next
+next
+next
+seek-ge b
+seek-ge e@3
+----
+<a@1:1> MaybeFilteredKeys()=false
+<e@3:5> MaybeFilteredKeys()=true
+<f@5:3> MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=false
+<e@3:5> MaybeFilteredKeys()=true
+<e@3:5> MaybeFilteredKeys()=false
+
+
+# NB: `seek-ge e` and `seek-ge dog` return MaybeFilteredKeys()=true, despite no
+# filtered keys existing within the range [e,e@3) or [dog,e@3). This is a
+# consequence of the index separator `e`. After seeking the index block, the
+# iterator only knows that the first block MAY contain keys ≤ e. However, it can
+# be skipped regardless, because block properties filters exclude it. In this
+# case, the iterator still returns MaybeFilteredKeys()=true, since keys MAY have
+# been excluded by the filter.
+
+iter point-key-filter=(suffix-point-keys-only,1,9)
+seek-ge e
+seek-ge dog
+----
+<e@3:5> MaybeFilteredKeys()=true
+<e@3:5> MaybeFilteredKeys()=true
+
+iter point-key-filter=(suffix-point-keys-only,1,100)
+first
+next
+next
+next
+next
+next
+next
+seek-lt d
+seek-ge d
+----
+<a@1:1> MaybeFilteredKeys()=false
+<b@10:2> MaybeFilteredKeys()=false
+<c@15:3> MaybeFilteredKeys()=false
+<d@25:4> MaybeFilteredKeys()=false
+<e@3:5> MaybeFilteredKeys()=false
+<f@5:3> MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=false
+<c@15:3> MaybeFilteredKeys()=false
+<d@25:4> MaybeFilteredKeys()=false
+
+# [10,16) intersects {b@10, c@15}.
+
+iter point-key-filter=(suffix-point-keys-only,10,16)
+last
+prev
+prev
+seek-lt a
+seek-lt c
+seek-lt ca
+seek-lt f
+seek-lt e
+seek-lt d
+----
+<c@15:3> MaybeFilteredKeys()=true
+<b@10:2> MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+<b@10:2> MaybeFilteredKeys()=false
+<c@15:3> MaybeFilteredKeys()=false
+<c@15:3> MaybeFilteredKeys()=true
+<c@15:3> MaybeFilteredKeys()=true
+<c@15:3> MaybeFilteredKeys()=false
+
+# Test monotonically increasing bounds optimization, with the first seek
+# filtering keys. The subsequent seek must not reuse the current iterator
+# position and improperly returning MaybeFilteredKeys=false when keys were
+# filtered.
+
+iter point-key-filter=(suffix-point-keys-only,10,16)
+set-bounds lower=b upper=ee
+seek-ge d
+set-bounds lower=ee upper=g
+seek-ge ee
+----
+. MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+
+iter point-key-filter=(suffix-point-keys-only,10,16)
+set-bounds lower=a upper=b
+seek-ge a
+set-bounds lower=b upper=e
+seek-ge b
+seek-ge bb
+----
+. MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+<b@10:2> MaybeFilteredKeys()=true
+<c@15:3> MaybeFilteredKeys()=false
+
+# Test monotonically decreasing bounds optimization, with the first seek
+# filtering keys. The subsequent seek must not reuse the current iterator
+# position and improperly returning MaybeFilteredKeys=false when keys were
+# filtered.
+
+iter point-key-filter=(suffix-point-keys-only,10,16)
+set-bounds lower=e upper=f
+seek-lt f
+set-bounds lower=c upper=e
+seek-lt e
+set-bounds lower=a upper=c
+seek-lt c
+seek-lt b
+----
+. MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+<c@15:3> MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+<b@10:2> MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+
+# The below case tests try-seek-using-next.
+#
+# The `seek-ge aa` does not reposition the iterator. This case should preserve
+# the existing MaybeFilteredKeys()=true value.
+#
+# The `seek-ge c@16` and seek-ge c@19` must also return MaybeFilteredKeys()=true.
+
+iter point-key-filter=(suffix-point-keys-only,10,16)
+seek-ge a
+seek-ge aa true
+seek-ge bb true
+seek-ge c@16 true
+seek-ge c@19 true
+----
+<b@10:2> MaybeFilteredKeys()=true
+<b@10:2> MaybeFilteredKeys()=true
+<c@15:3> MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+
+# Test another case of monotonically increasing bounds optimization, with a
+# different index block structure. The first seek down below should filter keys,
+# and leave the top-level index positioned at the last index block. The
+# subsequent seek must return MaybeFilteredKeys=true when keys were filtered.
+
+build collectors=(suffix-point-keys-only) index-block-size=1 block-size=64
+a@1.SET.1:foo
+b@10.SET.2:bar
+c@15.SET.3:baz
+d@25.SET.4:bax
+e@3.SET.5:box
+f@5.SET.3:mop
+----
+point:    [a@1#1,1,f@5#3,1]
+rangedel: [#0,0,#0,0]
+rangekey: [#0,0,#0,0]
+seqnums:  [1,5]
+
+block-props
+----
+d#72057594037927935,17:
+  0: [1, 16)
+  d#72057594037927935,17:
+    0: [1, 16)
+g#72057594037927935,17:
+  0: [3, 26)
+  g#72057594037927935,17:
+    0: [3, 26)
+
+iter point-key-filter=(suffix-point-keys-only,1,2)
+set-bounds lower=b upper=e
+seek-ge d
+set-bounds lower=e upper=g
+seek-ge ee
+----
+. MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+
+# Test another case of monotonically increasing bounds optimization, with a new
+# index block structure: This one has only one level. The first seek down below
+# should filter keys, and leave the index positioned at the last index block.
+# The subsequent seek must return MaybeFilteredKeys=true when keys were
+# filtered.
+
+build collectors=(suffix-point-keys-only) block-size=32
+a@1.SET.1:foo
+b@10.SET.2:bar
+c@15.SET.3:baz
+d@25.SET.4:bax
+e@3.SET.5:box
+f@5.SET.3:mop
+----
+point:    [a@1#1,1,f@5#3,1]
+rangedel: [#0,0,#0,0]
+rangekey: [#0,0,#0,0]
+seqnums:  [1,5]
+
+block-props
+----
+c#72057594037927935,17:
+  0: [1, 11)
+e#72057594037927935,17:
+  0: [15, 26)
+g#72057594037927935,17:
+  0: [3, 6)
+
+iter point-key-filter=(suffix-point-keys-only,1,2)
+set-bounds lower=b upper=ee
+seek-ge d
+set-bounds lower=ee upper=g
+seek-ge ee
+----
+. MaybeFilteredKeys()=false
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
+. MaybeFilteredKeys()=true
diff --git a/testdata/compaction_pacer_maybe_throttle b/testdata/compaction_pacer_maybe_throttle
index d794152f..2f7ffe47 100644
--- a/testdata/compaction_pacer_maybe_throttle
+++ b/testdata/compaction_pacer_maybe_throttle
@@ -1,250 +1,3 @@
-init compaction
-bytesIterated: 0
-currentTotal: 10
-----
-
-init compaction
-bytesIterated: 1
-currentTotal: 10
-----
-allow: 1
-
-init compaction
-bytesIterated: 2
-currentTotal: 10
-----
-allow: 1
-allow: 1
-
-init compaction
-bytesIterated: 3
-currentTotal: 10
-----
-allow: 1
-allow: 1
-allow: 1
-
-init compaction
-burst: 2
-bytesIterated: 1
-currentTotal: 10
-----
-allow: 1
-
-init compaction
-burst: 2
-bytesIterated: 2
-currentTotal: 10
-----
-allow: 2
-
-init compaction
-burst: 2
-bytesIterated: 3
-currentTotal: 10
-----
-allow: 2
-allow: 1
-
-init compaction
-burst: 10
-bytesIterated: 1
-currentTotal: 10
-----
-allow: 1
-
-init compaction
-burst: 10
-bytesIterated: 2
-currentTotal: 10
-----
-allow: 2
-
-init compaction
-burst: 10
-bytesIterated: 3
-currentTotal: 10
-----
-allow: 3
-
-init compaction
-burst: 10
-bytesIterated: 11
-currentTotal: 10
-----
-wait: 10
-wait: 1
-
-init compaction
-bytesIterated: 3
-currentTotal: 3
-slowdownThreshold: 1
-----
-wait: 1
-wait: 1
-wait: 1
-
-init compaction
-burst: 10
-bytesIterated: 11
-currentTotal: 5
-slowdownThreshold: 10
-----
-wait: 10
-wait: 1
-
-init compaction
-burst: 10
-bytesIterated: 11
-currentTotal: 11
-slowdownThreshold: 10
-----
-wait: 10
-wait: 1
-
-init compaction
-burst: 10
-bytesIterated: 1
-currentTotal: 12
-slowdownThreshold: 10
-----
-allow: 1
-
-init compaction
-burst: 10
-bytesIterated: 2
-currentTotal: 12
-slowdownThreshold: 10
-----
-wait: 2
-
-init compaction
-burst: 10
-bytesIterated: 2
-currentTotal: 12
-dirtyBytes: 0
-slowdownThreshold: 10
-----
-allow: 2
-
-init compaction
-burst: 10
-bytesIterated: 3
-currentTotal: 14
-slowdownThreshold: 10
-----
-allow: 3
-
-init flush
-bytesIterated: 0
-----
-
-init flush
-bytesIterated: 1
-currentTotal: 10
-----
-allow: 1
-
-init flush
-bytesIterated: 2
-currentTotal: 10
-----
-allow: 1
-allow: 1
-
-init flush
-bytesIterated: 3
-currentTotal: 10
-----
-allow: 1
-allow: 1
-allow: 1
-
-init flush
-burst: 2
-bytesIterated: 1
-currentTotal: 10
-----
-allow: 1
-
-init flush
-burst: 2
-bytesIterated: 2
-currentTotal: 10
-----
-allow: 2
-
-init flush
-burst: 2
-bytesIterated: 3
-currentTotal: 10
-----
-allow: 2
-allow: 1
-
-init flush
-burst: 10
-bytesIterated: 1
-currentTotal: 10
-----
-allow: 1
-
-init flush
-burst: 10
-bytesIterated: 2
-currentTotal: 10
-----
-allow: 2
-
-init flush
-burst: 10
-bytesIterated: 3
-currentTotal: 10
-----
-allow: 3
-
-init flush
-bytesIterated: 3
-currentTotal: 3
-slowdownThreshold: 1
-----
-wait: 1
-wait: 1
-wait: 1
-
-init flush
-burst: 10
-bytesIterated: 11
-currentTotal: 11
-slowdownThreshold: 10
-----
-wait: 10
-wait: 1
-
-init flush
-burst: 10
-bytesIterated: 1
-currentTotal: 12
-slowdownThreshold: 10
-----
-allow: 1
-
-init flush
-burst: 10
-bytesIterated: 2
-currentTotal: 12
-slowdownThreshold: 10
-----
-wait: 2
-
-init flush
-burst: 10
-bytesIterated: 3
-currentTotal: 14
-slowdownThreshold: 10
-----
-allow: 3
-
 init deletion
 burst: 10
 bytesIterated: 5
diff --git a/testdata/level_iter_seek b/testdata/level_iter_seek
index b24174d8..23db177b 100644
--- a/testdata/level_iter_seek
+++ b/testdata/level_iter_seek
@@ -56,16 +56,16 @@ c#7,1:c
 d#72057594037927935,15:
 .
 
-# There is no point key with d, but since there is a rangedel, levelIter
-# returns a synthetic boundary key using the largest key, f, in the file.
+# There is no point key with d, but since there is a rangedel, levelIter returns
+# the boundary key using the largest key, f, in the file.
 iter
 seek-prefix-ge d
 ----
-f/d-e:{(#6,RANGEDEL)}#5,15:
+f/d-e:{(#6,RANGEDEL)}#5,1:
 
 # Tests a sequence of SeekPrefixGE with monotonically increasing keys, some of
 # which are present and some not (so fail the bloom filter match). The seek to
-# cc returns a synthetic boundary key.
+# cc returns a boundary key.
 iter
 seek-prefix-ge aa
 seek-prefix-ge c
@@ -77,7 +77,7 @@ seek-prefix-ge h
 ----
 ./<invalid>#0,0:
 c/d-e:{(#6,RANGEDEL)}#7,1:c
-f/d-e:{(#6,RANGEDEL)}#5,15:
+f/d-e:{(#6,RANGEDEL)}#5,1:
 f/<invalid>#5,1:f
 g/<invalid>#4,1:g
 ./<invalid>#0,0:
diff --git a/testdata/metrics b/testdata/metrics
index e205c060..1e4f891c 100644
--- a/testdata/metrics
+++ b/testdata/metrics
@@ -41,7 +41,7 @@ zmemtbl         1   256 K
 
 disk-usage
 ----
-2.0 K
+1.9 K
 
 batch
 set b 2
@@ -152,7 +152,7 @@ zmemtbl         1   256 K
 
 disk-usage
 ----
-2.9 K
+2.8 K
 
 # Closing iter b will release the last zombie sstable and the last zombie memtable.