Repository: cockroachdb/pebble
Pull request #3599 (2024-05-09 14:33:12)

tool: add more summaries to manifest summarize
These were motivated by some disagg storage experimentation. There are summaries for compacted in bytes and files. Also, a histogram of file lifetimes when the file gets deleted. The classification of what is a compaction is slightly improved, and there is some small cleanup (unused field).

On a real manifest, in verbose mode (for the lifetime histograms) the output looks like:
                        _______L0_______L1_______L2_______L3_______L4_______L5_______L6_____TOTAL
2024-05-01T12:00:00Z
     Ingest+Flush Bytes       16GB        .        .        .        .        .        .     16GB
   Ingest+Flush Bytes/s    4.5MB/s        .        .        .        .        .        .  4.5MB/s
      Compact Out Bytes       16GB        .     32GB     36GB     36GB     32GB        .    151GB
    Compact Out Bytes/s    4.5MB/s        .  9.0MB/s   10MB/s   10MB/s  9.1MB/s        .   43MB/s
     Compact In Bytes/s          .        .  9.0MB/s   10MB/s   10MB/s  9.5MB/s  7.7MB/s   47MB/s
     Compact In Files/s          .        .    2.8/s    2.1/s    1.2/s    0.6/s    0.2/s    6.8/s
<snip>
Lifetime histograms
2024-05-01T12:00:00Z
  L0: mean: 15s p25: 10s p50: 15s p75: 21s p90: 26s
  L2: mean: 14s p25: 6s p50: 13s p75: 22s p90: 28s
  L3: mean: 1m7s p25: 13s p50: 1m3s p75: 1m39s p90: 2m23s
  L4: mean: 5m53s p25: 15s p50: 5m19s p75: 10m7s p90: 14m23s
  L5: mean: 34m10s p25: 35s p50: 30m55s p75: 59m43s p90: 1h21m3s
  L6: mean: 3h34m3s p25: 2h42m7s p50: 4h24m31s p75: 5h7m11s p90: 5h41m19s
<snip>

cockroach-teamcity: 

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


Patch:
diff --git a/compaction.go b/compaction.go
index bccd3bdf..61c19f68 100644
--- a/compaction.go
+++ b/compaction.go
@@ -18,7 +18,6 @@ import (
 	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/internal/compact"
-	"github.com/cockroachdb/pebble/internal/invariants"
 	"github.com/cockroachdb/pebble/internal/keyspan"
 	"github.com/cockroachdb/pebble/internal/keyspan/keyspanimpl"
 	"github.com/cockroachdb/pebble/internal/manifest"
@@ -2163,14 +2162,26 @@ func (d *DB) compact1(c *compaction, errChannel chan error) (err error) {
 // d.mu must be held when calling this method. The mutex will be released when
 // doing IO.
 func (d *DB) runCopyCompaction(
-	jobID JobID,
-	c *compaction,
-	inputMeta *fileMetadata,
-	objMeta objstorage.ObjectMetadata,
-	ve *versionEdit,
-) error {
-	ctx := context.TODO()
+	jobID JobID, c *compaction,
+) (ve *versionEdit, stats compact.Stats, _ error) {
+	iter := c.startLevel.files.Iter()
+	inputMeta := iter.First()
+	if iter.Next() != nil {
+		return nil, compact.Stats{}, base.AssertionFailedf("got more than one file for a move compaction")
+	}
+	if c.cancel.Load() {
+		return nil, compact.Stats{}, ErrCancelledCompaction
+	}
+	ve = &versionEdit{
+		DeletedFiles: map[deletedFileEntry]*fileMetadata{
+			{Level: c.startLevel.level, FileNum: inputMeta.FileNum}: inputMeta,
+		},
+	}
 
+	objMeta, err := d.objProvider.Lookup(fileTypeTable, inputMeta.FileBacking.DiskFileNum)
+	if err != nil {
+		return nil, compact.Stats{}, err
+	}
 	if !objMeta.IsExternal() {
 		if objMeta.IsRemote() || !remote.ShouldCreateShared(d.opts.Experimental.CreateOnShared, c.outputLevel.level) {
 			panic("pebble: scheduled a copy compaction that is not actually moving files to shared storage")
@@ -2215,14 +2226,6 @@ func (d *DB) runCopyCompaction(
 		newMeta.InitPhysicalBacking()
 	}
 
-	c.metrics = map[int]*LevelMetrics{
-		c.outputLevel.level: {
-			BytesIn:         inputMeta.Size,
-			BytesCompacted:  inputMeta.Size,
-			TablesCompacted: 1,
-		},
-	}
-
 	// Before dropping the db mutex, grab a ref to the current version. This
 	// prevents any concurrent excises from deleting files that this compaction
 	// needs to read/maintain a reference to.
@@ -2244,11 +2247,12 @@ func (d *DB) runCopyCompaction(
 
 	// If the src obj is external, we're doing an external to local/shared copy.
 	if objMeta.IsExternal() {
+		ctx := context.TODO()
 		src, err := d.objProvider.OpenForReading(
 			ctx, fileTypeTable, inputMeta.FileBacking.DiskFileNum, objstorage.OpenOptions{},
 		)
 		if err != nil {
-			return err
+			return nil, compact.Stats{}, err
 		}
 		defer func() {
 			if src != nil {
@@ -2263,7 +2267,7 @@ func (d *DB) runCopyCompaction(
 			},
 		)
 		if err != nil {
-			return err
+			return nil, compact.Stats{}, err
 		}
 		deleteOnExit = true
 
@@ -2287,7 +2291,17 @@ func (d *DB) runCopyCompaction(
 		)
 		src = nil // We passed src to CopySpan; it's responsible for closing it.
 		if err != nil {
-			return err
+			if errors.Is(err, sstable.ErrEmptySpan) {
+				// The virtual table was empty. Just remove the backing file.
+				// Note that deleteOnExit is true so we will delete the created object.
+				c.metrics = map[int]*LevelMetrics{
+					c.outputLevel.level: {
+						BytesIn: inputMeta.Size,
+					},
+				}
+				return ve, compact.Stats{}, nil
+			}
+			return nil, compact.Stats{}, err
 		}
 		newMeta.FileBacking.Size = wrote
 		newMeta.Size = wrote
@@ -2295,22 +2309,31 @@ func (d *DB) runCopyCompaction(
 		_, err := d.objProvider.LinkOrCopyFromLocal(context.TODO(), d.opts.FS,
 			d.objProvider.Path(objMeta), fileTypeTable, newMeta.FileBacking.DiskFileNum,
 			objstorage.CreateOptions{PreferSharedStorage: true})
-
 		if err != nil {
-			return err
+			return nil, compact.Stats{}, err
 		}
 		deleteOnExit = true
 	}
-	ve.NewFiles[0].Meta = newMeta
+	ve.NewFiles = []newFileEntry{{
+		Level: c.outputLevel.level,
+		Meta:  newMeta,
+	}}
 	if newMeta.Virtual {
 		ve.CreatedBackingTables = []*fileBacking{newMeta.FileBacking}
 	}
+	c.metrics = map[int]*LevelMetrics{
+		c.outputLevel.level: {
+			BytesIn:         inputMeta.Size,
+			BytesCompacted:  newMeta.Size,
+			TablesCompacted: 1,
+		},
+	}
 
 	if err := d.objProvider.Sync(); err != nil {
-		return err
+		return nil, compact.Stats{}, err
 	}
 	deleteOnExit = false
-	return nil
+	return ve, compact.Stats{}, nil
 }
 
 func (d *DB) runDeleteOnlyCompaction(
@@ -2334,23 +2357,17 @@ func (d *DB) runDeleteOnlyCompaction(
 	return ve, stats, nil
 }
 
-func (d *DB) runMoveOrCopyCompaction(
+func (d *DB) runMoveCompaction(
 	jobID JobID, c *compaction,
 ) (ve *versionEdit, stats compact.Stats, _ error) {
 	iter := c.startLevel.files.Iter()
 	meta := iter.First()
-	if invariants.Enabled {
-		if iter.Next() != nil {
-			panic("got more than one file for a move or copy compaction")
-		}
+	if iter.Next() != nil {
+		return nil, stats, base.AssertionFailedf("got more than one file for a move compaction")
 	}
 	if c.cancel.Load() {
 		return ve, stats, ErrCancelledCompaction
 	}
-	objMeta, err := d.objProvider.Lookup(fileTypeTable, meta.FileBacking.DiskFileNum)
-	if err != nil {
-		return ve, stats, err
-	}
 	c.metrics = map[int]*LevelMetrics{
 		c.outputLevel.level: {
 			BytesMoved:  meta.Size,
@@ -2365,12 +2382,8 @@ func (d *DB) runMoveOrCopyCompaction(
 			{Level: c.outputLevel.level, Meta: meta},
 		},
 	}
-	if c.kind == compactionKindMove {
-		return ve, stats, nil
-	}
 
-	err = d.runCopyCompaction(jobID, c, meta, objMeta, ve)
-	return ve, stats, err
+	return ve, stats, nil
 }
 
 // runCompaction runs a compaction that produces new on-disk tables from
@@ -2386,8 +2399,10 @@ func (d *DB) runCompaction(
 	switch c.kind {
 	case compactionKindDeleteOnly:
 		return d.runDeleteOnlyCompaction(jobID, c)
-	case compactionKindMove, compactionKindCopy:
-		return d.runMoveOrCopyCompaction(jobID, c)
+	case compactionKindMove:
+		return d.runMoveCompaction(jobID, c)
+	case compactionKindCopy:
+		return d.runCopyCompaction(jobID, c)
 	case compactionKindIngestedFlushable:
 		panic("pebble: runCompaction cannot handle compactionKindIngestedFlushable.")
 	}
diff --git a/db.go b/db.go
index b4b9acd0..34c4e443 100644
--- a/db.go
+++ b/db.go
@@ -271,6 +271,10 @@ type DB struct {
 
 	// The logical size of the current WAL.
 	logSize atomic.Uint64
+	// The number of input bytes to the log. This is the raw size of the
+	// batches written to the WAL, without the overhead of the record
+	// envelopes.
+	logBytesIn atomic.Uint64
 
 	// The number of bytes available on disk.
 	diskAvailBytes atomic.Uint64
@@ -371,10 +375,6 @@ type DB struct {
 			// manager is not protected by mu, but calls to Create must be
 			// serialized, and happen after the previous writer is closed.
 			manager wal.Manager
-			// The number of input bytes to the log. This is the raw size of the
-			// batches written to the WAL, without the overhead of the record
-			// envelopes. Requires DB.mu to be held when read or written.
-			bytesIn uint64
 			// The Writer is protected by commitPipeline.mu. This allows log writes
 			// to be performed without holding DB.mu, but requires both
 			// commitPipeline.mu and DB.mu to be held when rotating the WAL/memtable
@@ -390,7 +390,15 @@ type DB struct {
 		}
 
 		mem struct {
-			// The current mutable memTable.
+			// The current mutable memTable. Readers of the pointer may hold
+			// either DB.mu or commitPipeline.mu.
+			//
+			// Its internal fields are protected by commitPipeline.mu. This
+			// allows batch commits to be performed without DB.mu as long as no
+			// memtable rotation is required.
+			//
+			// Both commitPipeline.mu and DB.mu must be held when rotating the
+			// memtable.
 			mutable *memTable
 			// Queue of flushables (the mutable memtable is at end). Elements are
 			// added to the end of the slice and removed from the beginning. Once an
@@ -930,35 +938,39 @@ func (d *DB) commitWrite(b *Batch, syncWG *sync.WaitGroup, syncErr *error) (*mem
 		}
 	}
 
-	d.mu.Lock()
-
 	var err error
+	// Grab a reference to the memtable. We don't hold DB.mu, but we do hold
+	// d.commit.mu. It's okay for readers of d.mu.mem.mutable to only hold one of
+	// d.commit.mu or d.mu, because memtable rotations require holding both.
+	mem := d.mu.mem.mutable
+	// Batches which contain keys of kind InternalKeyKindIngestSST will
+	// never be applied to the memtable, so we don't need to make room for
+	// write.
 	if !b.ingestedSSTBatch {
-		// Batches which contain keys of kind InternalKeyKindIngestSST will
-		// never be applied to the memtable, so we don't need to make room for
-		// write. For the other cases, switch out the memtable if there was not
-		// enough room to store the batch.
-		err = d.makeRoomForWrite(b)
-	}
-
-	if err == nil && !d.opts.DisableWAL {
-		d.mu.log.bytesIn += uint64(len(repr))
+		// Flushable batches will require a rotation of the memtable regardless,
+		// so only attempt an optimistic reservation of space in the current
+		// memtable if this batch is not a large flushable batch.
+		if b.flushable == nil {
+			err = d.mu.mem.mutable.prepare(b)
+		}
+		if b.flushable != nil || err == arenaskl.ErrArenaFull {
+			// Slow path.
+			// We need to acquire DB.mu and rotate the memtable.
+			func() {
+				d.mu.Lock()
+				defer d.mu.Unlock()
+				err = d.makeRoomForWrite(b)
+				mem = d.mu.mem.mutable
+			}()
+		}
 	}
-
-	// Grab a reference to the memtable while holding DB.mu. Note that for
-	// non-flushable batches (b.flushable == nil) makeRoomForWrite() added a
-	// reference to the memtable which will prevent it from being flushed until
-	// we unreference it. This reference is dropped in DB.commitApply().
-	mem := d.mu.mem.mutable
-
-	d.mu.Unlock()
 	if err != nil {
 		return nil, err
 	}
-
 	if d.opts.DisableWAL {
 		return mem, nil
 	}
+	d.logBytesIn.Add(uint64(len(repr)))
 
 	if b.flushable == nil {
 		size, err = d.mu.log.writer.WriteRecord(repr, wal.SyncOptions{Done: syncWG, Err: syncErr}, b)
@@ -1959,7 +1971,7 @@ func (d *DB) Metrics() *Metrics {
 	// d.atomic.logSize has exceeded that physical size. We allow for this
 	// anomaly.
 	metrics.WAL.PhysicalSize = walStats.LiveFileSize
-	metrics.WAL.BytesIn = d.mu.log.bytesIn // protected by d.mu
+	metrics.WAL.BytesIn = d.logBytesIn.Load()
 	metrics.WAL.Size = d.logSize.Load()
 	for i, n := 0, len(d.mu.mem.queue)-1; i < n; i++ {
 		metrics.WAL.Size += d.mu.mem.queue[i].logSize
@@ -2325,13 +2337,24 @@ func (d *DB) walPreallocateSize() int {
 	return int(size)
 }
 
-func (d *DB) newMemTable(logNum base.DiskFileNum, logSeqNum uint64) (*memTable, *flushableEntry) {
+func (d *DB) newMemTable(
+	logNum base.DiskFileNum, logSeqNum, minSize uint64,
+) (*memTable, *flushableEntry) {
+	targetSize := minSize + uint64(memTableEmptySize)
+	// The targetSize should be less than MemTableSize, because any batch >=
+	// MemTableSize/2 should be treated as a large flushable batch.
+	if targetSize > d.opts.MemTableSize {
+		panic(errors.AssertionFailedf("attempting to allocate memtable larger than MemTableSize"))
+	}
+	// Double until the next memtable size is at least large enough to fit
+	// minSize.
+	for d.mu.mem.nextSize < targetSize {
+		d.mu.mem.nextSize = min(2*d.mu.mem.nextSize, d.opts.MemTableSize)
+	}
 	size := d.mu.mem.nextSize
+	// The next memtable should be double the size, up to Options.MemTableSize.
 	if d.mu.mem.nextSize < d.opts.MemTableSize {
-		d.mu.mem.nextSize *= 2
-		if d.mu.mem.nextSize > d.opts.MemTableSize {
-			d.mu.mem.nextSize = d.opts.MemTableSize
-		}
+		d.mu.mem.nextSize = min(2*d.mu.mem.nextSize, d.opts.MemTableSize)
 	}
 
 	memtblOpts := memTableOptions{
@@ -2411,69 +2434,56 @@ func (d *DB) newFlushableEntry(
 	return fe
 }
 
-// makeRoomForWrite ensures that the memtable has room to hold the contents of
-// Batch. It reserves the space in the memtable and adds a reference to the
-// memtable. The caller must later ensure that the memtable is unreferenced. If
-// the memtable is full, or a nil Batch is provided, the current memtable is
-// rotated (marked as immutable) and a new mutable memtable is allocated. This
-// memtable rotation also causes a log rotation.
+// maybeInduceWriteStall is called before performing a memtable rotation in
+// makeRoomForWrite. In some conditions, we prefer to stall the user's write
+// workload rather than continuing to accept writes that may result in resource
+// exhaustion or prohibitively slow reads.
 //
-// Both DB.mu and commitPipeline.mu must be held by the caller. Note that DB.mu
-// may be released and reacquired.
-func (d *DB) makeRoomForWrite(b *Batch) error {
-	if b != nil && b.ingestedSSTBatch {
-		panic("pebble: invalid function call")
-	}
-
-	force := b == nil || b.flushable != nil
+// There are a couple reasons we might wait to rotate the memtable and
+// instead induce a write stall:
+//  1. If too many memtables have queued, we wait for a flush to finish before
+//     creating another memtable.
+//  2. If L0 read amplification has grown too high, we wait for compactions
+//     to reduce the read amplification before accepting more writes that will
+//     increase write pressure.
+//
+// maybeInduceWriteStall checks these stall conditions, and if present, waits
+// for them to abate.
+func (d *DB) maybeInduceWriteStall(b *Batch) {
 	stalled := false
+	// This function will call EventListener.WriteStallBegin at most once.  If
+	// it does call it, it will call EventListener.WriteStallEnd once before
+	// returning.
 	for {
-		if b != nil && b.flushable == nil {
-			err := d.mu.mem.mutable.prepare(b)
-			if err != arenaskl.ErrArenaFull {
-				if stalled {
-					d.opts.EventListener.WriteStallEnd()
-				}
-				return err
-			}
-		} else if !force {
-			if stalled {
-				d.opts.EventListener.WriteStallEnd()
-			}
-			return nil
-		}
-		// force || err == ErrArenaFull, so we need to rotate the current memtable.
-		{
-			var size uint64
-			for i := range d.mu.mem.queue {
-				size += d.mu.mem.queue[i].totalBytes()
+		var size uint64
+		for i := range d.mu.mem.queue {
+			size += d.mu.mem.queue[i].totalBytes()
+		}
+		// If ElevateWriteStallThresholdForFailover is true, we give an
+		// unlimited memory budget for memtables. This is simpler than trying to
+		// configure an explicit value, given that memory resources can vary.
+		// When using WAL failover in CockroachDB, an OOM risk is worth
+		// tolerating for workloads that have a strict latency SLO. Also, an
+		// unlimited budget here does not mean that the disk stall in the
+		// primary will go unnoticed until the OOM -- CockroachDB is monitoring
+		// disk stalls, and we expect it to fail the node after ~60s if the
+		// primary is stalled.
+		if size >= uint64(d.opts.MemTableStopWritesThreshold)*d.opts.MemTableSize &&
+			!d.mu.log.manager.ElevateWriteStallThresholdForFailover() {
+			// We have filled up the current memtable, but already queued memtables
+			// are still flushing, so we wait.
+			if !stalled {
+				stalled = true
+				d.opts.EventListener.WriteStallBegin(WriteStallBeginInfo{
+					Reason: "memtable count limit reached",
+				})
 			}
-			// If ElevateWriteStallThresholdForFailover is true, we give an
-			// unlimited memory budget for memtables. This is simpler than trying to
-			// configure an explicit value, given that memory resources can vary.
-			// When using WAL failover in CockroachDB, an OOM risk is worth
-			// tolerating for workloads that have a strict latency SLO. Also, an
-			// unlimited budget here does not mean that the disk stall in the
-			// primary will go unnoticed until the OOM -- CockroachDB is monitoring
-			// disk stalls, and we expect it to fail the node after ~60s if the
-			// primary is stalled.
-			if size >= uint64(d.opts.MemTableStopWritesThreshold)*d.opts.MemTableSize &&
-				!d.mu.log.manager.ElevateWriteStallThresholdForFailover() {
-				// We have filled up the current memtable, but already queued memtables
-				// are still flushing, so we wait.
-				if !stalled {
-					stalled = true
-					d.opts.EventListener.WriteStallBegin(WriteStallBeginInfo{
-						Reason: "memtable count limit reached",
-					})
-				}
-				now := time.Now()
-				d.mu.compact.cond.Wait()
-				if b != nil {
-					b.commitStats.MemTableWriteStallDuration += time.Since(now)
-				}
-				continue
+			now := time.Now()
+			d.mu.compact.cond.Wait()
+			if b != nil {
+				b.commitStats.MemTableWriteStallDuration += time.Since(now)
 			}
+			continue
 		}
 		l0ReadAmp := d.mu.versions.currentVersion().L0Sublevels.ReadAmplification()
 		if l0ReadAmp >= d.opts.L0StopWritesThreshold {
@@ -2491,31 +2501,52 @@ func (d *DB) makeRoomForWrite(b *Batch) error {
 			}
 			continue
 		}
-
-		var newLogNum base.DiskFileNum
-		var prevLogSize uint64
-		if !d.opts.DisableWAL {
-			now := time.Now()
-			newLogNum, prevLogSize = d.rotateWAL()
-			if b != nil {
-				b.commitStats.WALRotationDuration += time.Since(now)
-			}
+		// Not stalled.
+		if stalled {
+			d.opts.EventListener.WriteStallEnd()
 		}
+		return
+	}
+}
 
-		immMem := d.mu.mem.mutable
-		imm := d.mu.mem.queue[len(d.mu.mem.queue)-1]
-		imm.logSize = prevLogSize
-		imm.flushForced = imm.flushForced || (b == nil)
+// makeRoomForWrite rotates the current mutable memtable, ensuring that the
+// resulting mutable memtable has room to hold the contents of the provided
+// Batch. The current memtable is rotated (marked as immutable) and a new
+// mutable memtable is allocated. It reserves space in the new memtable and adds
+// a reference to the memtable. The caller must later ensure that the memtable
+// is unreferenced. This memtable rotation also causes a log rotation.
+//
+// If the current memtable is not full but the caller wishes to trigger a
+// rotation regardless, the caller may pass a nil Batch, and no space in the
+// resulting mutable memtable will be reserved.
+//
+// Both DB.mu and commitPipeline.mu must be held by the caller. Note that DB.mu
+// may be released and reacquired.
+func (d *DB) makeRoomForWrite(b *Batch) error {
+	if b != nil && b.ingestedSSTBatch {
+		panic("pebble: invalid function call")
+	}
+	d.maybeInduceWriteStall(b)
 
-		// If we are manually flushing and we used less than half of the bytes in
-		// the memtable, don't increase the size for the next memtable. This
-		// reduces memtable memory pressure when an application is frequently
-		// manually flushing.
-		if (b == nil) && uint64(immMem.availBytes()) > immMem.totalBytes()/2 {
-			d.mu.mem.nextSize = immMem.totalBytes()
+	var newLogNum base.DiskFileNum
+	var prevLogSize uint64
+	if !d.opts.DisableWAL {
+		now := time.Now()
+		newLogNum, prevLogSize = d.rotateWAL()
+		if b != nil {
+			b.commitStats.WALRotationDuration += time.Since(now)
 		}
+	}
+	immMem := d.mu.mem.mutable
+	imm := d.mu.mem.queue[len(d.mu.mem.queue)-1]
+	imm.logSize = prevLogSize
 
-		if b != nil && b.flushable != nil {
+	var logSeqNum uint64
+	var minSize uint64
+	if b != nil {
+		logSeqNum = b.SeqNum()
+		if b.flushable != nil {
+			logSeqNum += uint64(b.Count())
 			// The batch is too large to fit in the memtable so add it directly to
 			// the immutable queue. The flushable batch is associated with the same
 			// log as the immutable memtable, but logically occurs after it in
@@ -2532,24 +2563,43 @@ func (d *DB) makeRoomForWrite(b *Batch) error {
 			// for it until it is flushed.
 			entry.releaseMemAccounting = d.opts.Cache.Reserve(int(b.flushable.totalBytes()))
 			d.mu.mem.queue = append(d.mu.mem.queue, entry)
-		}
-
-		var logSeqNum uint64
-		if b != nil {
-			logSeqNum = b.SeqNum()
-			if b.flushable != nil {
-				logSeqNum += uint64(b.Count())
-			}
 		} else {
-			logSeqNum = d.mu.versions.logSeqNum.Load()
+			minSize = b.memTableSize
+		}
+	} else {
+		// b == nil
+		//
+		// This is a manual forced flush.
+		logSeqNum = d.mu.versions.logSeqNum.Load()
+		imm.flushForced = true
+		// If we are manually flushing and we used less than half of the bytes in
+		// the memtable, don't increase the size for the next memtable. This
+		// reduces memtable memory pressure when an application is frequently
+		// manually flushing.
+		if uint64(immMem.availBytes()) > immMem.totalBytes()/2 {
+			d.mu.mem.nextSize = immMem.totalBytes()
 		}
-		d.rotateMemtable(newLogNum, logSeqNum, immMem)
-		force = false
 	}
+	d.rotateMemtable(newLogNum, logSeqNum, immMem, minSize)
+	if b != nil && b.flushable == nil {
+		err := d.mu.mem.mutable.prepare(b)
+		// Reserving enough space for the batch after rotation must never fail.
+		// We pass in a minSize that's equal to b.memtableSize to ensure that
+		// memtable rotation allocates a memtable sufficiently large. We also
+		// held d.commit.mu for the entirety of this function, ensuring that no
+		// other committers may have reserved memory in the new memtable yet.
+		if err == arenaskl.ErrArenaFull {
+			panic(errors.AssertionFailedf("memtable still full after rotation"))
+		}
+		return err
+	}
+	return nil
 }
 
 // Both DB.mu and commitPipeline.mu must be held by the caller.
-func (d *DB) rotateMemtable(newLogNum base.DiskFileNum, logSeqNum uint64, prev *memTable) {
+func (d *DB) rotateMemtable(
+	newLogNum base.DiskFileNum, logSeqNum uint64, prev *memTable, minSize uint64,
+) {
 	// Create a new memtable, scheduling the previous one for flushing. We do
 	// this even if the previous memtable was empty because the DB.Flush
 	// mechanism is dependent on being able to wait for the empty memtable to
@@ -2566,7 +2616,7 @@ func (d *DB) rotateMemtable(newLogNum base.DiskFileNum, logSeqNum uint64, prev *
 	//
 	// NB: prev should be the current mutable memtable.
 	var entry *flushableEntry
-	d.mu.mem.mutable, entry = d.newMemTable(newLogNum, logSeqNum)
+	d.mu.mem.mutable, entry = d.newMemTable(newLogNum, logSeqNum, minSize)
 	d.mu.mem.queue = append(d.mu.mem.queue, entry)
 	// d.logSize tracks the log size of the WAL file corresponding to the most
 	// recent flushable. The log size of the previous mutable memtable no longer
diff --git a/ingest.go b/ingest.go
index 63cc4523..64552d08 100644
--- a/ingest.go
+++ b/ingest.go
@@ -698,68 +698,6 @@ func (d *DB) ingestAttachRemote(jobID JobID, lr ingestLoadResult) error {
 		}
 	}
 
-	if invariants.Enabled || d.opts.Experimental.CheckExternalIngestions {
-		// Verify that the ingestion is not empty. Empty tables cause correctness
-		// issues in some iterators.
-		for i := range lr.external {
-			e := &lr.external[i]
-			isEmpty, err := func() (bool, error) {
-				readable, err := d.objProvider.OpenForReading(
-					context.Background(), fileTypeTable, e.FileBacking.DiskFileNum, objstorage.OpenOptions{},
-				)
-				if err != nil {
-					return false, err
-				}
-				reader, err := sstable.NewReader(readable, d.opts.MakeReaderOptions())
-				if err != nil {
-					readable.Close()
-					return false, err
-				}
-				defer reader.Close()
-				iter, err := reader.NewIter(e.IterTransforms(), nil, nil)
-				if err != nil {
-					return false, err
-				}
-				if iter != nil {
-					defer iter.Close()
-					if kv := iter.SeekGE(e.external.StartKey, base.SeekGEFlagsNone); kv != nil {
-						cmp := d.opts.Comparer.Compare(kv.K.UserKey, e.external.EndKey)
-						if cmp < 0 || (cmp == 0 && e.external.EndKeyIsInclusive) {
-							return false, nil
-						}
-					}
-					if err := iter.Error(); err != nil {
-						return false, err
-					}
-				}
-				rangeIter, err := reader.NewRawRangeDelIter(e.IterTransforms())
-				if err != nil {
-					return false, err
-				}
-				if rangeIter != nil {
-					defer rangeIter.Close()
-					span, err := rangeIter.SeekGE(e.external.StartKey)
-					if err != nil {
-						return false, err
-					}
-					if span != nil {
-						cmp := d.opts.Comparer.Compare(span.Start, e.external.EndKey)
-						if cmp < 0 || (cmp == 0 && e.external.EndKeyIsInclusive) {
-							return false, nil
-						}
-					}
-				}
-				return true, nil
-			}()
-			if err != nil {
-				return err
-			}
-			if isEmpty {
-				panic(fmt.Sprintf("external ingestion is empty: %s (%q %q)", e.external.ObjName, e.external.StartKey, e.external.EndKey))
-			}
-		}
-	}
-
 	if d.opts.EventListener.TableCreated != nil {
 		for i := range remoteObjMetas {
 			d.opts.EventListener.TableCreated(TableCreateInfo{
@@ -1363,7 +1301,7 @@ func (d *DB) handleIngestAsFlushable(
 	// ingested sstables being placed on top of them, but those
 	// memtables would have to be flushed anyways.
 	d.mu.mem.queue = append(d.mu.mem.queue, entry)
-	d.rotateMemtable(newLogNum, nextSeqNum, currMem)
+	d.rotateMemtable(newLogNum, nextSeqNum, currMem, 0 /* minSize */)
 	d.updateReadStateLocked(d.opts.DebugCheck)
 	// TODO(aaditya): is this necessary? we call this already in rotateMemtable above
 	d.maybeScheduleFlush()
diff --git a/internal/base/iterator.go b/internal/base/iterator.go
index 7cbea1b7..bea935eb 100644
--- a/internal/base/iterator.go
+++ b/internal/base/iterator.go
@@ -8,6 +8,9 @@ import (
 	"context"
 	"fmt"
 	"time"
+
+	"github.com/cockroachdb/pebble/internal/humanize"
+	"github.com/cockroachdb/redact"
 )
 
 // InternalIterator iterates over a DB's key/value pairs in key order. Unlike
@@ -107,13 +110,15 @@ type InternalIterator interface {
 	// SeekPrefixGE only checks the upper bound. It is up to the caller to ensure
 	// that key is greater than or equal to the lower bound.
 	//
-	// The prefix argument is used by some InternalIterator implementations (e.g.
-	// sstable.Reader) to avoid expensive operations. This operation is only
-	// useful when a user-defined Split function is supplied to the Comparer for
-	// the DB. The supplied prefix will be the prefix of the given key returned by
-	// that Split function. If the iterator is able to determine that no key with
-	// the prefix exists, it can return (nil,nilv). Unlike SeekGE, this is not an
-	// indication that iteration is exhausted.
+	// The prefix argument is used by some InternalIterator implementations
+	// (e.g.  sstable.Reader) to avoid expensive operations. This operation is
+	// only useful when a user-defined Split function is supplied to the
+	// Comparer for the DB. The supplied prefix will be the prefix of the given
+	// key returned by that Split function. If the iterator is able to determine
+	// that no key with the prefix exists, it can return (nil,nilv). Unlike
+	// SeekGE, this is not an indication that iteration is exhausted. The prefix
+	// byte slice is guaranteed to be stable until the next absolute positioning
+	// operation.
 	//
 	// Note that the iterator may return keys not matching the prefix. It is up
 	// to the caller to check if the prefix matches.
@@ -424,3 +429,31 @@ func (s *InternalIteratorStats) Merge(from InternalIteratorStats) {
 	s.SeparatedPointValue.ValueBytes += from.SeparatedPointValue.ValueBytes
 	s.SeparatedPointValue.ValueBytesFetched += from.SeparatedPointValue.ValueBytesFetched
 }
+
+func (s *InternalIteratorStats) String() string {
+	return redact.StringWithoutMarkers(s)
+}
+
+// SafeFormat implements the redact.SafeFormatter interface.
+func (s *InternalIteratorStats) SafeFormat(p redact.SafePrinter, verb rune) {
+	var tombstoned humanize.FormattedString
+	if s.PointsCoveredByRangeTombstones != 0 {
+		tombstoned = "(" + humanize.Count.Uint64(s.PointsCoveredByRangeTombstones) + " tombstoned)"
+	}
+	p.Printf("blocks: %s (%s cached), read time %s; "+
+		"points: %s%s (%s keys, %s values)",
+		humanize.Bytes.Uint64(s.BlockBytes),
+		humanize.Bytes.Uint64(s.BlockBytesInCache),
+		humanize.FormattedString(s.BlockReadDuration.String()),
+		humanize.Count.Uint64(s.PointCount),
+		tombstoned,
+		humanize.Bytes.Uint64(s.KeyBytes),
+		humanize.Bytes.Uint64(s.ValueBytes),
+	)
+	if s.SeparatedPointValue.Count != 0 {
+		p.Printf("; separated: %s (%s, %s fetched)",
+			humanize.Count.Uint64(s.SeparatedPointValue.Count),
+			humanize.Bytes.Uint64(s.SeparatedPointValue.ValueBytes),
+			humanize.Bytes.Uint64(s.SeparatedPointValue.ValueBytesFetched))
+	}
+}
diff --git a/internal/keyspan/interleaving_iter.go b/internal/keyspan/interleaving_iter.go
index 418358b5..a6eee31e 100644
--- a/internal/keyspan/interleaving_iter.go
+++ b/internal/keyspan/interleaving_iter.go
@@ -465,12 +465,12 @@ func (i *InterleavingIter) Next() *base.InternalKV {
 			// Since we're positioned on a Span, the pointIter is positioned
 			// entirely behind the current iterator position. Reposition it
 			// ahead of the current iterator position.
-			i.savePoint(i.pointIter.Next())
+			i.switchPointIteratorIntoForward()
 		case posKeyspanEnd:
 			// Since we're positioned on a Span, the pointIter is positioned
 			// entirely behind of the current iterator position. Reposition it
 			// ahead the current iterator position.
-			i.savePoint(i.pointIter.Next())
+			i.switchPointIteratorIntoForward()
 		}
 		// Fallthrough to calling i.nextPos.
 	}
@@ -543,7 +543,7 @@ func (i *InterleavingIter) Prev() *base.InternalKV {
 			// Since we're positioned on a Span, the pointIter is positioned
 			// entirely ahead of the current iterator position. Reposition it
 			// behind the current iterator position.
-			i.savePoint(i.pointIter.Prev())
+			i.switchPointIteratorIntoReverse()
 			// Without considering truncation of spans to seek keys, the keyspan
 			// iterator is already in the right place. But consider span [a, z)
 			// and this sequence of iterator calls:
@@ -565,7 +565,7 @@ func (i *InterleavingIter) Prev() *base.InternalKV {
 			// Since we're positioned on a Span, the pointIter is positioned
 			// entirely ahead of the current iterator position. Reposition it
 			// behind the current iterator position.
-			i.savePoint(i.pointIter.Prev())
+			i.switchPointIteratorIntoReverse()
 		}
 
 		if i.spanMarkerTruncated {
@@ -637,7 +637,7 @@ func (i *InterleavingIter) nextPos() {
 
 	switch i.pos {
 	case posExhausted:
-		i.savePoint(i.pointIter.Next())
+		i.switchPointIteratorIntoForward()
 		i.saveSpanForward(i.keyspanIter.Next())
 		i.savedKeyspan()
 		i.computeSmallestPos()
@@ -709,7 +709,7 @@ func (i *InterleavingIter) prevPos() {
 
 	switch i.pos {
 	case posExhausted:
-		i.savePoint(i.pointIter.Prev())
+		i.switchPointIteratorIntoReverse()
 		i.saveSpanBackward(i.keyspanIter.Prev())
 		i.savedKeyspan()
 		i.computeLargestPos()
@@ -848,6 +848,30 @@ func (i *InterleavingIter) keyspanSeekLT(k []byte) {
 	i.savedKeyspan()
 }
 
+// switchPointIteratorIntoReverse switches the direction of the point iterator
+// into reverse, stepping to the previous point key. If the point iterator is
+// exhausted in the forward direction and there's an upper bound present, it's
+// re-seeked to ensure the iterator obeys the upper bound.
+func (i *InterleavingIter) switchPointIteratorIntoReverse() {
+	if i.pointKV == nil && i.opts.UpperBound != nil {
+		i.savePoint(i.pointIter.SeekLT(i.opts.UpperBound, base.SeekLTFlagsNone))
+		return
+	}
+	i.savePoint(i.pointIter.Prev())
+}
+
+// switchPointIteratorIntoForward switches the direction of the point iterator
+// into the forward direction, stepping to the next point key. If the point
+// iterator is exhausted in the reverse direction and there's a lower bound
+// present, it's re-seeked to ensure the iterator obeys the lower bound.
+func (i *InterleavingIter) switchPointIteratorIntoForward() {
+	if i.pointKV == nil && i.opts.LowerBound != nil {
+		i.savePoint(i.pointIter.SeekGE(i.opts.LowerBound, base.SeekGEFlagsNone))
+		return
+	}
+	i.savePoint(i.pointIter.Next())
+}
+
 func (i *InterleavingIter) saveSpanForward(span *Span, err error) {
 	i.span = span
 	i.err = firstError(i.err, err)
@@ -1012,9 +1036,10 @@ func (i *InterleavingIter) verify(kv *base.InternalKV) *base.InternalKV {
 		switch {
 		case i.dir == -1 && i.spanMarkerTruncated:
 			panic("pebble: invariant violation: truncated span key in reverse iteration")
-		case kv != nil && i.opts.LowerBound != nil && i.cmp(kv.K.UserKey, i.opts.LowerBound) < 0:
+		case kv != nil && i.opts.LowerBound != nil && !kv.K.IsExclusiveSentinel() &&
+			i.cmp(kv.K.UserKey, i.opts.LowerBound) < 0:
 			panic("pebble: invariant violation: key < lower bound")
-		case kv != nil && i.opts.UpperBound != nil &&
+		case kv != nil && i.opts.UpperBound != nil && !kv.K.IsExclusiveSentinel() &&
 			!base.UserKeyExclusive(i.opts.UpperBound).IsUpperBoundForInternalKey(i.comparer.Compare, kv.K):
 			panic("pebble: invariant violation: key ≥ upper bound")
 		case i.err != nil && kv != nil:
diff --git a/internal/metamorphic/metaflags/meta_flags.go b/internal/metamorphic/metaflags/meta_flags.go
index 3561062e..b347030b 100644
--- a/internal/metamorphic/metaflags/meta_flags.go
+++ b/internal/metamorphic/metaflags/meta_flags.go
@@ -97,6 +97,9 @@ type RunOnceFlags struct {
 	// operations that reproduce a problem during a test run (e.g. panic or
 	// internal error).
 	TryToReduce bool
+	// ReduceAttempts is the number of attempts to reduce (for each op removal
+	// probability).
+	ReduceAttempts int
 }
 
 func initRunOnceFlags(c *CommonFlags) *RunOnceFlags {
@@ -114,6 +117,9 @@ Example, --compare '_meta/231220-164251.3552792807512/{standard-000,random-025}'
 		`if set, we will try to reduce the number of operations that cause a failure. The
 verbose flag should be used with this flag.`)
 
+	flag.IntVar(&ro.ReduceAttempts, "reduce-attempts", 100,
+		`the number of attempts to reduce, for each probability; only used with --try-to-reduce.`)
+
 	return ro
 }
 
diff --git a/iterator.go b/iterator.go
index 19185d49..188699ff 100644
--- a/iterator.go
+++ b/iterator.go
@@ -155,6 +155,18 @@ func (s *RangeKeyIteratorStats) Merge(o RangeKeyIteratorStats) {
 	s.SkippedPoints += o.SkippedPoints
 }
 
+func (s *RangeKeyIteratorStats) String() string {
+	return redact.StringWithoutMarkers(s)
+}
+
+// SafeFormat implements the redact.SafeFormatter interface.
+func (s *RangeKeyIteratorStats) SafeFormat(p redact.SafePrinter, verb rune) {
+	p.Printf("range keys: %s, contained points: %s (%s skipped)",
+		humanize.Count.Uint64(uint64(s.Count)),
+		humanize.Count.Uint64(uint64(s.ContainedPoints)),
+		humanize.Count.Uint64(uint64(s.SkippedPoints)))
+}
+
 // LazyValue is a lazy value. See the long comment in base.LazyValue.
 type LazyValue = base.LazyValue
 
@@ -1720,6 +1732,9 @@ func (i *Iterator) NextPrefix() bool {
 		i.iterValidityState = IterExhausted
 		return false
 	}
+	if i.Error() != nil {
+		return false
+	}
 	return i.nextPrefix() == IterValid
 }
 
@@ -1740,11 +1755,6 @@ func (i *Iterator) nextPrefix() IterValidityState {
 		// key.
 		i.rangeKey.updated = i.rangeKey.hasRangeKey && !i.Valid() && i.opts.rangeKeys()
 	}
-	// NextPrefix from an exhausted position is undefined. We keep the exhausted
-	// position, which provides determinism for the metamorphic tests.
-	if i.iterValidityState == IterExhausted {
-		return i.iterValidityState
-	}
 
 	// Although NextPrefix documents that behavior at IterAtLimit is undefined,
 	// this function handles these cases as a simple prefix-agnostic Next. This
@@ -2889,44 +2899,44 @@ func (stats *IteratorStats) String() string {
 
 // SafeFormat implements the redact.SafeFormatter interface.
 func (stats *IteratorStats) SafeFormat(s redact.SafePrinter, verb rune) {
-	for i := range stats.ForwardStepCount {
-		switch IteratorStatsKind(i) {
-		case InterfaceCall:
-			s.SafeString("(interface (dir, seek, step): ")
-		case InternalIterCall:
-			s.SafeString(", (internal (dir, seek, step): ")
-		}
-		s.Printf("(fwd, %d, %d), (rev, %d, %d))",
-			redact.Safe(stats.ForwardSeekCount[i]), redact.Safe(stats.ForwardStepCount[i]),
-			redact.Safe(stats.ReverseSeekCount[i]), redact.Safe(stats.ReverseStepCount[i]))
+	if stats.ReverseSeekCount[InterfaceCall] == 0 && stats.ReverseSeekCount[InternalIterCall] == 0 {
+		s.Printf("seeked %s times (%s internal)",
+			humanize.Count.Uint64(uint64(stats.ForwardSeekCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ForwardSeekCount[InternalIterCall])),
+		)
+	} else {
+		s.Printf("seeked %s times (%s fwd/%s rev, internal: %s fwd/%s rev)",
+			humanize.Count.Uint64(uint64(stats.ForwardSeekCount[InterfaceCall]+stats.ReverseSeekCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ForwardSeekCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ReverseSeekCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ForwardSeekCount[InternalIterCall])),
+			humanize.Count.Uint64(uint64(stats.ReverseSeekCount[InternalIterCall])),
+		)
 	}
-	if stats.InternalStats != (InternalIteratorStats{}) {
-		s.SafeString(",\n(internal-stats: ")
-		s.Printf("(block-bytes: (total %s, cached %s, read-time %s)), "+
-			"(points: (count %s, key-bytes %s, value-bytes %s, tombstoned %s))",
-			humanize.Bytes.Uint64(stats.InternalStats.BlockBytes),
-			humanize.Bytes.Uint64(stats.InternalStats.BlockBytesInCache),
-			humanize.FormattedString(stats.InternalStats.BlockReadDuration.String()),
-			humanize.Count.Uint64(stats.InternalStats.PointCount),
-			humanize.Bytes.Uint64(stats.InternalStats.KeyBytes),
-			humanize.Bytes.Uint64(stats.InternalStats.ValueBytes),
-			humanize.Count.Uint64(stats.InternalStats.PointsCoveredByRangeTombstones),
+	s.SafeString("; ")
+
+	if stats.ReverseStepCount[InterfaceCall] == 0 && stats.ReverseStepCount[InternalIterCall] == 0 {
+		s.Printf("stepped %s times (%s internal)",
+			humanize.Count.Uint64(uint64(stats.ForwardStepCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ForwardStepCount[InternalIterCall])),
 		)
-		if stats.InternalStats.SeparatedPointValue.Count != 0 {
-			s.Printf(", (separated: (count %s, bytes %s, fetched %s)))",
-				humanize.Count.Uint64(stats.InternalStats.SeparatedPointValue.Count),
-				humanize.Bytes.Uint64(stats.InternalStats.SeparatedPointValue.ValueBytes),
-				humanize.Bytes.Uint64(stats.InternalStats.SeparatedPointValue.ValueBytesFetched))
-		} else {
-			s.Printf(")")
-		}
+	} else {
+		s.Printf("stepped %s times (%s fwd/%s rev, internal: %s fwd/%s rev)",
+			humanize.Count.Uint64(uint64(stats.ForwardStepCount[InterfaceCall]+stats.ReverseStepCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ForwardStepCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ReverseStepCount[InterfaceCall])),
+			humanize.Count.Uint64(uint64(stats.ForwardStepCount[InternalIterCall])),
+			humanize.Count.Uint64(uint64(stats.ReverseStepCount[InternalIterCall])),
+		)
+	}
+
+	if stats.InternalStats != (InternalIteratorStats{}) {
+		s.SafeString("; ")
+		stats.InternalStats.SafeFormat(s, verb)
 	}
 	if stats.RangeKeyStats != (RangeKeyIteratorStats{}) {
-		s.SafeString(",\n(range-key-stats: ")
-		s.Printf("(count %d), (contained points: (count %d, skipped %d)))",
-			stats.RangeKeyStats.Count,
-			stats.RangeKeyStats.ContainedPoints,
-			stats.RangeKeyStats.SkippedPoints)
+		s.SafeString(", ")
+		stats.RangeKeyStats.SafeFormat(s, verb)
 	}
 }
 
diff --git a/level_iter.go b/level_iter.go
index dceca63f..37a140ab 100644
--- a/level_iter.go
+++ b/level_iter.go
@@ -31,21 +31,13 @@ type internalIterOpts struct {
 //
 // levelIter is used during compaction and as part of the Iterator
 // implementation. When used as part of the Iterator implementation, level
-// iteration needs to "pause" at sstable boundaries if a range deletion iterator
-// is open. In this case, we materialize a "synthetic" InternalKV to return from
-// levelIter. This prevents mergingIter from advancing past the sstable until
-// the sstable contains the smallest (or largest for reverse iteration) key in
-// the merged heap. Note that mergingIter treats a range deletion tombstone
-// returned by the point iterator as a no-op.
-//
-// SeekPrefixGE presents the need for a second type of pausing. If an sstable
-// iterator returns "not found" for a SeekPrefixGE operation, we don't want to
-// advance to the next sstable as the "not found" does not indicate that all of
-// the keys in the sstable are less than the search key. Advancing to the next
-// sstable would cause us to skip over range tombstones, violating
-// correctness. Instead, SeekPrefixGE creates a synthetic boundary key with the
-// kind InternalKeyKindRangeDeletion which will be used to pause the levelIter
-// at the sstable until the mergingIter is ready to advance past it.
+// iteration needs to "pause" at range deletion boundaries if file contains
+// range deletions. In this case, the levelIter uses a keyspan.InterleavingIter
+// to materialize InternalKVs at start and end boundaries of range deletions.
+// This prevents mergingIter from advancing past the sstable until the sstable
+// contains the smallest (or largest for reverse iteration) key in the merged
+// heap. Note that mergingIter treats a range deletion tombstone returned by the
+// point iterator as a no-op.
 type levelIter struct {
 	// The context is stored here since (a) iterators are expected to be
 	// short-lived (since they pin sstables), (b) plumbing a context into every
@@ -60,24 +52,15 @@ type levelIter struct {
 	// recent call to SetBounds.
 	lower []byte
 	upper []byte
+	// prefix holds the iteration prefix when the most recent absolute
+	// positioning method was a SeekPrefixGE.
+	prefix []byte
 	// The iterator options for the currently open table. If
 	// tableOpts.{Lower,Upper}Bound are nil, the corresponding iteration boundary
 	// does not lie within the table bounds.
 	tableOpts IterOptions
 	// The LSM level this levelIter is initialized for.
 	level manifest.Level
-	// The keys to return when iterating past an sstable boundary and that
-	// boundary is a range deletion tombstone. The boundary could be smallest
-	// (i.e. arrived at with Prev), or largest (arrived at with Next).
-	smallestBoundary *base.InternalKV
-	largestBoundary  *base.InternalKV
-	// A synthetic boundary key-value pair to return when an sstable contains
-	// range tombstones that might be relevant but no more relevant point keys.
-	// The synthetic boundary key is always an exclusive range deletion sentinel
-	// key. When the user-imposed iteration bounds have been reached, the key's
-	// user key is the exceeded bound. Otherwise, it's the smallest/largest key
-	// in the file.
-	syntheticBoundary base.InternalKV
 	// combinedIterState may be set when a levelIter is used during user
 	// iteration. Although levelIter only iterates over point keys, it's also
 	// responsible for lazily constructing the combined range & point iterator
@@ -120,6 +103,12 @@ type levelIter struct {
 	// IterOptions.PointKeyFilters is declared.
 	filtersBuf [1]BlockPropertyFilter
 
+	// interleaving is used when rangeDelIterPtr != nil to interleave the
+	// boundaries of range deletions among point keys. This ensures that we
+	// don't advance to a new file until the range deletions are no longer
+	// needed by other levels.
+	interleaving keyspan.InterleavingIter
+
 	// exhaustedDir is set to +1 or -1 when the levelIter has been exhausted in
 	// the forward or backward direction respectively. It is set when the
 	// underlying data is exhausted or when iteration has reached the upper or
@@ -167,6 +156,7 @@ func (l *levelIter) init(
 	l.err = nil
 	l.level = level
 	l.logger = opts.getLogger()
+	l.prefix = nil
 	l.lower = opts.LowerBound
 	l.upper = opts.UpperBound
 	l.tableOpts.TableFilter = opts.TableFilter
@@ -196,31 +186,6 @@ func (l *levelIter) initCombinedIterState(state *combinedIterState) {
 	l.combinedIterState = state
 }
 
-// emitSyntheticBoundaries indicates whether or not the iterator should emit
-// synthetic boundary keys at table boundaries and user-imposed iteration bounds
-// (l.{lower,upper}) for the current file. This behavior is used by the
-// mergingIter specifically, when initRangeDel is called. See the comment in
-// makeSynehticBoundary.
-func (l *levelIter) emitSyntheticBoundaries() bool {
-	return l.rangeDelIterPtr != nil && *l.rangeDelIterPtr != nil
-}
-
-func (l *levelIter) makeSyntheticBoundary(userKey []byte) *base.InternalKV {
-	// We only emit synthetic boundaries when used by the merging iterator and
-	// the file has range deletions that must be observed by the merging
-	// iterator. If we're emitting a synthetic boundary but are not configured
-	// to surface range deletions or the file doesn't have range deletions,
-	// something is wrong.
-	if invariants.Enabled && !l.emitSyntheticBoundaries() {
-		panic(errors.AssertionFailedf("levelIter: emitting synthetic boundary unnecessarily; no range deletions"))
-	}
-	l.syntheticBoundary = base.InternalKV{
-		K: base.MakeRangeDeleteSentinelKey(userKey),
-		V: base.LazyValue{},
-	}
-	return &l.syntheticBoundary
-}
-
 func (l *levelIter) maybeTriggerCombinedIteration(file *fileMetadata, dir int) {
 	// If we encounter a file that contains range keys, we may need to
 	// trigger a switch to combined range-key and point-key iteration,
@@ -518,8 +483,6 @@ const (
 )
 
 func (l *levelIter) loadFile(file *fileMetadata, dir int) loadFileReturnIndicator {
-	l.smallestBoundary = nil
-	l.largestBoundary = nil
 	if l.iterFile == file {
 		if l.err != nil {
 			return noFileLoaded
@@ -603,9 +566,33 @@ func (l *levelIter) loadFile(file *fileMetadata, dir int) loadFileReturnIndicato
 			return noFileLoaded
 		}
 		l.iter = iters.Point()
-		if l.rangeDelIterPtr != nil {
+		if l.rangeDelIterPtr != nil && iters.rangeDeletion != nil {
 			*l.rangeDelIterPtr = iters.rangeDeletion
 			l.rangeDelIterCopy = iters.rangeDeletion
+
+			// If this file has range deletions, interleave the bounds of the
+			// range deletions among the point keys. When used with a
+			// mergingIter, this ensures we don't move beyond a file with range
+			// deletions until its range deletions are no longer relevant.
+			//
+			// For now, we open a second range deletion iterator. Future work
+			// will avoid the need to open a second range deletion iterator, and
+			// avoid surfacing the file's range deletion iterator directly to
+			// the caller.
+			var itersForBounds iterSet
+			itersForBounds, l.err = l.newIters(l.ctx, l.iterFile, &l.tableOpts, l.internalOpts, iterRangeDeletions)
+			l.interleaving.Init(l.comparer, l.iter, itersForBounds.RangeDeletion(), keyspan.InterleavingIterOpts{
+				LowerBound:        l.tableOpts.LowerBound,
+				UpperBound:        l.tableOpts.UpperBound,
+				InterleaveEndKeys: true,
+			})
+			if l.err != nil {
+				l.iter = nil
+				*l.rangeDelIterPtr = nil
+				l.err = errors.CombineErrors(l.err, iters.CloseAll())
+				return noFileLoaded
+			}
+			l.iter = &l.interleaving
 		}
 		return newFileLoaded
 	}
@@ -619,12 +606,12 @@ func (l *levelIter) verify(kv *base.InternalKV) *base.InternalKV {
 	// eligible for inlining. Do not change this to use a variable.
 	if invariants.Enabled && !l.disableInvariants && kv != nil {
 		// We allow returning a boundary key that is outside of the lower/upper
-		// bounds as such keys are always range tombstones which will be skipped by
-		// the Iterator.
-		if l.lower != nil && kv != l.smallestBoundary && l.cmp(kv.K.UserKey, l.lower) < 0 {
+		// bounds as such keys are always range tombstones which will be skipped
+		// by the Iterator.
+		if l.lower != nil && kv != nil && !kv.K.IsExclusiveSentinel() && l.cmp(kv.K.UserKey, l.lower) < 0 {
 			l.logger.Fatalf("levelIter %s: lower bound violation: %s < %s\n%s", l.level, kv, l.lower, debug.Stack())
 		}
-		if l.upper != nil && kv != l.largestBoundary && l.cmp(kv.K.UserKey, l.upper) > 0 {
+		if l.upper != nil && kv != nil && !kv.K.IsExclusiveSentinel() && l.cmp(kv.K.UserKey, l.upper) > 0 {
 			l.logger.Fatalf("levelIter %s: upper bound violation: %s > %s\n%s", l.level, kv, l.upper, debug.Stack())
 		}
 	}
@@ -638,11 +625,12 @@ func (l *levelIter) SeekGE(key []byte, flags base.SeekGEFlags) *base.InternalKV
 
 	l.err = nil // clear cached iteration error
 	l.exhaustedDir = 0
+	l.prefix = nil
 	// NB: the top-level Iterator has already adjusted key based on
 	// IterOptions.LowerBound.
 	loadFileIndicator := l.loadFile(l.findFileGE(key, flags), +1)
 	if loadFileIndicator == noFileLoaded {
-		l.exhaustedDir = +1
+		l.exhaustedForward()
 		return nil
 	}
 	if loadFileIndicator == newFileLoaded {
@@ -660,15 +648,15 @@ func (l *levelIter) SeekPrefixGE(prefix, key []byte, flags base.SeekGEFlags) *ba
 	if invariants.Enabled && l.lower != nil && l.cmp(key, l.lower) < 0 {
 		panic(errors.AssertionFailedf("levelIter SeekGE to key %q violates lower bound %q", key, l.lower))
 	}
-
 	l.err = nil // clear cached iteration error
 	l.exhaustedDir = 0
+	l.prefix = prefix
 
 	// NB: the top-level Iterator has already adjusted key based on
 	// IterOptions.LowerBound.
 	loadFileIndicator := l.loadFile(l.findFileGE(key, flags), +1)
 	if loadFileIndicator == noFileLoaded {
-		l.exhaustedDir = +1
+		l.exhaustedForward()
 		return nil
 	}
 	if loadFileIndicator == newFileLoaded {
@@ -682,35 +670,6 @@ func (l *levelIter) SeekPrefixGE(prefix, key []byte, flags base.SeekGEFlags) *ba
 	if err := l.iter.Error(); err != nil {
 		return nil
 	}
-	// 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 return
-	// the table's bound as a synthetic key.
-	if l.emitSyntheticBoundaries() {
-		if l.tableOpts.UpperBound != nil {
-			l.largestBoundary = l.makeSyntheticBoundary(l.tableOpts.UpperBound)
-			l.exhaustedDir = +1
-			return l.verify(l.largestBoundary)
-		}
-		// Return the file's largest bound, ensuring this file stays open until
-		// the mergingIter advances beyond the file's bounds.
-		l.largestBoundary = l.makeSyntheticBoundary(l.iterFile.LargestPointKey.UserKey)
-		return l.verify(l.largestBoundary)
-	}
-	// It is possible that we are here because bloom filter matching failed. In
-	// that case it is likely that all keys matching the prefix are wholly
-	// within the current file and cannot be in the subsequent file. In that
-	// case we don't want to go to the next file, since loading and seeking in
-	// there has some cost. Additionally, for sparse key spaces, loading the
-	// next file will defeat the optimization for the next SeekPrefixGE that is
-	// called with flags.TrySeekUsingNext(), since for sparse key spaces it is
-	// likely that the next key will also be contained in the current file.
-	n := l.split(l.iterFile.LargestPointKey.UserKey)
-	if l.cmp(prefix, l.iterFile.LargestPointKey.UserKey[:n]) < 0 {
-		l.exhaustedDir = +1
-		return nil
-	}
 	return l.verify(l.skipEmptyFileForward())
 }
 
@@ -721,11 +680,12 @@ func (l *levelIter) SeekLT(key []byte, flags base.SeekLTFlags) *base.InternalKV
 
 	l.err = nil // clear cached iteration error
 	l.exhaustedDir = 0
+	l.prefix = nil
 
 	// NB: the top-level Iterator has already adjusted key based on
 	// IterOptions.UpperBound.
 	if l.loadFile(l.findFileLT(key, flags), -1) == noFileLoaded {
-		l.exhaustedDir = -1
+		l.exhaustedBackward()
 		return nil
 	}
 	if kv := l.iter.SeekLT(key, flags); kv != nil {
@@ -741,11 +701,12 @@ func (l *levelIter) First() *base.InternalKV {
 
 	l.err = nil // clear cached iteration error
 	l.exhaustedDir = 0
+	l.prefix = nil
 
 	// NB: the top-level Iterator will call SeekGE if IterOptions.LowerBound is
 	// set.
 	if l.loadFile(l.files.First(), +1) == noFileLoaded {
-		l.exhaustedDir = +1
+		l.exhaustedForward()
 		return nil
 	}
 	if kv := l.iter.First(); kv != nil {
@@ -761,11 +722,12 @@ func (l *levelIter) Last() *base.InternalKV {
 
 	l.err = nil // clear cached iteration error
 	l.exhaustedDir = 0
+	l.prefix = nil
 
 	// NB: the top-level Iterator will call SeekLT if IterOptions.UpperBound is
 	// set.
 	if l.loadFile(l.files.Last(), -1) == noFileLoaded {
-		l.exhaustedDir = -1
+		l.exhaustedBackward()
 		return nil
 	}
 	if kv := l.iter.Last(); kv != nil {
@@ -784,37 +746,8 @@ func (l *levelIter) Next() *base.InternalKV {
 	if l.err != nil || l.iter == nil {
 		return nil
 	}
-
-	switch {
-	case l.largestBoundary != nil:
-		if l.tableOpts.UpperBound != nil {
-			// The UpperBound was within this file, so don't load the next
-			// file. We leave the largestBoundary unchanged so that subsequent
-			// calls to Next() stay at this file. If a Seek/First/Last call is
-			// made and this file continues to be relevant, loadFile() will
-			// set the largestBoundary to nil.
-			if l.rangeDelIterPtr != nil {
-				*l.rangeDelIterPtr = nil
-			}
-			l.exhaustedDir = +1
-			return nil
-		}
-		// We're stepping past the boundary key, so now we can load the next file.
-		if l.loadFile(l.files.Next(), +1) != noFileLoaded {
-			if kv := l.iter.First(); kv != nil {
-				return l.verify(kv)
-			}
-			return l.verify(l.skipEmptyFileForward())
-		}
-		l.exhaustedDir = +1
-		return nil
-
-	default:
-		// Reset the smallest boundary since we're moving away from it.
-		l.smallestBoundary = nil
-		if kv := l.iter.Next(); kv != nil {
-			return l.verify(kv)
-		}
+	if kv := l.iter.Next(); kv != nil {
+		return l.verify(kv)
 	}
 	return l.verify(l.skipEmptyFileForward())
 }
@@ -824,33 +757,16 @@ func (l *levelIter) NextPrefix(succKey []byte) *base.InternalKV {
 		return nil
 	}
 
-	switch {
-	case l.largestBoundary != nil:
-		if l.tableOpts.UpperBound != nil {
-			// The UpperBound was within this file, so don't load the next
-			// file. We leave the largestBoundary unchanged so that subsequent
-			// calls to Next() stay at this file. If a Seek/First/Last call is
-			// made and this file continues to be relevant, loadFile() will
-			// set the largestBoundary to nil.
-			if l.rangeDelIterPtr != nil {
-				*l.rangeDelIterPtr = nil
-			}
-			return nil
-		}
-		// We're stepping past the boundary key, so we need to load a later
-		// file.
-
-	default:
-		// Reset the smallest boundary since we're moving away from it.
-		l.smallestBoundary = nil
-
-		if kv := l.iter.NextPrefix(succKey); kv != nil {
-			return l.verify(kv)
-		}
-		if l.iter.Error() != nil {
-			return nil
-		}
-		// Fall through to seeking.
+	if kv := l.iter.NextPrefix(succKey); kv != nil {
+		return l.verify(kv)
+	}
+	if l.iter.Error() != nil {
+		return nil
+	}
+	if l.tableOpts.UpperBound != nil {
+		// The UpperBound was within this file, so don't load the next file.
+		l.exhaustedForward()
+		return nil
 	}
 
 	// Seek the manifest level iterator using TrySeekUsingNext=true and
@@ -865,7 +781,7 @@ func (l *levelIter) NextPrefix(succKey []byte) *base.InternalKV {
 		}
 		return l.verify(l.skipEmptyFileForward())
 	}
-	l.exhaustedDir = +1
+	l.exhaustedForward()
 	return nil
 }
 
@@ -879,105 +795,57 @@ func (l *levelIter) Prev() *base.InternalKV {
 	if l.err != nil || l.iter == nil {
 		return nil
 	}
-
-	switch {
-	case l.smallestBoundary != nil:
-		if l.tableOpts.LowerBound != nil {
-			// The LowerBound was within this file, so don't load the previous
-			// file. We leave the smallestBoundary unchanged so that
-			// subsequent calls to Prev() stay at this file. If a
-			// Seek/First/Last call is made and this file continues to be
-			// relevant, loadFile() will set the smallestBoundary to nil.
-			if l.rangeDelIterPtr != nil {
-				*l.rangeDelIterPtr = nil
-			}
-			l.exhaustedDir = -1
-			return nil
-		}
-		// We're stepping past the boundary key, so now we can load the prev file.
-		if l.loadFile(l.files.Prev(), -1) != noFileLoaded {
-			if kv := l.iter.Last(); kv != nil {
-				return l.verify(kv)
-			}
-			return l.verify(l.skipEmptyFileBackward())
-		}
-		l.exhaustedDir = -1
-		return nil
-
-	default:
-		// Reset the largest boundary since we're moving away from it.
-		l.largestBoundary = nil
-		if kv := l.iter.Prev(); kv != nil {
-			return l.verify(kv)
-		}
+	if kv := l.iter.Prev(); kv != nil {
+		return l.verify(kv)
 	}
 	return l.verify(l.skipEmptyFileBackward())
 }
 
 func (l *levelIter) skipEmptyFileForward() *base.InternalKV {
 	var kv *base.InternalKV
-	// The first iteration of this loop starts with an already exhausted
-	// l.iter. The reason for the exhaustion is either that we iterated to the
-	// end of the sstable, or our iteration was terminated early due to the
-	// presence of an upper-bound or the use of SeekPrefixGE. If
-	// l.rangeDelIterPtr is non-nil, we may need to pretend the iterator is
-	// not exhausted to allow for the merging to finish consuming the
-	// l.rangeDelIterPtr before levelIter switches the rangeDelIter from
-	// under it. This pretense is done by either generating a synthetic
-	// boundary key or returning the largest key of the file, depending on the
-	// exhaustion reason.
-
+	// The first iteration of this loop starts with an already exhausted l.iter.
+	// The reason for the exhaustion is either that we iterated to the end of
+	// the sstable, or our iteration was terminated early due to the presence of
+	// an upper-bound or the use of SeekPrefixGE.
+	//
 	// Subsequent iterations will examine consecutive files such that the first
 	// file that does not have an exhausted iterator causes the code to return
-	// that key, else the behavior described above if there is a corresponding
-	// rangeDelIterPtr.
+	// that key.
 	for ; kv == nil; kv = l.iter.First() {
 		if l.iter.Error() != nil {
 			return nil
 		}
-		if l.rangeDelIterPtr != nil {
-			// We're being used as part of a mergingIter and we've exhausted the
-			// current sstable. If an upper bound is present and the upper bound
-			// lies within the current sstable, then we will have reached the
-			// upper bound rather than the end of the sstable. We need to return
-			// a synthetic boundary key so that mergingIter can use the range
-			// tombstone iterator until the other levels have reached this
-			// boundary.
-			//
-			// It is safe to set the boundary key to the UpperBound user key
-			// with the RANGEDEL sentinel since it is the smallest InternalKey
-			// that matches the exclusive upper bound, and does not represent a
-			// real key.
-			if l.tableOpts.UpperBound != nil {
-				l.exhaustedDir = +1
-				if *l.rangeDelIterPtr != nil {
-					l.largestBoundary = l.makeSyntheticBoundary(l.tableOpts.UpperBound)
-					return l.largestBoundary
-				}
-				// Else there are no range deletions in this sstable. This
-				// helps with performance when many levels are populated with
-				// sstables and most don't have any actual keys within the
-				// bounds.
+		// If an upper bound is present and the upper bound lies within the
+		// current sstable, then we will have reached the upper bound rather
+		// than the end of the sstable.
+		if l.tableOpts.UpperBound != nil {
+			l.exhaustedForward()
+			return nil
+		}
+
+		// If the iterator is in prefix iteration mode, it's possible that we
+		// are here because bloom filter matching failed. In that case it is
+		// likely that all keys matching the prefix are wholly within the
+		// current file and cannot be in a subsequent file. In that case we
+		// don't want to go to the next file, since loading and seeking in there
+		// has some cost.
+		//
+		// This is not just an optimization. We must not advance to the next
+		// file if the current file might possibly contain keys relevant to any
+		// prefix greater than our current iteration prefix. If we did, a
+		// subsequent SeekPrefixGE with TrySeekUsingNext could mistakenly skip
+		// the file's relevant keys.
+		if l.prefix != nil {
+			fileLargestPrefix := l.iterFile.LargestPointKey.UserKey[:l.split(l.iterFile.LargestPointKey.UserKey)]
+			if l.cmp(fileLargestPrefix, l.prefix) > 0 {
+				l.exhaustedForward()
 				return nil
 			}
-			// If the caller is accessing range dels through l.rangeDelIterPtr,
-			// pause at an ignorable boundary key to avoid advancing to the next
-			// file until other levels are caught up.
-			//
-			// Note that even if the largest boundary is not a range deletion,
-			// there may still be range deletions beyond the last point key
-			// returned. When block-property filters are in use, the sstable
-			// iterator may have transparently skipped a tail of the point keys
-			// in the file.
-			if *l.rangeDelIterPtr != nil {
-				l.largestBoundary = l.makeSyntheticBoundary(l.iterFile.LargestPointKey.UserKey)
-				return l.largestBoundary
-			}
 		}
 
 		// Current file was exhausted. Move to the next file.
 		if l.loadFile(l.files.Next(), +1) == noFileLoaded {
-			l.exhaustedDir = +1
+			l.exhaustedForward()
 			return nil
 		}
 	}
@@ -989,65 +857,45 @@ func (l *levelIter) skipEmptyFileBackward() *base.InternalKV {
 	// The first iteration of this loop starts with an already exhausted
 	// l.iter. The reason for the exhaustion is either that we iterated to the
 	// end of the sstable, or our iteration was terminated early due to the
-	// presence of a lower-bound. If l.rangeDelIterPtr is non-nil, we may need
-	// to pretend the iterator is not exhausted to allow for the merging to
-	// finish consuming the l.rangeDelIterPtr before levelIter switches the
-	// rangeDelIter from under it. This pretense is done by either generating
-	// a synthetic boundary key or returning the smallest key of the file,
-	// depending on the exhaustion reason.
-
+	// presence of a lower-bound.
+	//
 	// Subsequent iterations will examine consecutive files such that the first
 	// file that does not have an exhausted iterator causes the code to return
-	// that key, else the behavior described above if there is a corresponding
-	// rangeDelIterPtr.
+	// that key.
 	for ; kv == nil; kv = l.iter.Last() {
 		if l.iter.Error() != nil {
 			return nil
 		}
-		if l.rangeDelIterPtr != nil {
-			// We're being used as part of a mergingIter and we've exhausted the
-			// current sstable. If a lower bound is present and the lower bound
-			// lies within the current sstable, then we will have reached the
-			// lower bound rather than the beginning of the sstable. We need to
-			// return a synthetic boundary key so that mergingIter can use the
-			// range tombstone iterator until the other levels have reached this
-			// boundary.
-			//
-			// It is safe to set the boundary key to the LowerBound user key
-			// with the RANGEDEL sentinel since it is the smallest InternalKey
-			// that is within the inclusive lower bound, and does not represent
-			// a real key.
-			if l.tableOpts.LowerBound != nil {
-				l.exhaustedDir = -1
-				if *l.rangeDelIterPtr != nil {
-					l.smallestBoundary = l.makeSyntheticBoundary(l.tableOpts.LowerBound)
-					return l.smallestBoundary
-				}
-				// Else there are no range deletions in this sstable. This
-				// helps with performance when many levels are populated with
-				// sstables and most don't have any actual keys within the
-				// bounds.
-				return nil
-			}
-			// If the user is using the range deletion iterator, return a
-			// synthetic key with the smallest user key in the file. This file
-			// sorts before all other keys with the same user key, so
-			// monotonicity is maintained.
-			if *l.rangeDelIterPtr != nil {
-				l.smallestBoundary = l.makeSyntheticBoundary(l.iterFile.SmallestPointKey.UserKey)
-				return l.smallestBoundary
-			}
+		// If a lower bound is present and the lower bound lies within the
+		// current sstable, then we will have reached the lowerr bound rather
+		// than the end of the sstable.
+		if l.tableOpts.LowerBound != nil {
+			l.exhaustedBackward()
+			return nil
 		}
-
 		// Current file was exhausted. Move to the previous file.
 		if l.loadFile(l.files.Prev(), -1) == noFileLoaded {
-			l.exhaustedDir = -1
+			l.exhaustedBackward()
 			return nil
 		}
 	}
 	return kv
 }
 
+func (l *levelIter) exhaustedForward() {
+	l.exhaustedDir = +1
+	if l.rangeDelIterPtr != nil {
+		*l.rangeDelIterPtr = nil
+	}
+}
+
+func (l *levelIter) exhaustedBackward() {
+	l.exhaustedDir = -1
+	if l.rangeDelIterPtr != nil {
+		*l.rangeDelIterPtr = nil
+	}
+}
+
 func (l *levelIter) Error() error {
 	if l.err != nil || l.iter == nil {
 		return l.err
diff --git a/merging_iter.go b/merging_iter.go
index 2e2792d8..e04df79c 100644
--- a/merging_iter.go
+++ b/merging_iter.go
@@ -1199,36 +1199,55 @@ func (m *mergingIter) NextPrefix(succKey []byte) *base.InternalKV {
 
 	// The heap root necessarily must be positioned at a key < succKey, because
 	// NextPrefix was invoked.
-	root := &m.heap.items[0]
-	m.levelsPositioned[(*root).index] = true
+	root := m.heap.items[0]
 	if invariants.Enabled && m.heap.cmp((*root).iterKV.K.UserKey, succKey) >= 0 {
 		m.logger.Fatalf("pebble: invariant violation: NextPrefix(%q) called on merging iterator already positioned at %q",
 			succKey, (*root).iterKV)
 	}
-	if m.err = m.nextEntry(*root, succKey); m.err != nil {
+	// NB: root is the heap root before we call nextEntry; nextEntry may change
+	// the heap root, so we must not `root` to still be the root of the heap, or
+	// even to be in the heap if the level's iterator becomes exhausted.
+	if m.err = m.nextEntry(root, succKey); m.err != nil {
 		return nil
 	}
-	// NB: root is a pointer to the heap root. nextEntry may have changed
-	// the heap root, so we must not expect root to still point to the same
-	// level (or to even be valid, if the heap is now exhaused).
+	// We only consider the level to be conclusively positioned at the next
+	// prefix if our call to nextEntry did not advance the level onto a range
+	// deletion's boundary. Range deletions may have bounds within the prefix
+	// that are still surfaced by NextPrefix.
+	m.levelsPositioned[root.index] = root.iterKV == nil || !root.iterKV.K.IsExclusiveSentinel()
 
 	for m.heap.len() > 0 {
-		if m.levelsPositioned[(*root).index] {
+		root := m.heap.items[0]
+		if m.levelsPositioned[root.index] {
 			// A level we've previously positioned is at the top of the heap, so
 			// there are no other levels positioned at keys < succKey. We've
 			// advanced as far as we need to.
 			break
 		}
+		// If the current heap root is a sentinel key, we need to skip it.
+		// Calling NextPrefix while positioned at a sentinel key is not
+		// supported.
+		if root.iterKV.K.IsExclusiveSentinel() {
+			if m.err = m.nextEntry(root, nil); m.err != nil {
+				return nil
+			}
+			continue
+		}
+
 		// Since this level was not the original heap root when NextPrefix was
 		// called, we don't know whether this level's current key has the
 		// previous prefix or a new one.
-		if m.heap.cmp((*root).iterKV.K.UserKey, succKey) >= 0 {
+		if m.heap.cmp(root.iterKV.K.UserKey, succKey) >= 0 {
 			break
 		}
-		m.levelsPositioned[(*root).index] = true
-		if m.err = m.nextEntry(*root, succKey); m.err != nil {
+		if m.err = m.nextEntry(root, succKey); m.err != nil {
 			return nil
 		}
+		// We only consider the level to be conclusively positioned at the next
+		// prefix if our call to nextEntry did not land onto a range deletion's
+		// boundary. Range deletions may have bounds within the prefix that are
+		// still surfaced by NextPrefix.
+		m.levelsPositioned[root.index] = root.iterKV == nil || !root.iterKV.K.IsExclusiveSentinel()
 	}
 	return m.findNextEntry()
 }
diff --git a/metamorphic/build.go b/metamorphic/build.go
index c4e232b6..623c6267 100644
--- a/metamorphic/build.go
+++ b/metamorphic/build.go
@@ -274,32 +274,6 @@ func openExternalObj(
 	return reader, pointIter, rangeDelIter, rangeKeyIter
 }
 
-// externalObjIsEmpty returns true if the given external object has no point or
-// range keys withing the given bounds.
-func externalObjIsEmpty(
-	t *Test, externalObjID objID, bounds pebble.KeyRange, syntheticPrefix sstable.SyntheticPrefix,
-) bool {
-	reader, pointIter, rangeDelIter, rangeKeyIter := openExternalObj(t, externalObjID, bounds, syntheticPrefix)
-	defer reader.Close()
-	defer closeIters(pointIter, rangeDelIter, rangeKeyIter)
-
-	kv := pointIter.First()
-	panicIfErr(pointIter.Error())
-	if kv != nil {
-		return false
-	}
-	for _, it := range []keyspan.FragmentIterator{rangeDelIter, rangeKeyIter} {
-		if it != nil {
-			span, err := it.First()
-			panicIfErr(err)
-			if span != nil {
-				return false
-			}
-		}
-	}
-	return true
-}
-
 func panicIfErr(err error) {
 	if err != nil {
 		panic(err)
diff --git a/metamorphic/ops.go b/metamorphic/ops.go
index 07299334..0db675fc 100644
--- a/metamorphic/ops.go
+++ b/metamorphic/ops.go
@@ -995,33 +995,12 @@ type externalObjWithBounds struct {
 func (o *ingestExternalFilesOp) run(t *Test, h historyRecorder) {
 	db := t.getDB(o.dbID)
 
-	// We modify objs to eliminate empty objects.
-	var objs []externalObjWithBounds
-	for _, obj := range o.objs {
-		// Make sure the object exists and is not empty.
-		objMeta := t.getExternalObj(obj.externalObjID)
-		if m := objMeta.sstMeta; !m.HasPointKeys && !m.HasRangeKeys && !m.HasRangeDelKeys {
-			continue
-		}
-		if externalObjIsEmpty(t, obj.externalObjID, obj.bounds, obj.syntheticPrefix) {
-			// Currently we don't support ingesting external objects that have no point
-			// or range keys in the given range. Filter out any such objects.
-			// TODO(radu): even though we don't expect this case in practice, eventually
-			// we want to make sure that it doesn't cause failures.
-			continue
-		}
-		objs = append(objs, obj)
-	}
-	if len(objs) == 0 {
-		h.Recordf("%s // no-op", o)
-		return
-	}
 	var err error
 	if !t.testOpts.externalStorageEnabled {
 		// Emulate the operation by crating local, truncated SST files and ingesting
 		// them.
 		var paths []string
-		for i, obj := range objs {
+		for i, obj := range o.objs {
 			// Make sure the object exists and is not empty.
 			path, sstMeta := buildForIngestExternalEmulation(
 				t, o.dbID, obj.externalObjID, obj.bounds, obj.syntheticSuffix, obj.syntheticPrefix, i,
@@ -1034,8 +1013,8 @@ func (o *ingestExternalFilesOp) run(t *Test, h historyRecorder) {
 			err = db.Ingest(paths)
 		}
 	} else {
-		external := make([]pebble.ExternalFile, len(objs))
-		for i, obj := range objs {
+		external := make([]pebble.ExternalFile, len(o.objs))
+		for i, obj := range o.objs {
 			meta := t.getExternalObj(obj.externalObjID)
 			external[i] = pebble.ExternalFile{
 				Locator:           "external",
diff --git a/metamorphic/options.go b/metamorphic/options.go
index 3bc80f6e..f11158a3 100644
--- a/metamorphic/options.go
+++ b/metamorphic/options.go
@@ -495,7 +495,7 @@ func standardOptions() []*TestOptions {
 `,
 		15: `
 [Level "0"]
-  target_file_size=1
+  target_file_size=12
 `,
 		16: `
 [Level "0"]
@@ -656,13 +656,17 @@ func RandomOptions(
 		scaleDuration := func(d time.Duration, minFactor, maxFactor float64) time.Duration {
 			return time.Duration(float64(d) * (minFactor + rng.Float64()*(maxFactor-minFactor)))
 		}
+		probeInterval := scaleDuration(referenceDur, 0.10, 0.50) // Between 10-50% of the reference duration.
 		unhealthyThreshold := expRandDuration(rng, 3*referenceDur, time.Second)
 		healthyThreshold := expRandDuration(rng, 3*referenceDur, time.Second)
-		healthyInterval := scaleDuration(healthyThreshold, 1.0, 10.0) // Between 1-10x the healthy threshold
+		// Use a healthy interval between 1-119x the probe interval. The probe
+		// maintains a maximum history 120 entries, so the healthy interval
+		// must not exceed 119x the probe interval.
+		healthyInterval := scaleDuration(probeInterval, 1.0, 119.0)
 		opts.WALFailover = &pebble.WALFailoverOptions{
 			Secondary: wal.Dir{FS: vfs.Default, Dirname: "data/wal_secondary"},
 			FailoverOptions: wal.FailoverOptions{
-				PrimaryDirProbeInterval:      scaleDuration(healthyThreshold, 0.10, 0.50), // Between 10-50% of the healthy threshold
+				PrimaryDirProbeInterval:      probeInterval,
 				HealthyProbeLatencyThreshold: healthyThreshold,
 				HealthyInterval:              healthyInterval,
 				UnhealthySamplingInterval:    scaleDuration(unhealthyThreshold, 0.10, 0.50), // Between 10-50% of the unhealthy threshold
@@ -706,6 +710,9 @@ func RandomOptions(
 	lopts.BlockSizeThreshold = 50 + rng.Intn(50)   // 50 - 100
 	lopts.IndexBlockSize = 1 << uint(rng.Intn(24)) // 1 - 16MB
 	lopts.TargetFileSize = 1 << uint(rng.Intn(28)) // 1 - 256MB
+	// The EstimatedSize of an empty table writer is 8 bytes. We want something a
+	// little bigger than that as the minimum target.
+	lopts.TargetFileSize = max(lopts.TargetFileSize, 12)
 
 	// We either use no bloom filter, the default filter, or a filter with
 	// randomized bits-per-key setting. We zero out the Filters map. It'll get
diff --git a/metrics.go b/metrics.go
index 8b9ff258..a19bf2b9 100644
--- a/metrics.go
+++ b/metrics.go
@@ -157,6 +157,7 @@ type Metrics struct {
 		DefaultCount      int64
 		DeleteOnlyCount   int64
 		ElisionOnlyCount  int64
+		CopyCount         int64
 		MoveCount         int64
 		ReadCount         int64
 		RewriteCount      int64
@@ -569,13 +570,14 @@ func (m *Metrics) SafeFormat(w redact.SafePrinter, _ rune) {
 		redact.Safe(m.Compact.NumInProgress),
 		humanize.Bytes.Int64(m.Compact.InProgressBytes))
 
-	w.Printf("             default: %d  delete: %d  elision: %d  move: %d  read: %d  rewrite: %d  multi-level: %d\n",
+	w.Printf("             default: %d  delete: %d  elision: %d  move: %d  read: %d  rewrite: %d  copy: %d  multi-level: %d\n",
 		redact.Safe(m.Compact.DefaultCount),
 		redact.Safe(m.Compact.DeleteOnlyCount),
 		redact.Safe(m.Compact.ElisionOnlyCount),
 		redact.Safe(m.Compact.MoveCount),
 		redact.Safe(m.Compact.ReadCount),
 		redact.Safe(m.Compact.RewriteCount),
+		redact.Safe(m.Compact.CopyCount),
 		redact.Safe(m.Compact.MultiLevelCount))
 
 	w.Printf("MemTables: %d (%s)  zombie: %d (%s)\n",
diff --git a/objstorage/objstorageprovider/readahead.go b/objstorage/objstorageprovider/readahead.go
index e07017c9..e29155d8 100644
--- a/objstorage/objstorageprovider/readahead.go
+++ b/objstorage/objstorageprovider/readahead.go
@@ -40,40 +40,7 @@ func makeReadaheadState(maxReadaheadSize int64) readaheadState {
 }
 
 func (rs *readaheadState) recordCacheHit(offset, blockLength int64) {
-	currentReadEnd := offset + blockLength
-	if rs.numReads >= minFileReadsForReadahead {
-		if currentReadEnd >= rs.limit && offset <= rs.limit+rs.maxReadaheadSize {
-			// This is a read that would have resulted in a readahead, had it
-			// not been a cache hit.
-			rs.limit = currentReadEnd
-			return
-		}
-		if currentReadEnd < rs.limit-rs.prevSize || offset > rs.limit+rs.maxReadaheadSize {
-			// We read too far away from rs.limit to benefit from readahead in
-			// any scenario. Reset all variables.
-			rs.numReads = 1
-			rs.limit = currentReadEnd
-			rs.size = initialReadaheadSize
-			rs.prevSize = 0
-			return
-		}
-		// Reads in the range [rs.limit - rs.prevSize, rs.limit] end up
-		// here. This is a read that is potentially benefitting from a past
-		// readahead.
-		return
-	}
-	if currentReadEnd >= rs.limit && offset <= rs.limit+rs.maxReadaheadSize {
-		// Blocks are being read sequentially and would benefit from readahead
-		// down the line.
-		rs.numReads++
-		return
-	}
-	// We read too far ahead of the last read, or before it. This indicates
-	// a random read, where readahead is not desirable. Reset all variables.
-	rs.numReads = 1
-	rs.limit = currentReadEnd
-	rs.size = initialReadaheadSize
-	rs.prevSize = 0
+	_ = rs.maybeReadaheadOrCacheHit(offset, blockLength, false)
 }
 
 // maybeReadahead updates state and determines whether to issue a readahead /
@@ -81,6 +48,13 @@ func (rs *readaheadState) recordCacheHit(offset, blockLength int64) {
 // Returns a size value (greater than 0) that should be prefetched if readahead
 // would be beneficial.
 func (rs *readaheadState) maybeReadahead(offset, blockLength int64) int64 {
+	return rs.maybeReadaheadOrCacheHit(offset, blockLength, true)
+}
+
+// The return value should be ignored if !readahead.
+func (rs *readaheadState) maybeReadaheadOrCacheHit(
+	offset, blockLength int64, readahead bool,
+) int64 {
 	currentReadEnd := offset + blockLength
 	if rs.numReads >= minFileReadsForReadahead {
 		// The minimum threshold of sequential reads to justify reading ahead
@@ -119,18 +93,22 @@ func (rs *readaheadState) maybeReadahead(offset, blockLength int64) int64 {
 			//
 			//
 			rs.numReads++
-			rs.limit = offset + rs.size
-			rs.prevSize = rs.size
-			// Increase rs.size for the next read.
-			rs.size *= 2
-			if rs.size > rs.maxReadaheadSize {
-				rs.size = rs.maxReadaheadSize
+			if readahead {
+				rs.limit = offset + rs.size
+				rs.prevSize = rs.size
+				// Increase rs.size for the next read.
+				rs.size *= 2
+				if rs.size > rs.maxReadaheadSize {
+					rs.size = rs.maxReadaheadSize
+				}
+			} else {
+				// This is a read that would have resulted in a readahead, had it
+				// not been a cache hit.
+				rs.limit = currentReadEnd
 			}
 			return rs.prevSize
 		}
 		if currentReadEnd < rs.limit-rs.prevSize || offset > rs.limit+rs.maxReadaheadSize {
-			// The above conditional has rs.limit > rs.prevSize to confirm that
-			// rs.limit - rs.prevSize would not underflow.
 			// We read too far away from rs.limit to benefit from readahead in
 			// any scenario. Reset all variables.
 			// The case where we read too far ahead:
@@ -156,7 +134,16 @@ func (rs *readaheadState) maybeReadahead(offset, blockLength int64) int64 {
 
 			return 0
 		}
-		// Reads in the range [rs.limit - rs.prevSize, rs.limit] end up
+		// The previous if-block predicates were all false. This mechanically implies:
+		//
+		// INVARIANT:
+		//  !(currentReadEnd >= rs.limit && offset <= rs.limit+rs.maxReadaheadSize) &&
+		//  !(currentReadEnd < rs.limit-rs.prevSize || offset > rs.limit+rs.maxReadaheadSize)
+		// Which mechanically simplifies to:
+		//  currentReadEnd < rs.limit  && currentReadEnd >= rs.limit-rs.prevSize &&
+		//  offset <= rs.limit+rs.maxReadaheadSize
+		//
+		// So reads in the range [rs.limit - rs.prevSize, rs.limit] end up
 		// here. This is a read that is potentially benefitting from a past
 		// readahead, but there's no reason to issue a readahead call at the
 		// moment.
@@ -171,6 +158,8 @@ func (rs *readaheadState) maybeReadahead(offset, blockLength int64) int64 {
 		rs.numReads++
 		return 0
 	}
+	// Not yet at the numReads threshold to justify readahead. But we want to
+	// capture whether readahead will be beneficial in the future.
 	if currentReadEnd >= rs.limit && offset <= rs.limit+rs.maxReadaheadSize {
 		// Blocks are being read sequentially and would benefit from readahead
 		// down the line.
@@ -182,6 +171,16 @@ func (rs *readaheadState) maybeReadahead(offset, blockLength int64) int64 {
 		//                offset    currentReadEnd
 		//
 		rs.numReads++
+		// It is possible to fall here when rs.limit has not been initialized. If
+		// we don't initialize, rs.limit, it is possible that the first read
+		// offset was at rs.limit+rs.maxReadaheadSize-delta and the enclosing
+		// if-block predicate was true, and the next read is sequential but has
+		// offset > rs.limit+rs.maxReadaheadSize (if we left rs.limit at 0), and
+		// the enclosing if-block predicate will be false and we will incorrectly
+		// think that readahead is not beneficial. The same issue arises if
+		// rs.limit has been initialized and currentReadEnd is advancing past
+		// rs.limit.
+		rs.limit = currentReadEnd
 		return 0
 	}
 	// We read too far ahead of the last read, or before it. This indicates
diff --git a/open.go b/open.go
index 21336ac9..cab6196e 100644
--- a/open.go
+++ b/open.go
@@ -341,7 +341,7 @@ func Open(dirname string, opts *Options) (db *DB, err error) {
 	// sequence number of the first batch that will be inserted.
 	if !d.opts.ReadOnly {
 		var entry *flushableEntry
-		d.mu.mem.mutable, entry = d.newMemTable(0 /* logNum */, d.mu.versions.logSeqNum.Load())
+		d.mu.mem.mutable, entry = d.newMemTable(0 /* logNum */, d.mu.versions.logSeqNum.Load(), 0 /* minSize */)
 		d.mu.mem.queue = append(d.mu.mem.queue, entry)
 	}
 
@@ -808,7 +808,7 @@ func (d *DB) replayWAL(
 		if mem != nil {
 			return
 		}
-		mem, entry = d.newMemTable(base.DiskFileNum(ll.Num), seqNum)
+		mem, entry = d.newMemTable(base.DiskFileNum(ll.Num), seqNum, 0 /* minSize */)
 		if d.opts.ReadOnly {
 			d.mu.mem.mutable = mem
 			d.mu.mem.queue = append(d.mu.mem.queue, entry)
diff --git a/options.go b/options.go
index 20166612..545d561e 100644
--- a/options.go
+++ b/options.go
@@ -682,10 +682,6 @@ type Options struct {
 		// major version is at least `FormatFlushableIngest`.
 		DisableIngestAsFlushable func() bool
 
-		// CheckExternalIngestions enables opening external ssts at ingest time and
-		// validating that they are not empty. Used for testing/debugging.
-		CheckExternalIngestions bool
-
 		// RemoteStorage enables use of remote storage (e.g. S3) for storing
 		// sstables. Setting this option enables use of CreateOnShared option and
 		// allows ingestion of external files.
diff --git a/replay/replay.go b/replay/replay.go
index b6f6034f..4040a7d6 100644
--- a/replay/replay.go
+++ b/replay/replay.go
@@ -104,6 +104,7 @@ type Metrics struct {
 		Move        int64
 		Read        int64
 		Rewrite     int64
+		Copy        int64
 		MultiLevel  int64
 	}
 	EstimatedDebt SampledMetric
@@ -169,6 +170,7 @@ func (m *Metrics) WriteBenchmarkString(name string, w io.Writer) error {
 			{Value: float64(m.CompactionCounts.Move), Unit: "move"},
 			{Value: float64(m.CompactionCounts.Read), Unit: "read"},
 			{Value: float64(m.CompactionCounts.Rewrite), Unit: "rewrite"},
+			{Value: float64(m.CompactionCounts.Copy), Unit: "copy"},
 			{Value: float64(m.CompactionCounts.MultiLevel), Unit: "multilevel"},
 		}},
 		// Total database sizes sampled after every workload step and
@@ -555,6 +557,7 @@ func (r *Runner) Wait() (Metrics, error) {
 	m.CompactionCounts.Move = pm.Compact.MoveCount
 	m.CompactionCounts.Read = pm.Compact.ReadCount
 	m.CompactionCounts.Rewrite = pm.Compact.RewriteCount
+	m.CompactionCounts.Copy = pm.Compact.CopyCount
 	m.CompactionCounts.MultiLevel = pm.Compact.MultiLevelCount
 	m.Ingest.BytesIntoL0 = pm.Levels[0].BytesIngested
 	m.Ingest.BytesWeightedByLevel = ingestBytesWeighted
diff --git a/sstable/copier.go b/sstable/copier.go
index 96fe3cb8..207b427f 100644
--- a/sstable/copier.go
+++ b/sstable/copier.go
@@ -31,7 +31,10 @@ import (
 //
 // The resulting sstable will have no block properties.
 //
-// Closes input and finishes or aborts output, including on non-nil errors.
+// The function might return ErrEmptySpan if there are no blocks that could
+// include keys in the given range. See ErrEmptySpan for more details.
+//
+// Closes input and finishes or aborts output in all cases, including on errors.
 //
 // Note that CopySpan is not aware of any suffix or prefix replacement; the
 // caller must account for those when specifying the bounds.
@@ -81,7 +84,8 @@ func CopySpan(
 	}()
 
 	if r.Properties.NumValueBlocks > 0 || r.Properties.NumRangeKeys() > 0 || r.Properties.NumRangeDeletions > 0 {
-		return 0, errors.New("cannot CopySpan sstables with value blocks or range keys")
+		// We just checked for these conditions above.
+		return 0, base.AssertionFailedf("cannot CopySpan sstables with value blocks or range keys")
 	}
 
 	// Set the filter block to be copied over if it exists. It will return false
@@ -128,7 +132,7 @@ func CopySpan(
 	// a non-empty sst by copying something outside the span, but #3907 means that
 	// the empty virtual span would still be a problem, so don't bother.
 	if len(blocks) < 1 {
-		return 0, errors.Newf("CopySpan cannot copy empty span %s %s", start, end)
+		return 0, ErrEmptySpan
 	}
 
 	// Find the span of the input file that contains all our blocks, and then copy
@@ -171,6 +175,15 @@ func CopySpan(
 	return wrote, nil
 }
 
+// ErrEmptySpan is returned by CopySpan if the input sstable has no keys in the
+// requested span.
+//
+// Note that CopySpan's determination of block overlap is best effort - we may
+// copy a block that doesn't actually contain any keys in the span, in which
+// case we won't generate this error. We currently only generate this error when
+// the span start is beyond all keys in the physical sstable.
+var ErrEmptySpan = errors.New("cannot copy empty span")
+
 // indexEntry captures the two components of an sst index entry: the key and the
 // decoded block handle value.
 type indexEntry struct {
diff --git a/tool/db.go b/tool/db.go
index 5909d641..184ceaf8 100644
--- a/tool/db.go
+++ b/tool/db.go
@@ -66,6 +66,7 @@ type dbT struct {
 	ioSizes       string
 	verbose       bool
 	bypassPrompt  bool
+	lsmURL        bool
 }
 
 func newDB(
@@ -210,6 +211,9 @@ specified database.
 			&d.fmtValue, "value", "value formatter")
 	}
 
+	d.LSM.Flags().BoolVar(
+		&d.lsmURL, "url", false, "generate LSM viewer URL")
+
 	d.Space.Flags().Var(
 		&d.start, "start", "start key for the range")
 	d.Space.Flags().Var(
@@ -445,6 +449,9 @@ func (d *dbT) runLSM(cmd *cobra.Command, args []string) {
 	defer d.closeDB(stderr, db)
 
 	fmt.Fprintf(stdout, "%s", db.Metrics())
+	if d.lsmURL {
+		fmt.Fprintf(stdout, "\nLSM viewer: %s\n", db.LSMViewURL())
+	}
 }
 
 func (d *dbT) runScan(cmd *cobra.Command, args []string) {
diff --git a/tool/manifest.go b/tool/manifest.go
index a4cf0a22..c9ba098e 100644
--- a/tool/manifest.go
+++ b/tool/manifest.go
@@ -11,6 +11,7 @@ import (
 	"slices"
 	"time"
 
+	"github.com/HdrHistogram/hdrhistogram-go"
 	"github.com/cockroachdb/pebble"
 	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/internal/humanize"
@@ -309,7 +310,12 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 	type summaryBucket struct {
 		bytesAdded      [manifest.NumLevels]uint64
 		bytesCompactOut [manifest.NumLevels]uint64
+		bytesCompactIn  [manifest.NumLevels]uint64
+		filesCompactIn  [manifest.NumLevels]uint64
+		fileLifetimeSec [manifest.NumLevels]*hdrhistogram.Histogram
 	}
+	// 365 days. Arbitrary.
+	const maxLifetimeSec = 365 * 24 * 60 * 60
 	var (
 		bve           manifest.BulkVersionEdit
 		newestOverall time.Time
@@ -319,6 +325,7 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 	)
 	bve.AddedByFileNum = make(map[base.FileNum]*manifest.FileMetadata)
 	rr := record.NewReader(f, 0 /* logNum */)
+	numHistErrors := 0
 	for i := 0; ; i++ {
 		r, err := rr.Next()
 		if err == io.EOF {
@@ -336,9 +343,27 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 			return err
 		}
 
-		veNewest, veOldest := newestOverall, newestOverall
+		// !isLikelyCompaction corresponds to flushes or ingests, that will be
+		// counted in bytesAdded. This is imperfect since ingests that excise can
+		// have deleted files without creating backing tables, and be counted as
+		// compactions. Also, copy compactions have no deleted files and create
+		// backing tables, so will be counted as flush/ingest.
+		//
+		// The bytesAdded metric overcounts since existing files virtualized by an
+		// ingest are also included.
+		//
+		// TODO(sumeer): this summarization needs a rewrite. We could do that
+		// after adding an enum to the VersionEdit to aid the summarization.
+		isLikelyCompaction := len(ve.NewFiles) > 0 && len(ve.DeletedFiles) > 0 && len(ve.CreatedBackingTables) == 0
+		isIntraL0Compaction := isLikelyCompaction && ve.NewFiles[0].Level == 0
+		veNewest := newestOverall
 		for _, nf := range ve.NewFiles {
 			_, seen := metadatas[nf.Meta.FileNum]
+			if seen && !isLikelyCompaction {
+				// Output error and continue processing as usual.
+				fmt.Fprintf(stdout, "error: flush/ingest has file that is already known %d size %s\n",
+					nf.Meta.FileNum, humanize.Bytes.Uint64(nf.Meta.Size))
+			}
 			metadatas[nf.Meta.FileNum] = nf.Meta
 			if nf.Meta.CreationTime == 0 {
 				continue
@@ -348,12 +373,6 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 			if veNewest.Before(t) {
 				veNewest = t
 			}
-			// Only update the oldest if we haven't already seen this
-			// file; it might've been moved in which case the sstable's
-			// creation time is from when it was originally created.
-			if veOldest.After(t) && !seen {
-				veOldest = t
-			}
 		}
 		// Ratchet up the most recent timestamp we've seen.
 		if newestOverall.Before(veNewest) {
@@ -377,26 +396,36 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 			buckets[bucketKey] = b
 		}
 
-		// Increase `bytesAdded` for any version edits that only add files.
-		// These are either flushes or ingests.
-		if len(ve.NewFiles) > 0 && len(ve.DeletedFiles) == 0 {
-			for _, nf := range ve.NewFiles {
+		for _, nf := range ve.NewFiles {
+			if !isLikelyCompaction {
 				b.bytesAdded[nf.Level] += nf.Meta.Size
+			} else if !isIntraL0Compaction {
+				b.bytesCompactIn[nf.Level] += nf.Meta.Size
+				b.filesCompactIn[nf.Level]++
 			}
-			continue
 		}
 
-		// Increase `bytesCompactOut` for the input level of any compactions
-		// that remove bytes from a level (excluding intra-L0 compactions).
-		// compactions.
-		destLevel := -1
-		if len(ve.NewFiles) > 0 {
-			destLevel = ve.NewFiles[0].Level
-		}
 		for dfe := range ve.DeletedFiles {
-			if dfe.Level != destLevel {
+			// Increase `bytesCompactOut` for the input level of any compactions
+			// that remove bytes from a level (excluding intra-L0 compactions).
+			if isLikelyCompaction && !isIntraL0Compaction && dfe.Level != manifest.NumLevels-1 {
 				b.bytesCompactOut[dfe.Level] += metadatas[dfe.FileNum].Size
 			}
+			meta, ok := metadatas[dfe.FileNum]
+			if m.verbose && ok && meta.CreationTime > 0 {
+				hist := b.fileLifetimeSec[dfe.Level]
+				if hist == nil {
+					hist = hdrhistogram.New(0, maxLifetimeSec, 1)
+					b.fileLifetimeSec[dfe.Level] = hist
+				}
+				lifetimeSec := int64((newestOverall.Sub(time.Unix(meta.CreationTime, 0).UTC())) / time.Second)
+				if lifetimeSec > maxLifetimeSec {
+					lifetimeSec = maxLifetimeSec
+				}
+				if err := hist.RecordValue(lifetimeSec); err != nil {
+					numHistErrors++
+				}
+			}
 		}
 	}
 
@@ -406,7 +435,7 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 		}
 		return humanize.Bytes.Uint64(v).String()
 	}
-	formatRate := func(v uint64, dur time.Duration) string {
+	formatByteRate := func(v uint64, dur time.Duration) string {
 		if v == 0 {
 			return "."
 		}
@@ -416,6 +445,16 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 		}
 		return humanize.Bytes.Uint64(uint64(float64(v)/secs)).String() + "/s"
 	}
+	formatRate := func(v uint64, dur time.Duration) string {
+		if v == 0 {
+			return "."
+		}
+		secs := dur.Seconds()
+		if secs == 0 {
+			secs = 1
+		}
+		return fmt.Sprintf("%.1f/s", float64(v)/secs)
+	}
 
 	if newestOverall.IsZero() {
 		fmt.Fprintf(stdout, "(no timestamps)\n")
@@ -432,7 +471,7 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 			}
 
 			if bi%10 == 0 {
-				fmt.Fprintf(stdout, "                     ")
+				fmt.Fprintf(stdout, "                        ")
 				fmt.Fprintf(stdout, "_______L0_______L1_______L2_______L3_______L4_______L5_______L6_____TOTAL\n")
 			}
 			fmt.Fprintf(stdout, "%s\n", bt.Format(time.RFC3339))
@@ -450,17 +489,19 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 				format func(uint64, time.Duration) string
 				vals   [manifest.NumLevels]uint64
 			}{
-				{"Ingest+Flush", formatUint64, bucket.bytesAdded},
-				{"Ingest+Flush", formatRate, bucket.bytesAdded},
-				{"Compact (out)", formatUint64, bucket.bytesCompactOut},
-				{"Compact (out)", formatRate, bucket.bytesCompactOut},
+				{"Ingest+Flush Bytes", formatUint64, bucket.bytesAdded},
+				{"Ingest+Flush Bytes/s", formatByteRate, bucket.bytesAdded},
+				{"Compact Out Bytes", formatUint64, bucket.bytesCompactOut},
+				{"Compact Out Bytes/s", formatByteRate, bucket.bytesCompactOut},
+				{"Compact In Bytes/s", formatByteRate, bucket.bytesCompactIn},
+				{"Compact In Files/s", formatRate, bucket.filesCompactIn},
 			}
 			for _, stat := range stats {
 				var sum uint64
 				for _, v := range stat.vals {
 					sum += v
 				}
-				fmt.Fprintf(stdout, "%20s   %8s %8s %8s %8s %8s %8s %8s %8s\n",
+				fmt.Fprintf(stdout, "%23s   %8s %8s %8s %8s %8s %8s %8s %8s\n",
 					stat.label,
 					stat.format(stat.vals[0], dur),
 					stat.format(stat.vals[1], dur),
@@ -473,6 +514,36 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 			}
 		}
 		fmt.Fprintf(stdout, "%s\n", newestOverall.Format(time.RFC3339))
+
+		formatSec := func(sec int64) string {
+			return (time.Second * time.Duration(sec)).String()
+		}
+		if m.verbose {
+			fmt.Fprintf(stdout, "\nLifetime histograms\n")
+			for bi, bt := 0, oldestOverall; !bt.After(newestOverall); bi, bt = bi+1, bt.Truncate(m.summarizeDur).Add(m.summarizeDur) {
+				// Truncate the start time to calculate the bucket key, and
+				// retrieve the appropriate bucket.
+				bk := bt.Truncate(m.summarizeDur)
+				var bucket summaryBucket
+				if buckets[bk] != nil {
+					bucket = *buckets[bk]
+				}
+				fmt.Fprintf(stdout, "%s\n", bt.Format(time.RFC3339))
+				formatHist := func(level int, hist *hdrhistogram.Histogram) {
+					if hist == nil {
+						return
+					}
+					fmt.Fprintf(stdout, "  L%d: mean: %s p25: %s p50: %s p75: %s p90: %s\n", level,
+						formatSec(int64(hist.Mean())), formatSec(hist.ValueAtPercentile(25)),
+						formatSec(hist.ValueAtPercentile(50)), formatSec(hist.ValueAtPercentile(75)),
+						formatSec(hist.ValueAtPercentile(90)))
+				}
+				for i := range bucket.fileLifetimeSec {
+					formatHist(i, bucket.fileLifetimeSec[i])
+				}
+			}
+			fmt.Fprintf(stdout, "%s\n", newestOverall.Format(time.RFC3339))
+		}
 	}
 
 	dur := newestOverall.Sub(oldestOverall)
@@ -480,6 +551,9 @@ func (m *manifestT) runSummarizeOne(stdout io.Writer, arg string) error {
 	fmt.Fprintf(stdout, "Estimated start time: %s\n", oldestOverall.Format(time.RFC3339))
 	fmt.Fprintf(stdout, "Estimated end time:   %s\n", newestOverall.Format(time.RFC3339))
 	fmt.Fprintf(stdout, "Estimated duration:   %s\n", dur.String())
+	if numHistErrors > 0 {
+		fmt.Fprintf(stdout, "Errors in lifetime histograms: %d\n", numHistErrors)
+	}
 
 	return nil
 }
diff --git a/version_set.go b/version_set.go
index b3f17198..5f7130ed 100644
--- a/version_set.go
+++ b/version_set.go
@@ -845,6 +845,15 @@ func (vs *versionSet) incrementCompactions(
 	case compactionKindRewrite:
 		vs.metrics.Compact.Count++
 		vs.metrics.Compact.RewriteCount++
+
+	case compactionKindCopy:
+		vs.metrics.Compact.Count++
+		vs.metrics.Compact.CopyCount++
+
+	default:
+		if invariants.Enabled {
+			panic("unhandled compaction kind")
+		}
 	}
 	if len(extraLevels) > 0 {
 		vs.metrics.Compact.MultiLevelCount++


Test patch:
diff --git a/data_test.go b/data_test.go
index 3ee315c1..5ea1ba66 100644
--- a/data_test.go
+++ b/data_test.go
@@ -842,6 +842,16 @@ func runDBDefineCmdReuseFS(td *datadriven.TestData, opts *Options) (*DB, error)
 			for _, levelOpts := range opts.Levels {
 				levelOpts.BlockSize = size
 			}
+		case "bloom-bits-per-key":
+			v, err := strconv.Atoi(arg.Vals[0])
+			if err != nil {
+				return nil, err
+			}
+			fp := bloom.FilterPolicy(v)
+			opts.Filters = map[string]FilterPolicy{fp.Name(): fp}
+			for i := range opts.Levels {
+				opts.Levels[i].FilterPolicy = fp
+			}
 		case "format-major-version":
 			fmv, err := strconv.Atoi(arg.Vals[0])
 			if err != nil {
diff --git a/internal/metamorphic/meta_test.go b/internal/metamorphic/meta_test.go
index a5d5da1f..ad2a51e8 100644
--- a/internal/metamorphic/meta_test.go
+++ b/internal/metamorphic/meta_test.go
@@ -67,7 +67,7 @@ func runTestMeta(t *testing.T, multiInstance bool) {
 		}
 		testRootDir, runSubdirs := runOnceFlags.ParseCompare()
 		if runOnceFlags.TryToReduce {
-			tryToReduceCompare(t, runOnceFlags.Dir, testRootDir, runSubdirs)
+			tryToReduceCompare(t, runOnceFlags.Dir, testRootDir, runSubdirs, runOnceFlags.ReduceAttempts)
 			return
 		}
 		metamorphic.Compare(t, testRootDir, runOnceFlags.Seed, runSubdirs, onceOpts...)
@@ -81,7 +81,7 @@ func runTestMeta(t *testing.T, multiInstance bool) {
 			onceOpts = append(onceOpts, metamorphic.MultiInstance(2))
 		}
 		if runOnceFlags.TryToReduce {
-			tryToReduce(t, runOnceFlags.Dir, runOnceFlags.RunDir)
+			tryToReduce(t, runOnceFlags.Dir, runOnceFlags.RunDir, runOnceFlags.ReduceAttempts)
 			return
 		}
 		metamorphic.RunOnce(t, runOnceFlags.RunDir, runOnceFlags.Seed, filepath.Join(runOnceFlags.RunDir, "history"), onceOpts...)
diff --git a/internal/metamorphic/reduce_test.go b/internal/metamorphic/reduce_test.go
index 5e54ae1f..2addab6d 100644
--- a/internal/metamorphic/reduce_test.go
+++ b/internal/metamorphic/reduce_test.go
@@ -32,10 +32,10 @@ import (
 //
 // The test will save the smallest reproduction found and print out the relevant
 // information.
-func tryToReduce(t *testing.T, testStateDir string, runDir string) {
+func tryToReduce(t *testing.T, testStateDir string, runDir string, reduceAttempts int) {
 	testRootDir := filepath.Dir(runDir)
 	runSubdir := filepath.Base(runDir)
-	r := makeReducer(t, testStateDir, testRootDir, []string{runSubdir})
+	r := makeReducer(t, testStateDir, testRootDir, []string{runSubdir}, reduceAttempts)
 	r.Run(t)
 }
 
@@ -52,9 +52,9 @@ func tryToReduce(t *testing.T, testStateDir string, runDir string) {
 // The test will save the smallest reproduction found and print out the relevant
 // information.
 func tryToReduceCompare(
-	t *testing.T, testStateDir string, testRootDir string, runSubdirs []string,
+	t *testing.T, testStateDir string, testRootDir string, runSubdirs []string, reduceAttempts int,
 ) {
-	r := makeReducer(t, testStateDir, testRootDir, runSubdirs)
+	r := makeReducer(t, testStateDir, testRootDir, runSubdirs, reduceAttempts)
 	r.Run(t)
 }
 
@@ -62,8 +62,9 @@ func tryToReduceCompare(
 // tries to reduce the number of operations.
 type reducer struct {
 	// testRootDir is the directory of the test, which contains the "ops" file.
-	testRootDir string
-	configs     []testConfig
+	testRootDir    string
+	configs        []testConfig
+	reduceAttempts int
 
 	ops []string
 
@@ -83,7 +84,7 @@ type testConfig struct {
 }
 
 func makeReducer(
-	t *testing.T, testStateDir string, testRootDir string, runSubdirs []string,
+	t *testing.T, testStateDir string, testRootDir string, runSubdirs []string, reduceAttempts int,
 ) *reducer {
 	// All run dirs should have the same parent path.
 	opsData, err := os.ReadFile(filepath.Join(testRootDir, "ops"))
@@ -104,10 +105,11 @@ func makeReducer(
 	t.Logf("Starting with %d operations", len(ops))
 
 	return &reducer{
-		testRootDir:  testRootDir,
-		configs:      tc,
-		ops:          ops,
-		testStateDir: testStateDir,
+		testRootDir:    testRootDir,
+		configs:        tc,
+		ops:            ops,
+		reduceAttempts: reduceAttempts,
+		testStateDir:   testStateDir,
 	}
 }
 
@@ -196,8 +198,8 @@ func (r *reducer) Run(t *testing.T) {
 	// find any reductions we decrease it. This works well even if the problem is
 	// not deterministic and isn't reproduced on every run.
 	for removeProbability := 0.1; removeProbability > 1e-5 && removeProbability > 0.1/float64(len(ops)); removeProbability *= 0.5 {
-		t.Logf("removeProbability %.2f", removeProbability)
-		for i := 0; i < 100; i++ {
+		t.Logf("removeProbability %.2f%%", removeProbability*100.0)
+		for i := 0; i < r.reduceAttempts; i++ {
 			if o := randomSubset(t, ops, removeProbability); r.try(t, o) {
 				ops = o
 				// Reset the counter.
diff --git a/level_checker_test.go b/level_checker_test.go
index 947cc35e..57b65492 100644
--- a/level_checker_test.go
+++ b/level_checker_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/internal/invalidating"
+	"github.com/cockroachdb/pebble/internal/invariants"
 	"github.com/cockroachdb/pebble/internal/keyspan"
 	"github.com/cockroachdb/pebble/internal/manifest"
 	"github.com/cockroachdb/pebble/internal/private"
@@ -85,6 +86,10 @@ func (f *failMerger) Close() error {
 }
 
 func TestCheckLevelsCornerCases(t *testing.T) {
+	if invariants.Enabled {
+		t.Skip("disabled under invariants; relies on violating invariants to detect them")
+	}
+
 	memFS := vfs.NewMem()
 	var levels [][]*fileMetadata
 	formatKey := testkeys.Comparer.FormatKey
diff --git a/metrics_test.go b/metrics_test.go
index d1cec478..f0db9587 100644
--- a/metrics_test.go
+++ b/metrics_test.go
@@ -40,7 +40,8 @@ func exampleMetrics() Metrics {
 	m.Compact.MoveCount = 30
 	m.Compact.ReadCount = 31
 	m.Compact.RewriteCount = 32
-	m.Compact.MultiLevelCount = 33
+	m.Compact.CopyCount = 33
+	m.Compact.MultiLevelCount = 34
 	m.Compact.EstimatedDebt = 6
 	m.Compact.InProgressBytes = 7
 	m.Compact.NumInProgress = 2
diff --git a/objstorage/objstorageprovider/readahead_test.go b/objstorage/objstorageprovider/readahead_test.go
index a44d78ac..86aff7b5 100644
--- a/objstorage/objstorageprovider/readahead_test.go
+++ b/objstorage/objstorageprovider/readahead_test.go
@@ -20,9 +20,7 @@ func TestMaybeReadahead(t *testing.T) {
 		cacheHit := false
 		switch d.Cmd {
 		case "reset":
-			rs.size = initialReadaheadSize
-			rs.limit = 0
-			rs.numReads = 0
+			rs = makeReadaheadState(rs.maxReadaheadSize)
 			return ""
 
 		case "cache-read":
diff --git a/objstorage/objstorageprovider/testdata/readahead b/objstorage/objstorageprovider/testdata/readahead
index d2bb2173..adfe4ec6 100644
--- a/objstorage/objstorageprovider/testdata/readahead
+++ b/objstorage/objstorageprovider/testdata/readahead
@@ -8,7 +8,7 @@ readahead:  0
 numReads:   1
 size:       65536
 prevSize:   0
-limit:      0
+limit:      2064
 
 read
 2096, 16
@@ -17,7 +17,7 @@ readahead:  0
 numReads:   2
 size:       65536
 prevSize:   0
-limit:      0
+limit:      2112
 
 read
 2112, 16
@@ -84,7 +84,7 @@ readahead:  0
 numReads:   2
 size:       65536
 prevSize:   0
-limit:      16208
+limit:      16209
 
 # The next read is too far ahead to benefit from readahead
 # (i.e. 540497 > 16208 (limit) + (512 << 10) (maxReadaheadSize))
@@ -126,45 +126,49 @@ readahead:  0
 numReads:   2
 size:       65536
 prevSize:   0
-limit:      16
+limit:      7796
 
 read
-7680, 16
+7800, 16
 ----
 readahead:  65536
 numReads:   3
 size:       131072
 prevSize:   65536
-limit:      73216
+limit:      73336
 
 read
-7780, 16
----
+7880, 16
+----
 readahead:  0
 numReads:   4
 size:       131072
 prevSize:   65536
-limit:      73216
+limit:      73336
 
 read
-7880, 16
+7980, 16
 ----
-expected 2 args: offset, size
+readahead:  0
+numReads:   5
+size:       131072
+prevSize:   65536
+limit:      73336
 
 read
-7980, 16
+8080, 16
 ----
 readahead:  0
-numReads:   4
+numReads:   6
 size:       131072
 prevSize:   65536
-limit:      73216
+limit:      73336
 
 read
 73416, 16
 ----
 readahead:  131072
-numReads:   5
+numReads:   7
 size:       262144
 prevSize:   131072
 limit:      204488
@@ -173,7 +177,7 @@ read
 204488, 16
 ----
 readahead:  262144
-numReads:   6
+numReads:   8
 size:       262144
 prevSize:   262144
 limit:      466632
@@ -184,7 +188,7 @@ read
 466632, 16
 ----
 readahead:  262144
-numReads:   7
+numReads:   9
 size:       262144
 prevSize:   262144
 limit:      728776
@@ -195,7 +199,7 @@ cache-read
 728770, 16
 ----
 readahead:  0
-numReads:   7
+numReads:   10
 size:       262144
 prevSize:   262144
 limit:      728786
@@ -204,7 +208,7 @@ read
 728780, 16
 ----
 readahead:  262144
-numReads:   8
+numReads:   11
 size:       262144
 prevSize:   262144
 limit:      990924
@@ -219,3 +223,36 @@ numReads:   1
 size:       65536
 prevSize:   0
 limit:      1216
+
+reset
+----
+
+# Offset 240KiB, length 32KiB.
+read
+245760, 32768
+----
+readahead:  0
+numReads:   1
+size:       65536
+prevSize:   0
+limit:      278528
+
+# Offset 280KiB, length 32KiB.
+read
+286720, 32768
+----
+readahead:  0
+numReads:   2
+size:       65536
+prevSize:   0
+limit:      319488
+
+# Offset 320KiB, length 32KiB.
+read
+327680, 32768
+----
+readahead:  65536
+numReads:   3
+size:       131072
+prevSize:   65536
+limit:      393216
diff --git a/open_test.go b/open_test.go
index 43d1237f..31f9a6a7 100644
--- a/open_test.go
+++ b/open_test.go
@@ -713,9 +713,11 @@ func TestOpenWALReplay(t *testing.T) {
 				}
 			}
 			require.Equal(t, 0, sstCount)
-			// The memtable size starts at 256KB and doubles up to 32MB so we expect 5
-			// logs (one for each doubling).
-			require.Equal(t, 7, logCount)
+			// The memtable size starts at 256KB and doubles up to 32MB. But,
+			// we'll jump to a power of two large enough if a batch motivating a
+			// rotation is larger than the next power of two. We expect 3 WALs
+			// here.
+			require.Equal(t, 3, logCount)
 
 			// Re-open the DB with a smaller memtable. Values for 1, 2 will fit in the first memtable;
 			// value for 3 will go in the next memtable; value for 4 will be in a flushable batch
diff --git a/replay/replay_test.go b/replay/replay_test.go
index 2c3b7eb5..516092dc 100644
--- a/replay/replay_test.go
+++ b/replay/replay_test.go
@@ -441,7 +441,7 @@ func TestBenchmarkString(t *testing.T) {
 	var buf bytes.Buffer
 	require.NoError(t, m.WriteBenchmarkString("tpcc", &buf))
 	require.Equal(t, strings.TrimSpace(`
-BenchmarkBenchmarkReplay/tpcc/CompactionCounts 1 0 compactions 0 default 0 delete 0 elision 0 move 0 read 0 rewrite 0 multilevel
+BenchmarkBenchmarkReplay/tpcc/CompactionCounts 1 0 compactions 0 default 0 delete 0 elision 0 move 0 read 0 rewrite 0 copy 0 multilevel
 BenchmarkBenchmarkReplay/tpcc/DatabaseSize/mean 1 5.36870912e+09 bytes
 BenchmarkBenchmarkReplay/tpcc/DatabaseSize/max 1 5.36870912e+09 bytes
 BenchmarkBenchmarkReplay/tpcc/DurationWorkload 1 1 sec/op
diff --git a/testdata/event_listener b/testdata/event_listener
index 08537b56..8db0b6d7 100644
--- a/testdata/event_listener
+++ b/testdata/event_listener
@@ -222,7 +222,7 @@ total |     3  1.7KB     0B       0 |     - |  671B |     1   590B |     0     0
 WAL: 1 files (0B)  in: 48B  written: 81B (69% overhead)
 Flushes: 3
 Compactions: 1  estimated debt: 1.7KB  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -322,7 +322,7 @@ total |     6  3.5KB     0B       0 |     - | 1.8KB |     3  1.7KB |     0     0
 WAL: 1 files (0B)  in: 82B  written: 108B (32% overhead)
 Flushes: 6
 Compactions: 1  estimated debt: 3.5KB  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (512KB)  zombie: 1 (512KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
diff --git a/testdata/external_iterator b/testdata/external_iterator
index af80e0df..238b81b4 100644
--- a/testdata/external_iterator
+++ b/testdata/external_iterator
@@ -245,5 +245,4 @@ aaaa@3: (aaaa@3, .)
 aaaa@1: (aaaa@1, .)
 aaaaa@3: (aaaaa@3, .)
 aaaaa@1: (aaaaa@1, .)
-stats: (interface (dir, seek, step): (fwd, 5, 5), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 5, 5), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 547B, cached 0B, read-time 0s)), (points: (count 10, key-bytes 50B, value-bytes 35B, tombstoned 0)), (separated: (count 15, bytes 65B, fetched 25B)))
+stats: seeked 5 times (5 internal); stepped 5 times (5 internal); blocks: 547B (0B cached), read time 0s; points: 10 (50B keys, 35B values); separated: 15 (65B, 25B fetched)
diff --git a/testdata/ingest b/testdata/ingest
index 24302a6b..4be171bd 100644
--- a/testdata/ingest
+++ b/testdata/ingest
@@ -46,7 +46,7 @@ total |     1   569B     0B       0 |     - |  569B |     1   569B |     0     0
 WAL: 1 files (0B)  in: 0B  written: 0B (0% overhead)
 Flushes: 0
 Compactions: 0  estimated debt: 0B  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 0 (0B)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
diff --git a/testdata/ingest_external b/testdata/ingest_external
index 71921b1c..dc6edf5a 100644
--- a/testdata/ingest_external
+++ b/testdata/ingest_external
@@ -830,3 +830,81 @@ a: (foo, .)
 aa@10: (a, .)
 b@11: (b, .)
 .
+
+# Test empty external ingestion.
+reset
+----
+
+build-remote ext
+set a@10 a
+set b@11 b
+----
+
+ingest-external
+ext bounds=(u,v)
+----
+
+lsm
+----
+L6:
+  000004(000004):[u#10,DELSIZED-v#inf,RANGEDEL]
+
+iter
+first
+----
+.
+
+download a z via-backing-file-download
+----
+ok
+
+# LSM should be empty now.
+lsm
+----
+
+iter
+first
+----
+.
+
+# Test with two external files, one empty.
+ingest-external
+ext bounds=(a,c)
+----
+
+ingest-external
+ext bounds=(u,v)
+----
+
+lsm
+----
+L6:
+  000006(000006):[a#11,DELSIZED-c#inf,RANGEDEL]
+  000007(000006):[u#12,DELSIZED-v#inf,RANGEDEL]
+
+iter
+first
+next
+next
+----
+a@10: (a, .)
+b@11: (b, .)
+.
+
+download a z
+----
+ok
+
+lsm
+----
+L6:
+  000008:[a@10#0,SET-b@11#0,SET]
+
+iter
+first
+next
+next
+----
+a@10: (a, .)
+b@11: (b, .)
+.
diff --git a/testdata/iter_histories/iter_optimizations b/testdata/iter_histories/iter_optimizations
index 18108183..5c3513ae 100644
--- a/testdata/iter_histories/iter_optimizations
+++ b/testdata/iter_histories/iter_optimizations
@@ -199,8 +199,7 @@ stats
 ----
 lastPositioningOp="unknown"
 b@5: (b@5, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 119B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 119B (0B cached), read time 0s; points: 1 (3B keys, 3B values)
 
 mutate batch=foo
 set h@2 h@2
@@ -215,8 +214,7 @@ stats
 .
 lastPositioningOp="seekprefixge"
 c@3: (c@3, .)
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 119B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 6B, value-bytes 6B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 0 times (0 internal); blocks: 119B (0B cached), read time 0s; points: 2 (6B keys, 6B values)
 
 mutate batch=foo
 set i@1 i@1
@@ -231,8 +229,7 @@ stats
 .
 lastPositioningOp="seekprefixge"
 d@9: (d@9, .)
-stats: (interface (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 119B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 9B, value-bytes 9B, tombstoned 0)))
+stats: seeked 3 times (3 internal); stepped 0 times (0 internal); blocks: 119B (0B cached), read time 0s; points: 3 (9B keys, 9B values)
 
 mutate batch=foo
 set j@6 j@6
@@ -247,8 +244,7 @@ stats
 .
 lastPositioningOp="seekprefixge"
 e@8: (e@8, .)
-stats: (interface (dir, seek, step): (fwd, 4, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 4, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 119B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 12B, value-bytes 12B, tombstoned 0)))
+stats: seeked 4 times (4 internal); stepped 0 times (0 internal); blocks: 119B (0B cached), read time 0s; points: 4 (12B keys, 12B values)
 
 # Ensure that a case eligible for TrySeekUsingNext across a SetOptions correctly
 # sees new batch mutations. The batch iterator should ignore the
diff --git a/testdata/iter_histories/prefix_iteration b/testdata/iter_histories/prefix_iteration
index 1ea878ea..d977b2ec 100644
--- a/testdata/iter_histories/prefix_iteration
+++ b/testdata/iter_histories/prefix_iteration
@@ -336,3 +336,29 @@ seek-prefix-ge c@9
 ----
 .
 .
+
+# Regression test for #3610.
+#
+# Similar to the above case, this test consists of two SeekPrefixGEs for
+# ascending keys, resulting in TrySeekUsingNext()=true for the second seek.
+# Previously, during the first SeekPrefixGE the mergingIter could Next the
+# levelIter beyond the file containing point keys relevant to both seeks.
+
+define bloom-bits-per-key=100
+L4
+  b@0.SET.10:b@0
+L5
+  b@8.RANGEDEL.3:b@1
+  c@3.SET.0:c@3
+----
+L4:
+  000004:[b@0#10,SET-b@0#10,SET]
+L5:
+  000005:[b@8#3,RANGEDEL-c@3#0,SET]
+
+combined-iter
+seek-prefix-ge b@10
+seek-prefix-ge c@10
+----
+b@0: (b@0, .)
+c@3: (c@3, .)
diff --git a/testdata/iter_histories/range_key_masking b/testdata/iter_histories/range_key_masking
index f13c71ae..5fcccee9 100644
--- a/testdata/iter_histories/range_key_masking
+++ b/testdata/iter_histories/range_key_masking
@@ -153,9 +153,7 @@ stats
 ----
 a: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 1.1KB, cached 0B, read-time 0s)), (points: (count 25, key-bytes 75B, value-bytes 75B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 25, skipped 25)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 1.1KB (0B cached), read time 0s; points: 25 (75B keys, 75B values), range keys: 1, contained points: 25 (25 skipped)
 
 # Repeat the above test, but with an iterator that uses a block-property filter
 # mask. The internal stats should reflect fewer bytes read and fewer points
@@ -168,9 +166,7 @@ stats
 ----
 a: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 514B, cached 514B, read-time 0s)), (points: (count 2, key-bytes 6B, value-bytes 6B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 2, skipped 2)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 514B (514B cached), read time 0s; points: 2 (6B keys, 6B values), range keys: 1, contained points: 2 (2 skipped)
 
 # Perform a similar comparison in reverse.
 
@@ -181,9 +177,7 @@ stats
 ----
 a: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 1.1KB, cached 1.1KB, read-time 0s)), (points: (count 25, key-bytes 75B, value-bytes 75B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 25, skipped 25)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 1.1KB (1.1KB cached), read time 0s; points: 25 (75B keys, 75B values), range keys: 1, contained points: 25 (25 skipped)
 
 combined-iter mask-suffix=@9 mask-filter
 last
@@ -192,9 +186,7 @@ stats
 ----
 a: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 514B, cached 514B, read-time 0s)), (points: (count 2, key-bytes 6B, value-bytes 6B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 2, skipped 2)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 514B (514B cached), read time 0s; points: 2 (6B keys, 6B values), range keys: 1, contained points: 2 (2 skipped)
 
 # Perform similar comparisons with seeks.
 
@@ -205,9 +197,7 @@ stats
 ----
 m: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 789B, cached 789B, read-time 0s)), (points: (count 13, key-bytes 39B, value-bytes 39B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 13, skipped 13)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 789B (789B cached), read time 0s; points: 13 (39B keys, 39B values), range keys: 1, contained points: 13 (13 skipped)
 
 combined-iter mask-suffix=@9 mask-filter
 seek-ge m
@@ -216,9 +206,7 @@ stats
 ----
 m: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 514B, cached 514B, read-time 0s)), (points: (count 2, key-bytes 6B, value-bytes 6B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 2, skipped 2)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 514B (514B cached), read time 0s; points: 2 (6B keys, 6B values), range keys: 1, contained points: 2 (2 skipped)
 
 combined-iter mask-suffix=@9
 seek-lt m
@@ -227,9 +215,7 @@ stats
 ----
 a: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 789B, cached 789B, read-time 0s)), (points: (count 12, key-bytes 36B, value-bytes 36B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 12, skipped 12)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 789B (789B cached), read time 0s; points: 12 (36B keys, 36B values), range keys: 1, contained points: 12 (12 skipped)
 
 combined-iter mask-suffix=@9 mask-filter
 seek-lt m
@@ -238,6 +224,4 @@ stats
 ----
 a: (., [a-z) @5=boop UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 539B, cached 539B, read-time 0s)), (points: (count 2, key-bytes 6B, value-bytes 6B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 2, skipped 2)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 539B (539B cached), read time 0s; points: 2 (6B keys, 6B values), range keys: 1, contained points: 2 (2 skipped)
diff --git a/testdata/iter_histories/stats_no_invariants b/testdata/iter_histories/stats_no_invariants
index fb64b720..dd73915f 100644
--- a/testdata/iter_histories/stats_no_invariants
+++ b/testdata/iter_histories/stats_no_invariants
@@ -30,19 +30,15 @@ next
 next
 stats
 ----
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 0, 0))
+stats: seeked 0 times (0 internal); stepped 0 times (0 internal)
 a: (a, .)
 b: (b, [b-c) @5=boop UPDATED)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 89B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0))),
-(range-key-stats: (count 1), (contained points: (count 1, skipped 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (2 internal); blocks: 89B (0B cached), read time 0s; points: 2 (2B keys, 2B values), range keys: 1, contained points: 1 (0 skipped)
 c: (c, . UPDATED)
 cat: (., [cat-dog) @3=beep UPDATED)
 d: (d, [cat-dog) @3=beep)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 5), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 6), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 89B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0))),
-(range-key-stats: (count 2), (contained points: (count 2, skipped 0)))
+stats: seeked 1 times (1 internal); stepped 5 times (6 internal); blocks: 89B (0B cached), read time 0s; points: 4 (4B keys, 4B values), range keys: 2, contained points: 2 (0 skipped)
 
 # Do the above forward iteration but with a mask suffix. The results should be
 # identical despite range keys serving as masks, because none of the point keys
@@ -63,9 +59,7 @@ c: (c, . UPDATED)
 cat: (., [cat-dog) @3=beep UPDATED)
 d: (d, [cat-dog) @3=beep)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 5), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 6), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 89B, cached 89B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0))),
-(range-key-stats: (count 2), (contained points: (count 2, skipped 0)))
+stats: seeked 1 times (1 internal); stepped 5 times (6 internal); blocks: 89B (89B cached), read time 0s; points: 4 (4B keys, 4B values), range keys: 2, contained points: 2 (0 skipped)
 
 # Scan backward
 
@@ -84,9 +78,7 @@ c: (c, . UPDATED)
 b: (b, [b-c) @5=boop UPDATED)
 a: (a, . UPDATED)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 5)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 6)),
-(internal-stats: (block-bytes: (total 89B, cached 89B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0))),
-(range-key-stats: (count 2), (contained points: (count 2, skipped 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 5 times (0 fwd/5 rev, internal: 0 fwd/6 rev); blocks: 89B (89B cached), read time 0s; points: 4 (4B keys, 4B values), range keys: 2, contained points: 2 (0 skipped)
 
 combined-iter
 seek-ge ace
@@ -107,9 +99,7 @@ cat: (., [cat-dog) @3=beep)
 d: (d, [cat-dog) @3=beep)
 day: (., [cat-dog) @3=beep)
 .
-stats: (interface (dir, seek, step): (fwd, 8, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 6, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 89B, cached 89B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0))),
-(range-key-stats: (count 2), (contained points: (count 3, skipped 0)))
+stats: seeked 8 times (6 internal); stepped 0 times (4 internal); blocks: 89B (89B cached), read time 0s; points: 4 (4B keys, 4B values), range keys: 2, contained points: 3 (0 skipped)
 
 combined-iter
 seek-lt 1
@@ -134,9 +124,7 @@ cat: (., [cat-dog) @3=beep UPDATED)
 d: (d, [cat-dog) @3=beep)
 d: (d, [cat-dog) @3=beep)
 d: (d, [cat-dog) @3=beep)
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 10, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 10, 10)),
-(internal-stats: (block-bytes: (total 267B, cached 267B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 15B, tombstoned 0))),
-(range-key-stats: (count 2), (contained points: (count 6, skipped 0)))
+stats: seeked 10 times (0 fwd/10 rev, internal: 0 fwd/10 rev); stepped 0 times (0 fwd/0 rev, internal: 0 fwd/10 rev); blocks: 267B (267B cached), read time 0s; points: 15 (15B keys, 15B values), range keys: 2, contained points: 6 (0 skipped)
 
 rangekey-iter
 first
@@ -155,5 +143,4 @@ cat [cat-dog) @3=beep UPDATED
 bat [bat-c) @5=boop UPDATED
 cat [cat-catatonic) @3=beep UPDATED
 .
-stats: (interface (dir, seek, step): (fwd, 2, 4), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 4), (rev, 0, 0)),
-(range-key-stats: (count 4), (contained points: (count 0, skipped 0)))
+stats: seeked 2 times (2 internal); stepped 4 times (4 internal), range keys: 4, contained points: 0 (0 skipped)
diff --git a/testdata/iterator b/testdata/iterator
index 17c4a76b..e5d1aad0 100644
--- a/testdata/iterator
+++ b/testdata/iterator
@@ -8,8 +8,7 @@ iter seq=4
 seek-prefix-ge a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 define
 a.SET.1:b
@@ -23,20 +22,19 @@ prev
 a: (b, .)
 .
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 seek-ge b
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal)
 
 iter seq=2
 seek-lt a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 0))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 0 times (0 internal)
 
 
 define
@@ -52,8 +50,7 @@ prev
 a: (b, .)
 .
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 4B values)
 
 iter seq=3
 seek-ge a
@@ -63,8 +60,7 @@ prev
 a: (c, .)
 .
 a: (c, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 1, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 2 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 4B values)
 
 iter seq=2
 seek-prefix-ge a
@@ -76,8 +72,7 @@ a: (b, .)
 .
 err=pebble: unsupported reverse prefix iteration
 err=pebble: unsupported reverse prefix iteration
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 3 times (2 fwd/1 rev, internal: 1 fwd/0 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=3
 seek-prefix-ge a
@@ -85,8 +80,7 @@ next
 ----
 a: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 define
 a.DEL.2:
@@ -97,8 +91,7 @@ iter seq=3
 seek-ge a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=2
 seek-ge 1
@@ -106,15 +99,13 @@ next
 ----
 a: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=3
 seek-lt b
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 0 times (0 fwd/0 rev, internal: 0 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=2
 seek-lt b
@@ -124,21 +115,19 @@ next
 a: (b, .)
 .
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 2B values)
 
 iter seq=3
 seek-prefix-ge a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=2
 seek-prefix-ge 1
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal)
 
 define
 a.DEL.2:
@@ -152,43 +141,37 @@ next
 ----
 b: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (3 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 2B values)
 
 iter seq=3
 seek-ge a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 2B values)
 
 iter seq=2
 seek-ge a
 ----
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=4
 seek-prefix-ge a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=3
 seek-prefix-ge a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=2
 seek-prefix-ge a
 ----
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 iter seq=2
 seek-prefix-ge a
@@ -196,8 +179,7 @@ seek-prefix-ge b
 ----
 a: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 2B values)
 
 define
 a.DEL.3:
@@ -215,8 +197,7 @@ seek-prefix-ge c
 .
 .
 c: (d, .)
-stats: (interface (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 3, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 3B, tombstoned 0)))
+stats: seeked 3 times (3 internal); stepped 0 times (4 internal); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 3B values)
 
 iter seq=3
 seek-prefix-ge a
@@ -226,8 +207,7 @@ seek-prefix-ge c
 a: (b, .)
 b: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 3B, tombstoned 0)))
+stats: seeked 3 times (3 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 3B values)
 
 iter seq=3
 seek-ge a
@@ -237,8 +217,7 @@ seek-ge c
 a: (b, .)
 b: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 3B, tombstoned 0)))
+stats: seeked 3 times (3 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 3B values)
 
 define
 a.SET.1:a
@@ -256,8 +235,7 @@ a: (a, .)
 b: (b, .)
 c: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 3 times (3 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=4
 seek-ge b
@@ -265,21 +243,19 @@ next
 ----
 b: (b, .)
 c: (c, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=4
 seek-ge c
 ----
 c: (c, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=4
 seek-lt a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 0))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 0 times (0 internal)
 
 iter seq=4
 seek-lt b
@@ -289,8 +265,7 @@ next
 a: (a, .)
 .
 a: (a, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=4
 seek-lt c
@@ -302,8 +277,7 @@ b: (b, .)
 a: (a, .)
 .
 a: (a, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 2)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 3 times (1 fwd/2 rev, internal: 0 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 
 iter seq=4
@@ -318,8 +292,7 @@ b: (b, .)
 a: (a, .)
 .
 a: (a, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 3)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 3)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 4 times (1 fwd/3 rev, internal: 0 fwd/3 rev); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 4B values)
 
 iter seq=4
 seek-prefix-ge a
@@ -327,8 +300,7 @@ next
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=4
 seek-prefix-ge b
@@ -336,8 +308,7 @@ next
 ----
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 
 iter seq=4
@@ -346,15 +317,14 @@ next
 ----
 c: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 
 iter seq=4
 seek-prefix-ge d
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal)
 
 iter seq=4
 seek-prefix-ge a
@@ -364,8 +334,7 @@ seek-prefix-ge b
 a: (a, .)
 c: (c, .)
 b: (b, .)
-stats: (interface (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 3 times (3 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 define
 a.SET.b2:b
@@ -380,21 +349,19 @@ prev
 a: (b, .)
 .
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 4B values)
 
 iter seq=2
 seek-ge b
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 seek-lt a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 0))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 0 times (0 internal)
 
 iter seq=2
 seek-lt b
@@ -404,8 +371,7 @@ next
 a: (b, .)
 .
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 seek-lt c
@@ -415,8 +381,7 @@ next
 a: (b, .)
 .
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 2 times (1 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2
 seek-prefix-ge a
@@ -424,15 +389,13 @@ next
 ----
 a: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 seek-prefix-ge b
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 
 define
@@ -448,8 +411,7 @@ next
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=5
 seek-prefix-ge a
@@ -457,15 +419,13 @@ next
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=5
 seek-prefix-ge aa
 ----
 aa: (aa, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (2B keys, 2B values)
 
 iter seq=5
 seek-prefix-ge aa
@@ -473,8 +433,7 @@ next
 ----
 aa: (aa, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (2B keys, 2B values)
 
 iter seq=5
 seek-prefix-ge aa
@@ -482,8 +441,7 @@ next
 ----
 aa: (aa, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (2B keys, 2B values)
 
 iter seq=5
 seek-prefix-ge aaa
@@ -491,15 +449,13 @@ next
 ----
 aaa: (aaa, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (3B keys, 3B values)
 
 iter seq=5
 seek-prefix-ge aaa
 ----
 aaa: (aaa, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (3B keys, 3B values)
 
 iter seq=5
 seek-prefix-ge b
@@ -507,8 +463,7 @@ next
 ----
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=5
 seek-prefix-ge aa
@@ -524,8 +479,7 @@ aaa: (aaa, .)
 aa: (aa, .)
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 1, 4)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 4)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 9B, value-bytes 9B, tombstoned 0)))
+stats: seeked 2 times (1 fwd/1 rev, internal: 1 fwd/1 rev); stepped 4 times (0 fwd/4 rev, internal: 0 fwd/4 rev); blocks: 0B (0B cached), read time 0s; points: 5 (9B keys, 9B values)
 
 iter seq=5
 seek-prefix-ge aa
@@ -541,8 +495,7 @@ aa: (aa, .)
 aaa: (aaa, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 4), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 9B, value-bytes 9B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 4 times (4 internal); blocks: 0B (0B cached), read time 0s; points: 5 (9B keys, 9B values)
 
 iter seq=5
 seek-prefix-ge aaa
@@ -556,8 +509,7 @@ aa: (aa, .)
 aaa: (aaa, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 3), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 9B, value-bytes 9B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 3 times (3 internal); blocks: 0B (0B cached), read time 0s; points: 4 (9B keys, 9B values)
 
 iter seq=5
 seek-prefix-ge aaa
@@ -569,8 +521,7 @@ aaa: (aaa, .)
 aaa: (aaa, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 2), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 7B, value-bytes 7B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 2 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 3 (7B keys, 7B values)
 
 iter seq=5
 seek-prefix-ge aaa
@@ -586,8 +537,7 @@ aa: (aa, .)
 aaa: (aaa, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 4), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 2, 4), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 6, key-bytes 11B, value-bytes 11B, tombstoned 0)))
+stats: seeked 2 times (1 fwd/1 rev, internal: 2 fwd/1 rev); stepped 4 times (4 fwd/0 rev, internal: 4 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 6 (11B keys, 11B values)
 
 
 iter seq=5
@@ -600,8 +550,7 @@ aaa: (aaa, .)
 aaa: (aaa, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 12B, value-bytes 12B, tombstoned 0)))
+stats: seeked 2 times (1 fwd/1 rev, internal: 1 fwd/1 rev); stepped 2 times (2 fwd/0 rev, internal: 3 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 5 (12B keys, 12B values)
 
 iter seq=4
 seek-prefix-ge a
@@ -613,8 +562,7 @@ a: (a, .)
 aa: (aa, .)
 aaa: (aaa, .)
 .
-stats: (interface (dir, seek, step): (fwd, 4, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 4, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 7B, value-bytes 7B, tombstoned 0)))
+stats: seeked 4 times (4 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 4 (7B keys, 7B values)
 
 iter seq=3
 seek-prefix-ge aaa
@@ -628,8 +576,7 @@ seek-prefix-ge aaa
 a: (a, .)
 aa: (aa, .)
 .
-stats: (interface (dir, seek, step): (fwd, 5, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 5, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 10B, value-bytes 10B, tombstoned 0)))
+stats: seeked 5 times (5 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 5 (10B keys, 10B values)
 
 define
 bb.DEL.2:
@@ -641,8 +588,7 @@ iter seq=4
 seek-prefix-ge bb
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 4B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (4B keys, 1B values)
 
 
 define
@@ -663,8 +609,7 @@ a: (bcd, .)
 b: (ab, .)
 .
 b: (ab, .)
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 5), (rev, 1, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 8, key-bytes 8B, value-bytes 8B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); stepped 3 times (2 fwd/1 rev, internal: 5 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 8 (8B keys, 8B values)
 
 iter seq=3
 seek-ge a
@@ -672,8 +617,7 @@ next
 ----
 a: (bc, .)
 b: (ab, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (4 internal); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=2
 seek-ge a
@@ -681,8 +625,7 @@ next
 ----
 a: (b, .)
 b: (a, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=4
 seek-lt c
@@ -694,8 +637,7 @@ b: (ab, .)
 a: (bcd, .)
 .
 a: (bcd, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 2)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 1, 5)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 8, key-bytes 8B, value-bytes 8B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 3 times (1 fwd/2 rev, internal: 2 fwd/5 rev); blocks: 0B (0B cached), read time 0s; points: 8 (8B keys, 8B values)
 
 iter seq=3
 seek-lt c
@@ -703,8 +645,7 @@ prev
 ----
 b: (ab, .)
 a: (bc, .)
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 4)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/4 rev); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=2
 seek-lt c
@@ -712,8 +653,7 @@ prev
 ----
 b: (a, .)
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=4
 seek-ge a
@@ -725,8 +665,7 @@ a: (bcd, .)
 b: (ab, .)
 a: (bcd, .)
 b: (ab, .)
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 10), (rev, 1, 5)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 15B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 2 fwd/1 rev); stepped 3 times (2 fwd/1 rev, internal: 10 fwd/5 rev); blocks: 0B (0B cached), read time 0s; points: 15 (15B keys, 15B values)
 
 iter seq=3
 seek-ge a
@@ -738,8 +677,7 @@ a: (bc, .)
 b: (ab, .)
 a: (bc, .)
 b: (ab, .)
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 8), (rev, 1, 4)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 15B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 2 fwd/1 rev); stepped 3 times (2 fwd/1 rev, internal: 8 fwd/4 rev); blocks: 0B (0B cached), read time 0s; points: 15 (15B keys, 15B values)
 
 iter seq=2
 seek-ge a
@@ -751,8 +689,7 @@ a: (b, .)
 b: (a, .)
 a: (b, .)
 b: (a, .)
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 4), (rev, 1, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 15B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 2 fwd/1 rev); stepped 3 times (2 fwd/1 rev, internal: 4 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 15 (15B keys, 15B values)
 
 iter seq=4
 seek-lt c
@@ -764,8 +701,7 @@ b: (ab, .)
 a: (bcd, .)
 b: (ab, .)
 a: (bcd, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 2)), (internal (dir, seek, step): (fwd, 1, 5), (rev, 2, 10)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 15B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/2 rev); stepped 3 times (1 fwd/2 rev, internal: 5 fwd/10 rev); blocks: 0B (0B cached), read time 0s; points: 15 (15B keys, 15B values)
 
 iter seq=3
 seek-lt c
@@ -777,8 +713,7 @@ b: (ab, .)
 a: (bc, .)
 b: (ab, .)
 a: (bc, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 2)), (internal (dir, seek, step): (fwd, 1, 4), (rev, 2, 8)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 15B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/2 rev); stepped 3 times (1 fwd/2 rev, internal: 4 fwd/8 rev); blocks: 0B (0B cached), read time 0s; points: 15 (15B keys, 15B values)
 
 iter seq=2
 seek-lt c
@@ -790,8 +725,7 @@ b: (a, .)
 a: (b, .)
 b: (a, .)
 a: (b, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 2)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 2, 4)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 15B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/2 rev); stepped 3 times (1 fwd/2 rev, internal: 2 fwd/4 rev); blocks: 0B (0B cached), read time 0s; points: 15 (15B keys, 15B values)
 
 iter seq=3
 seek-prefix-ge a
@@ -799,8 +733,7 @@ next
 ----
 a: (bc, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2
 seek-prefix-ge a
@@ -808,8 +741,7 @@ next
 ----
 a: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=4
 seek-prefix-ge a
@@ -817,8 +749,7 @@ next
 ----
 a: (bcd, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (3 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2
 seek-prefix-ge a
@@ -826,8 +757,7 @@ next
 ----
 a: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=3
 seek-prefix-ge a
@@ -835,27 +765,25 @@ next
 ----
 a: (bc, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=3
 seek-prefix-ge c
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal)
 
 iter seq=3
 seek-prefix-ge 1
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal)
 
 iter seq=3
 seek-prefix-ge a
 ----
 a: (bc, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 
 define
@@ -876,8 +804,7 @@ next
 a: (bc, .)
 .
 .
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 2 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2
 seek-prefix-ge a
@@ -887,8 +814,7 @@ next
 a: (b, .)
 .
 .
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 2 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=4
 seek-prefix-ge a
@@ -896,8 +822,7 @@ next
 ----
 a: (bcd, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (3 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2
 seek-prefix-ge a
@@ -905,8 +830,7 @@ next
 ----
 a: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=3
 seek-prefix-ge aa
@@ -914,15 +838,13 @@ next
 ----
 aa: (ab, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 4B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (4B keys, 2B values)
 
 iter seq=4
 seek-prefix-ge aa
 ----
 aa: (ab, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 4B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (4B keys, 2B values)
 
 define
 a.SET.1:a
@@ -939,8 +861,7 @@ prev
 a: (a, .)
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2 lower=b
 seek-ge a
@@ -950,8 +871,7 @@ prev
 b: (b, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2 lower=c
 seek-ge a
@@ -961,8 +881,7 @@ prev
 c: (c, .)
 c: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2 lower=d
 seek-ge a
@@ -972,8 +891,7 @@ prev
 d: (d, .)
 d: (d, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2 lower=e
 seek-ge a
@@ -981,7 +899,7 @@ first
 ----
 .
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 0))
+stats: seeked 2 times (2 internal); stepped 0 times (0 internal)
 
 iter seq=2 upper=d
 seek-lt d
@@ -991,8 +909,7 @@ next
 c: (c, .)
 c: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 0, 2), (rev, 2, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 2 times (0 fwd/2 rev, internal: 0 fwd/2 rev); stepped 1 times (1 fwd/0 rev, internal: 2 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=2 upper=c
 seek-lt d
@@ -1002,8 +919,7 @@ next
 b: (b, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 0, 2), (rev, 2, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 2 times (0 fwd/2 rev, internal: 0 fwd/2 rev); stepped 1 times (1 fwd/0 rev, internal: 2 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=2 upper=b
 seek-lt d
@@ -1013,8 +929,7 @@ next
 a: (a, .)
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 2, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 2 times (0 fwd/2 rev, internal: 1 fwd/2 rev); stepped 1 times (1 fwd/0 rev, internal: 1 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2 upper=a
 seek-lt d
@@ -1022,7 +937,7 @@ last
 ----
 .
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 2, 0))
+stats: seeked 2 times (0 fwd/2 rev, internal: 0 fwd/2 rev); stepped 0 times (0 internal)
 
 iter seq=2 lower=b upper=c
 seek-ge a
@@ -1030,8 +945,7 @@ next
 ----
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 set-bounds lower=a
@@ -1043,8 +957,7 @@ prev
 a: (a, .)
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 set-bounds lower=b
@@ -1056,8 +969,7 @@ prev
 b: (b, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 set-bounds lower=c
@@ -1069,8 +981,7 @@ prev
 c: (c, .)
 c: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 set-bounds lower=d
@@ -1082,8 +993,7 @@ prev
 d: (d, .)
 d: (d, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 set-bounds lower=e
@@ -1093,7 +1003,7 @@ first
 .
 .
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 0))
+stats: seeked 2 times (2 internal); stepped 0 times (0 internal)
 
 iter seq=2
 set-bounds upper=d
@@ -1105,8 +1015,7 @@ next
 c: (c, .)
 c: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 0, 2), (rev, 2, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 2 times (0 fwd/2 rev, internal: 0 fwd/2 rev); stepped 1 times (1 fwd/0 rev, internal: 2 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=2
 set-bounds upper=c
@@ -1118,8 +1027,7 @@ next
 b: (b, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 0, 2), (rev, 2, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 2 times (0 fwd/2 rev, internal: 0 fwd/2 rev); stepped 1 times (1 fwd/0 rev, internal: 2 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 iter seq=2
 set-bounds upper=b
@@ -1131,8 +1039,7 @@ next
 a: (a, .)
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 2, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 2 times (0 fwd/2 rev, internal: 1 fwd/2 rev); stepped 1 times (1 fwd/0 rev, internal: 1 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2
 set-bounds upper=a
@@ -1142,7 +1049,7 @@ last
 .
 .
 .
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 2, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 2, 0))
+stats: seeked 2 times (0 fwd/2 rev, internal: 0 fwd/2 rev); stepped 0 times (0 internal)
 
 iter seq=2
 set-bounds lower=a
@@ -1154,8 +1061,7 @@ next
 c: (c, .)
 d: (d, .)
 .
-stats: (interface (dir, seek, step): (fwd, 0, 2), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 0, 3), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 4B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 2 times (2 fwd/0 rev, internal: 3 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 4B values)
 
 iter seq=2
 set-bounds lower=b upper=c
@@ -1165,8 +1071,7 @@ next
 .
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 set-bounds lower=b
@@ -1178,8 +1083,7 @@ seek-ge a
 b: (b, .)
 .
 b: (b, .)
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 seek-ge a
@@ -1187,8 +1091,7 @@ set-bounds upper=e
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 set-bounds lower=b
@@ -1198,8 +1101,7 @@ set-bounds upper=e
 .
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 set-bounds lower=b
@@ -1207,8 +1109,7 @@ first
 ----
 .
 b: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 set-bounds upper=b
@@ -1216,8 +1117,7 @@ first
 ----
 .
 a: (a, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2
 set-bounds lower=b
@@ -1225,8 +1125,7 @@ last
 ----
 .
 d: (d, .)
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 0 times (0 fwd/0 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 set-bounds upper=b
@@ -1234,8 +1133,7 @@ last
 ----
 .
 a: (a, .)
-stats: (interface (dir, seek, step): (fwd, 0, 0), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); stepped 0 times (0 fwd/0 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 # The prev call after "set-bounds upper=c" will assume that the iterator
 # is exhausted due to having stepped up to c. Which means prev should step
@@ -1250,8 +1148,7 @@ d: (d, .)
 .
 .
 b: (b, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 1)), (internal (dir, seek, step): (fwd, 0, 2), (rev, 2, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 5, key-bytes 5B, value-bytes 5B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 0 fwd/2 rev); stepped 2 times (1 fwd/1 rev, internal: 2 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 5 (5B keys, 5B values)
 
 # The next call after "set-bounds lower=b" will assume that the iterator
 # is exhausted due to having stepped below b. Which means next should step
@@ -1266,8 +1163,7 @@ a: (a, .)
 .
 .
 b: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (2 internal); stepped 2 times (1 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 iter seq=2
 set-bounds lower=b
@@ -1277,8 +1173,7 @@ next
 .
 b: (b, .)
 c: (c, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 1, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 iter seq=2
 set-bounds upper=d
@@ -1288,8 +1183,7 @@ prev
 .
 c: (c, .)
 b: (b, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 2)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/2 rev); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 3B values)
 
 define
 a.SET.1:a
@@ -1306,15 +1200,14 @@ prev
 a: (a, .)
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 1)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 2 times (2 internal); stepped 1 times (0 fwd/1 rev, internal: 0 fwd/1 rev); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 
 iter seq=2 lower=aa
 seek-prefix-ge a
 ----
 err=pebble: SeekPrefixGE supplied with key outside of lower bound
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 0, 0), (rev, 0, 0))
+stats: seeked 1 times (0 internal); stepped 0 times (0 internal)
 
 iter seq=2 lower=a upper=aa
 seek-prefix-ge a
@@ -1322,8 +1215,7 @@ next
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2 lower=a upper=aaa
 seek-prefix-ge a
@@ -1331,8 +1223,7 @@ next
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2 lower=a upper=b
 seek-prefix-ge a
@@ -1340,8 +1231,7 @@ next
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2 lower=a upper=c
 seek-prefix-ge a
@@ -1349,15 +1239,13 @@ next
 ----
 a: (a, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 iter seq=2 lower=a upper=aaa
 seek-prefix-ge aa
 ----
 aa: (aa, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (2B keys, 2B values)
 
 iter seq=2 lower=a upper=aaa
 seek-prefix-ge aa
@@ -1365,8 +1253,7 @@ next
 ----
 aa: (aa, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (2B keys, 2B values)
 
 define
 a.SET.1:a
@@ -1381,8 +1268,7 @@ next
 a: (a, .)
 b: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 2 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 define
 a.SET.2:a
@@ -1393,8 +1279,7 @@ iter seq=2
 seek-prefix-ge a
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 1B values)
 
 define
 a.SINGLEDEL.1:
@@ -1404,8 +1289,7 @@ iter seq=2
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 0B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (1 internal); blocks: 0B (0B cached), read time 0s; points: 1 (1B keys, 0B values)
 
 define
 a.SINGLEDEL.2:
@@ -1416,8 +1300,7 @@ iter seq=3
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 0B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 0B values)
 
 define
 a.SINGLEDEL.2:
@@ -1428,8 +1311,7 @@ iter seq=3
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 0B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 0B values)
 
 define
 a.SINGLEDEL.2:
@@ -1440,8 +1322,7 @@ iter seq=3
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 0B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 0B values)
 
 define
 a.SINGLEDEL.2:
@@ -1452,8 +1333,7 @@ iter seq=3
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 define
 a.SET.2:b
@@ -1466,8 +1346,7 @@ next
 ----
 a: (b, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 1B values)
 
 define
 a.SINGLEDEL.2:
@@ -1481,8 +1360,7 @@ next
 ----
 b: (c, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 1 times (3 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 2B values)
 
 define
 a.SINGLEDEL.3:
@@ -1494,8 +1372,7 @@ iter
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 2B values)
 
 define
 a.SINGLEDEL.3:
@@ -1507,8 +1384,7 @@ iter seq=4
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 3, key-bytes 3B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (3 internal); blocks: 0B (0B cached), read time 0s; points: 3 (3B keys, 2B values)
 
 define
 a.SINGLEDEL.4:
@@ -1521,8 +1397,7 @@ iter seq=5
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 6B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (4 internal); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 6B values)
 
 define
 a.SINGLEDEL.4:
@@ -1535,8 +1410,7 @@ iter seq=5
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 6B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (4 internal); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 6B values)
 
 define
 a.SINGLEDEL.4:
@@ -1549,8 +1423,7 @@ iter seq=5
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 4, key-bytes 4B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (4 internal); blocks: 0B (0B cached), read time 0s; points: 4 (4B keys, 3B values)
 
 define
 a.SINGLEDEL.3:
@@ -1561,8 +1434,7 @@ iter seq=4
 first
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 3B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (2 internal); blocks: 0B (0B cached), read time 0s; points: 2 (2B keys, 3B values)
 
 # Exercise iteration with limits, when there are no deletes.
 define
@@ -1604,8 +1476,7 @@ a: valid (a, .)
 . exhausted
 . at-limit
 a: valid (a, .)
-stats: (interface (dir, seek, step): (fwd, 1, 6), (rev, 1, 7)), (internal (dir, seek, step): (fwd, 3, 3), (rev, 1, 6)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 11, key-bytes 11B, value-bytes 11B, tombstoned 0)))
+stats: seeked 2 times (1 fwd/1 rev, internal: 3 fwd/1 rev); stepped 13 times (6 fwd/7 rev, internal: 3 fwd/6 rev); blocks: 0B (0B cached), read time 0s; points: 11 (11B keys, 11B values)
 
 # Exercise iteration with limits when we have deletes.
 
@@ -1652,8 +1523,7 @@ d: valid (d, .)
 . exhausted
 d: valid (d, .)
 . exhausted
-stats: (interface (dir, seek, step): (fwd, 1, 10), (rev, 0, 5)), (internal (dir, seek, step): (fwd, 3, 13), (rev, 1, 8)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 21, key-bytes 21B, value-bytes 14B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 3 fwd/1 rev); stepped 15 times (10 fwd/5 rev, internal: 13 fwd/8 rev); blocks: 0B (0B cached), read time 0s; points: 21 (21B keys, 14B values)
 
 iter seq=4
 seek-ge-limit b d
@@ -1665,8 +1535,7 @@ next-limit e
 . at-limit
 . at-limit
 d: valid (d, .)
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 1, 9), (rev, 0, 5)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 15, key-bytes 15B, value-bytes 9B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 3 times (2 fwd/1 rev, internal: 9 fwd/5 rev); blocks: 0B (0B cached), read time 0s; points: 15 (15B keys, 9B values)
 
 iter seq=4
 seek-lt-limit d c
@@ -1682,8 +1551,7 @@ next-limit b
 a: valid (a, .)
 . exhausted
 a: valid (a, .)
-stats: (interface (dir, seek, step): (fwd, 0, 1), (rev, 1, 4)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 1, 5)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 6, key-bytes 6B, value-bytes 4B, tombstoned 0)))
+stats: seeked 1 times (0 fwd/1 rev, internal: 1 fwd/1 rev); stepped 5 times (1 fwd/4 rev, internal: 0 fwd/5 rev); blocks: 0B (0B cached), read time 0s; points: 6 (6B keys, 4B values)
 
 # NB: Zero values are skipped by deletable merger.
 define merger=deletable
@@ -1709,8 +1577,7 @@ prev
 .
 .
 .
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 2)), (internal (dir, seek, step): (fwd, 1, 8), (rev, 1, 8)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 16, key-bytes 16B, value-bytes 24B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); stepped 4 times (2 fwd/2 rev, internal: 8 fwd/8 rev); blocks: 0B (0B cached), read time 0s; points: 16 (16B keys, 24B values)
 
 iter seq=4
 seek-ge a
@@ -1724,5 +1591,4 @@ b: (3, .)
 .
 b: (3, .)
 a: (2, .)
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 2)), (internal (dir, seek, step): (fwd, 1, 6), (rev, 1, 6)),
-(internal-stats: (block-bytes: (total 0B, cached 0B, read-time 0s)), (points: (count 16, key-bytes 16B, value-bytes 24B, tombstoned 0)))
+stats: seeked 1 times (1 fwd/0 rev, internal: 1 fwd/1 rev); stepped 4 times (2 fwd/2 rev, internal: 6 fwd/6 rev); blocks: 0B (0B cached), read time 0s; points: 16 (16B keys, 24B values)
diff --git a/testdata/iterator_seek_opt b/testdata/iterator_seek_opt
index 1bbc6ce5..cbfef941 100644
--- a/testdata/iterator_seek_opt
+++ b/testdata/iterator_seek_opt
@@ -28,7 +28,7 @@ iter
 seek-ge a
 ----
 a: (4, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal)
 SeekGEs with trySeekUsingNext: 0
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -36,7 +36,7 @@ iter
 seek-ge b
 ----
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 2, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 2, 0), (rev, 0, 0))
+stats: seeked 2 times (2 internal); stepped 0 times (0 internal)
 SeekGEs with trySeekUsingNext: 2
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -44,7 +44,7 @@ iter
 seek-ge c
 ----
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 3, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 3, 0), (rev, 0, 0))
+stats: seeked 3 times (3 internal); stepped 0 times (0 internal)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -54,7 +54,7 @@ iter
 seek-ge bb
 ----
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 4, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 4, 0), (rev, 0, 0))
+stats: seeked 4 times (4 internal); stepped 0 times (0 internal)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -65,7 +65,7 @@ iter
 seek-ge bbb
 ----
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 5, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 4, 0), (rev, 0, 0))
+stats: seeked 5 times (4 internal); stepped 0 times (0 internal)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -77,7 +77,7 @@ seek-ge e
 ----
 d: (2, .)
 e: (1, .)
-stats: (interface (dir, seek, step): (fwd, 6, 1), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 5, 1), (rev, 0, 0))
+stats: seeked 6 times (5 internal); stepped 1 times (1 internal)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -87,7 +87,7 @@ seek-ge b
 ----
 d: (2, .)
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 7, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 6, 1), (rev, 0, 3))
+stats: seeked 7 times (6 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -97,7 +97,7 @@ iter
 seek-prefix-ge a
 ----
 a: (4, .)
-stats: (interface (dir, seek, step): (fwd, 8, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 7, 1), (rev, 0, 3))
+stats: seeked 8 times (7 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 0
 
@@ -105,7 +105,7 @@ iter
 seek-prefix-ge b
 ----
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 9, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 8, 1), (rev, 0, 3))
+stats: seeked 9 times (8 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 2
 
@@ -113,7 +113,7 @@ iter
 seek-prefix-ge c
 ----
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 10, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 9, 1), (rev, 0, 3))
+stats: seeked 10 times (9 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -123,7 +123,7 @@ iter
 seek-prefix-ge bb
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 11, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 10, 1), (rev, 0, 3))
+stats: seeked 11 times (10 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -137,7 +137,7 @@ seek-ge a
 ----
 .
 a: (4, .)
-stats: (interface (dir, seek, step): (fwd, 12, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 11, 1), (rev, 0, 3))
+stats: seeked 12 times (11 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -147,7 +147,7 @@ seek-ge b
 ----
 .
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 13, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 12, 1), (rev, 0, 3))
+stats: seeked 13 times (12 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 4
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -155,7 +155,7 @@ iter
 seek-ge bb
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 14, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 13, 1), (rev, 0, 3))
+stats: seeked 14 times (13 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 5
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -165,7 +165,7 @@ seek-ge bbb
 ----
 .
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 15, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 14, 1), (rev, 0, 3))
+stats: seeked 15 times (14 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 5
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -173,7 +173,7 @@ iter
 seek-ge cc
 ----
 .
-stats: (interface (dir, seek, step): (fwd, 16, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 15, 1), (rev, 0, 3))
+stats: seeked 16 times (15 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 6
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -188,7 +188,7 @@ seek-ge b
 ----
 .
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 17, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 16, 1), (rev, 0, 3))
+stats: seeked 17 times (16 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 6
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -198,7 +198,7 @@ seek-ge c
 ----
 .
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 18, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 17, 1), (rev, 0, 3))
+stats: seeked 18 times (17 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 6
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -210,7 +210,7 @@ seek-ge d
 ----
 .
 d: (2, .)
-stats: (interface (dir, seek, step): (fwd, 19, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 18, 1), (rev, 0, 3))
+stats: seeked 19 times (18 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 8
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -220,7 +220,7 @@ seek-prefix-ge b
 ----
 .
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 20, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 19, 1), (rev, 0, 3))
+stats: seeked 20 times (19 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 8
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -230,7 +230,7 @@ seek-prefix-ge c
 ----
 .
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 21, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 20, 1), (rev, 0, 3))
+stats: seeked 21 times (20 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 8
 SeekPrefixGEs with trySeekUsingNext: 4
 
@@ -242,7 +242,7 @@ seek-prefix-ge d
 ----
 .
 d: (2, .)
-stats: (interface (dir, seek, step): (fwd, 22, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 21, 1), (rev, 0, 3))
+stats: seeked 22 times (21 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 8
 SeekPrefixGEs with trySeekUsingNext: 6
 
@@ -258,7 +258,7 @@ seek-ge b
 ----
 .
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 23, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 22, 1), (rev, 0, 3))
+stats: seeked 23 times (22 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 8
 SeekPrefixGEs with trySeekUsingNext: 6
 
@@ -268,7 +268,7 @@ seek-ge c
 ----
 .
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 24, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 23, 1), (rev, 0, 3))
+stats: seeked 24 times (23 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 8
 SeekPrefixGEs with trySeekUsingNext: 6
 
@@ -280,7 +280,7 @@ seek-ge d
 ----
 .
 d: (2, .)
-stats: (interface (dir, seek, step): (fwd, 25, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 24, 1), (rev, 0, 3))
+stats: seeked 25 times (24 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 10
 SeekPrefixGEs with trySeekUsingNext: 6
 
@@ -290,7 +290,7 @@ seek-prefix-ge b
 ----
 .
 b: (1, .)
-stats: (interface (dir, seek, step): (fwd, 26, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 25, 1), (rev, 0, 3))
+stats: seeked 26 times (25 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 10
 SeekPrefixGEs with trySeekUsingNext: 6
 
@@ -300,7 +300,7 @@ seek-prefix-ge c
 ----
 .
 c: (1, .)
-stats: (interface (dir, seek, step): (fwd, 27, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 26, 1), (rev, 0, 3))
+stats: seeked 27 times (26 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 10
 SeekPrefixGEs with trySeekUsingNext: 6
 
@@ -312,6 +312,6 @@ seek-prefix-ge d
 ----
 .
 d: (2, .)
-stats: (interface (dir, seek, step): (fwd, 28, 1), (rev, 0, 1)), (internal (dir, seek, step): (fwd, 27, 1), (rev, 0, 3))
+stats: seeked 28 times (27 internal); stepped 2 times (1 fwd/1 rev, internal: 1 fwd/3 rev)
 SeekGEs with trySeekUsingNext: 10
 SeekPrefixGEs with trySeekUsingNext: 8
diff --git a/testdata/iterator_stats b/testdata/iterator_stats
index 37eb9f82..5579b4d8 100644
--- a/testdata/iterator_stats
+++ b/testdata/iterator_stats
@@ -17,8 +17,7 @@ stats
 a: (1, .)
 c: (2, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 57B, cached 57B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 2 times (2 internal); blocks: 57B (57B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 # Perform the same operation again with a new iterator. It should yield
 # identical statistics.
@@ -32,8 +31,7 @@ stats
 a: (1, .)
 c: (2, .)
 .
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 57B, cached 57B, read-time 0s)), (points: (count 2, key-bytes 2B, value-bytes 2B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 2 times (2 internal); blocks: 57B (57B cached), read time 0s; points: 2 (2B keys, 2B values)
 
 build ext2
 set d@10 d10
@@ -63,18 +61,13 @@ next
 stats
 ----
 c: (2, .)
-stats: (interface (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 0), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 57B, cached 57B, read-time 0s)), (points: (count 1, key-bytes 1B, value-bytes 1B, tombstoned 0)))
+stats: seeked 1 times (1 internal); stepped 0 times (0 internal); blocks: 57B (57B cached), read time 0s; points: 1 (1B keys, 1B values)
 d@10: (d10, .)
 d@9: (d9, .)
-stats: (interface (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 2), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 157B, cached 147B, read-time 0s)), (points: (count 3, key-bytes 8B, value-bytes 6B, tombstoned 0)), (separated: (count 1, bytes 2B, fetched 2B)))
+stats: seeked 1 times (1 internal); stepped 2 times (2 internal); blocks: 157B (147B cached), read time 0s; points: 3 (8B keys, 6B values); separated: 1 (2B, 2B fetched)
 d@8: (d8, .)
-stats: (interface (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 3), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 157B, cached 147B, read-time 0s)), (points: (count 4, key-bytes 11B, value-bytes 8B, tombstoned 0)), (separated: (count 2, bytes 4B, fetched 4B)))
+stats: seeked 1 times (1 internal); stepped 3 times (3 internal); blocks: 157B (147B cached), read time 0s; points: 4 (11B keys, 8B values); separated: 2 (4B, 4B fetched)
 e@20: (e20, .)
-stats: (interface (dir, seek, step): (fwd, 1, 4), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 4), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 157B, cached 147B, read-time 0s)), (points: (count 5, key-bytes 15B, value-bytes 11B, tombstoned 0)), (separated: (count 2, bytes 4B, fetched 4B)))
+stats: seeked 1 times (1 internal); stepped 4 times (4 internal); blocks: 157B (147B cached), read time 0s; points: 5 (15B keys, 11B values); separated: 2 (4B, 4B fetched)
 e@18: (e18, .)
-stats: (interface (dir, seek, step): (fwd, 1, 5), (rev, 0, 0)), (internal (dir, seek, step): (fwd, 1, 5), (rev, 0, 0)),
-(internal-stats: (block-bytes: (total 157B, cached 147B, read-time 0s)), (points: (count 6, key-bytes 19B, value-bytes 13B, tombstoned 0)), (separated: (count 3, bytes 7B, fetched 7B)))
+stats: seeked 1 times (1 internal); stepped 5 times (5 internal); blocks: 157B (147B cached), read time 0s; points: 6 (19B keys, 13B values); separated: 3 (7B, 7B fetched)
diff --git a/testdata/level_iter_boundaries b/testdata/level_iter_boundaries
index 413090bd..dec8d90c 100644
--- a/testdata/level_iter_boundaries
+++ b/testdata/level_iter_boundaries
@@ -7,11 +7,31 @@ b.RANGEDEL.2:d
 iter
 first
 next
+next
+next
+next
+next
+next
 last
 prev
+prev
+prev
+prev
+prev
+prev
 ----
+a#72057594037927935,RANGEDEL:
+b#72057594037927935,RANGEDEL:
+b#72057594037927935,RANGEDEL:
+c#72057594037927935,RANGEDEL:
+c#72057594037927935,RANGEDEL:
 d#72057594037927935,RANGEDEL:
 .
+d#72057594037927935,RANGEDEL:
+c#72057594037927935,RANGEDEL:
+c#72057594037927935,RANGEDEL:
+b#72057594037927935,RANGEDEL:
+b#72057594037927935,RANGEDEL:
 a#72057594037927935,RANGEDEL:
 .
 
@@ -21,10 +41,10 @@ seek-ge d
 seek-lt b
 prev
 ----
-d#72057594037927935,RANGEDEL:
+c#72057594037927935,RANGEDEL:
 .
+b#72057594037927935,RANGEDEL:
 a#72057594037927935,RANGEDEL:
-.
 
 iter
 seek-prefix-ge c
@@ -32,10 +52,10 @@ seek-prefix-ge d
 seek-lt b
 prev
 ----
-d#72057594037927935,RANGEDEL:
+c#72057594037927935,RANGEDEL:
 .
+b#72057594037927935,RANGEDEL:
 a#72057594037927935,RANGEDEL:
-.
 
 iter
 seek-ge e
@@ -77,8 +97,10 @@ first
 next
 next
 next
+next
 ----
 a#1,SET:a
+b#72057594037927935,RANGEDEL:
 c#72057594037927935,RANGEDEL:
 c#3,SET:c
 .
@@ -88,8 +110,10 @@ last
 prev
 prev
 prev
+prev
 ----
 c#3,SET:c
+c#72057594037927935,RANGEDEL:
 b#72057594037927935,RANGEDEL:
 a#1,SET:a
 .
@@ -107,17 +131,23 @@ iter
 first
 next
 next
+next
 ----
 a#1,SET:b
+b#72057594037927935,RANGEDEL:
 c#72057594037927935,RANGEDEL:
 .
 
 iter
 last
 prev
+prev
+prev
 ----
+c#72057594037927935,RANGEDEL:
+b#72057594037927935,RANGEDEL:
 a#1,SET:b
-a#72057594037927935,RANGEDEL:
+.
 
 clear
 ----
@@ -132,17 +162,21 @@ iter
 first
 next
 next
+next
 ----
+a#72057594037927935,RANGEDEL:
+b#72057594037927935,RANGEDEL:
 c#2,SET:c
-c#72057594037927935,RANGEDEL:
 .
 
 iter
 last
 prev
 prev
+prev
 ----
 c#2,SET:c
+b#72057594037927935,RANGEDEL:
 a#72057594037927935,RANGEDEL:
 .
 
@@ -169,15 +203,15 @@ seek-ge d
 prev
 seek-lt e
 ----
+d#72057594037927935,RANGEDEL:
 d#3,SET:d
-e#72057594037927935,RANGEDEL:
+d#72057594037927935,RANGEDEL:
 d#3,SET:d
 e#72057594037927935,RANGEDEL:
 d#3,SET:d
+d#72057594037927935,RANGEDEL:
 c#72057594037927935,RANGEDEL:
-d#3,SET:d
-c#72057594037927935,RANGEDEL:
-d#3,SET:d
+e#72057594037927935,RANGEDEL:
 
 # Regression test to check that Seek{GE,LT}, First, and Last do not
 # have iteration bounds affected by SeekPrefixGE. Previously,
@@ -343,10 +377,8 @@ iter save continue
 set-bounds lower=e upper=f
 seek-ge e
 next
-next
 ----
 e#5,SET:z
-f#72057594037927935,RANGEDEL:
 .
 
 file-pos
@@ -356,10 +388,8 @@ file 000001 [loaded]
 iter save continue
 seek-lt f
 prev
-prev
 ----
 e#5,SET:z
-e#72057594037927935,RANGEDEL:
 .
 
 file-pos
@@ -370,10 +400,8 @@ iter save continue
 set-bounds lower=f upper=g
 seek-lt g
 prev
-prev
 ----
 f#6,SET:z
-f#72057594037927935,RANGEDEL:
 .
 
 file-pos
diff --git a/testdata/level_iter_seek b/testdata/level_iter_seek
index 83a3ae39..8a1e0e4d 100644
--- a/testdata/level_iter_seek
+++ b/testdata/level_iter_seek
@@ -33,14 +33,18 @@ h.SET.3:h
 
 iter
 seek-ge d
+next
+next
 ----
-f#5,SET:f / d-e:{(#6,RANGEDEL)}
+d#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+e#72057594037927935,RANGEDEL:
+f#5,SET:f
 
 iter
 set-bounds upper=d
 seek-ge d
 ----
-d#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+.
 
 iter
 set-bounds upper=d
@@ -48,28 +52,29 @@ seek-ge c
 next
 prev
 next
-next
 ----
 c#7,SET:c / d-e:{(#6,RANGEDEL)}
-d#72057594037927935,RANGEDEL:
+.
 c#7,SET:c
-d#72057594037927935,RANGEDEL:
 .
 
 # 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
+next
 ----
-f#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+d#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+d\x00#72057594037927935,RANGEDEL:
 
 # 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 boundary key.
+# cc returns a range deletion tombstone's start key.
 iter
 seek-prefix-ge aa
 seek-prefix-ge c
 seek-prefix-ge cc
+next
 seek-prefix-ge f
 seek-prefix-ge g
 seek-prefix-ge gg
@@ -77,7 +82,8 @@ seek-prefix-ge h
 ----
 .
 c#7,SET:c / d-e:{(#6,RANGEDEL)}
-f#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+d#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+cc\x00#72057594037927935,RANGEDEL:
 f#5,SET:f
 g#4,SET:g
 .
@@ -107,6 +113,8 @@ next
 stats
 next
 stats
+next
+stats
 reset-stats
 stats
 ----
@@ -117,9 +125,11 @@ b#8,SET:b
 {BlockBytes:0 BlockBytesInCache:0 BlockReadDuration:0s KeyBytes:0 ValueBytes:0 PointCount:0 PointsCoveredByRangeTombstones:0 SeparatedPointValue:{Count:0 ValueBytes:0 ValueBytesFetched:0}}
 c#7,SET:c
 {BlockBytes:56 BlockBytesInCache:0 BlockReadDuration:0s KeyBytes:0 ValueBytes:0 PointCount:0 PointsCoveredByRangeTombstones:0 SeparatedPointValue:{Count:0 ValueBytes:0 ValueBytesFetched:0}}
-f#5,SET:f
+d#72057594037927935,RANGEDEL:
 {BlockBytes:56 BlockBytesInCache:0 BlockReadDuration:0s KeyBytes:0 ValueBytes:0 PointCount:0 PointsCoveredByRangeTombstones:0 SeparatedPointValue:{Count:0 ValueBytes:0 ValueBytesFetched:0}}
-f#72057594037927935,RANGEDEL:
+e#72057594037927935,RANGEDEL:
+{BlockBytes:56 BlockBytesInCache:0 BlockReadDuration:0s KeyBytes:0 ValueBytes:0 PointCount:0 PointsCoveredByRangeTombstones:0 SeparatedPointValue:{Count:0 ValueBytes:0 ValueBytesFetched:0}}
+f#5,SET:f
 {BlockBytes:56 BlockBytesInCache:0 BlockReadDuration:0s KeyBytes:0 ValueBytes:0 PointCount:0 PointsCoveredByRangeTombstones:0 SeparatedPointValue:{Count:0 ValueBytes:0 ValueBytesFetched:0}}
 g#4,SET:g
 {BlockBytes:112 BlockBytesInCache:0 BlockReadDuration:0s KeyBytes:0 ValueBytes:0 PointCount:0 PointsCoveredByRangeTombstones:0 SeparatedPointValue:{Count:0 ValueBytes:0 ValueBytesFetched:0}}
@@ -133,7 +143,7 @@ iter
 set-bounds lower=d
 seek-lt d
 ----
-d#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+.
 
 iter
 set-bounds lower=d
@@ -142,15 +152,17 @@ prev
 next
 prev
 prev
+prev
 ----
 f#5,SET:f / d-e:{(#6,RANGEDEL)}
-d#72057594037927935,RANGEDEL:
+e#72057594037927935,RANGEDEL:
 f#5,SET:f
+e#72057594037927935,RANGEDEL:
 d#72057594037927935,RANGEDEL:
 .
 
 # Verify that First() in the presence of an upper-bound pauses at the
-# table containing the upper-bound.
+# first range deletion's start key.
 
 clear
 ----
@@ -165,10 +177,10 @@ iter
 set-bounds upper=f
 first
 ----
-f#72057594037927935,RANGEDEL:
+d#72057594037927935,RANGEDEL:
 
 # Verify that Last() in the presence of a lower-bound pauses at the
-# table containing the lower-bound.
+# last range deletion's end key.
 
 clear
 ----
@@ -182,7 +194,11 @@ d.RANGEDEL.6:e
 iter
 set-bounds lower=c
 last
+prev
+prev
 ----
+e#72057594037927935,RANGEDEL:
+d#72057594037927935,RANGEDEL:
 c#7,SET:c
 
 # Verify that a seek to a file with range tombstones as boundaries pauses on
@@ -210,9 +226,11 @@ seek-ge d
 prev
 next
 next
+next
 ----
-e#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
+d#72057594037927935,RANGEDEL: / d-e:{(#6,RANGEDEL)}
 c#7,SET:c
+d#72057594037927935,RANGEDEL:
 e#72057594037927935,RANGEDEL:
 f#8,SET:f
 
@@ -221,14 +239,16 @@ seek-lt b
 next
 prev
 prev
+prev
 ----
-a#72057594037927935,RANGEDEL: / a-b:{(#5,RANGEDEL)}
+b#72057594037927935,RANGEDEL: / a-b:{(#5,RANGEDEL)}
 c#7,SET:c
+b#72057594037927935,RANGEDEL:
 a#72057594037927935,RANGEDEL:
 .
 
-# Verify that prev when positioned at the largest boundary returns the
-# last key.
+# Verify that prev when positioned at the start bound of the first range
+# deletion returns the last key.
 
 clear
 ----
@@ -246,11 +266,11 @@ seek-ge d
 prev
 ----
 b#1,SET:b
-e#72057594037927935,RANGEDEL: / d-e:{(#2,RANGEDEL)}
+d#72057594037927935,RANGEDEL: / d-e:{(#2,RANGEDEL)}
 b#1,SET:b
 
-# Verify that next when positioned at the smallest boundary returns
-# the first key.
+# Verify that next when positioned at the start boundary of the first range
+# deletion returns the first key.
 
 clear
 ----
@@ -268,7 +288,7 @@ seek-lt d
 next
 ----
 d#2,SET:d
-a#72057594037927935,RANGEDEL: / a-b:{(#1,RANGEDEL)}
+b#72057594037927935,RANGEDEL: / a-b:{(#1,RANGEDEL)}
 d#2,SET:d
 
 # Verify SeekPrefixGE correctness with trySeekUsingNext=true
@@ -325,9 +345,9 @@ seek-prefix-ge j true
 a#1,SET:a / c-e:{(#4,RANGEDEL)}
 a#1,SET:a / c-e:{(#4,RANGEDEL)}
 b#2,SET:b / c-e:{(#4,RANGEDEL)}
-e#72057594037927935,RANGEDEL:
-e#72057594037927935,RANGEDEL: / c-e:{(#4,RANGEDEL)}
-e#72057594037927935,RANGEDEL: / c-e:{(#4,RANGEDEL)}
+c#72057594037927935,RANGEDEL:
+c#72057594037927935,RANGEDEL: / c-e:{(#4,RANGEDEL)}
+d#72057594037927935,RANGEDEL: / c-e:{(#4,RANGEDEL)}
 f#5,SINGLEDEL:
 g#6,SET:g
 h#7,SINGLEDEL:
@@ -384,14 +404,14 @@ j.SET.3:j
 iter
 seek-ge f
 next
-next
 ----
 f#5,SET:f
-f#72057594037927935,RANGEDEL:
 i#4,SET:i
 
-# The below count should be 2, as we skip over the rangekey-only file.
+# The below count should be 3, as we skip over the rangekey-only file.
+# TODO(jackson): When we stop opening range deletion iterators twice, this
+# should be 2.
 
 iters-created
 ----
-2
+3
diff --git a/testdata/merging_iter b/testdata/merging_iter
index c24465e9..3595ac62 100644
--- a/testdata/merging_iter
+++ b/testdata/merging_iter
@@ -58,11 +58,16 @@ seek-ge d
 next
 ----
 #  000000.rangeDelIter.opSpanSeekGE("d") = a-e:{(#8,RANGEDEL)}
+#  000000.rangeDelIter.opSpanSeekGE("d") = a-e:{(#8,RANGEDEL)}
+#  000001.rangeDelIter.opSpanSeekGE("e") = e-g:{(#8,RANGEDEL)}
 #  000001.rangeDelIter.opSpanSeekGE("e") = e-g:{(#8,RANGEDEL)}
-#  000000.rangeDelIter.opSpanSeekGE("e") = nil
+#  000000.rangeDelIter.opSpanSeekGE("d") = a-e:{(#8,RANGEDEL)}
+#  000000.rangeDelIter.opSpanNext() = nil
+#  000000.rangeDelIter.opSpanClose() = nil
 #  000000.rangeDelIter.opSpanClose() = nil
 #  000001.rangeDelIter.opSpanSeekGE("e") = e-g:{(#8,RANGEDEL)}
 e#10,SET:10
+#  000001.rangeDelIter.opSpanNext() = nil
 #  000001.rangeDelIter.opSpanSeekGE("g") = nil <err="injected error">
 err=injected error
 
@@ -820,16 +825,16 @@ seek-lt coo
 seek-prefix-ge b
 ----
 #  iter.First() = (a#30,SET,"30")
-#  rangedelIter.opSpanSeekGE("a") = nil <err="injected error">
+#  rangedelIter.opSpanFirst() = nil <err="injected error">
 err=injected error
 #  iter.Last() = (f#21,SET,"21")
-#  rangedelIter.opSpanSeekGE("f") = nil <err="injected error">
+#  rangedelIter.opSpanLast() = nil <err="injected error">
 err=injected error
 #  iter.SeekGE("boo") = (c#18,SET,"18")
 #  rangedelIter.opSpanSeekGE("boo") = nil <err="injected error">
 err=injected error
 #  iter.SeekLT("coo") = (c#18,SET,"18")
-#  rangedelIter.opSpanSeekGE("coo") = nil <err="injected error">
+#  rangedelIter.opSpanSeekLT("coo") = nil <err="injected error">
 err=injected error
 #  iter.SeekPrefixGE("b") = (b#19,SET,"19")
 #  rangedelIter.opSpanSeekGE("b") = nil <err="injected error">
diff --git a/testdata/metrics b/testdata/metrics
index 92161350..678d5180 100644
--- a/testdata/metrics
+++ b/testdata/metrics
@@ -15,7 +15,7 @@ total |  2.8K  2.7KB     0B    2.8K |     - | 2.8KB |  2.9K  2.8KB |  2.9K  2.8K
 WAL: 22 files (24B)  in: 25B  written: 26B (4% overhead)
 Flushes: 8
 Compactions: 5  estimated debt: 6B  in progress: 2 (7B)
-             default: 27  delete: 28  elision: 29  move: 30  read: 31  rewrite: 32  multi-level: 33
+             default: 27  delete: 28  elision: 29  move: 30  read: 31  rewrite: 32  copy: 33  multi-level: 34
 MemTables: 12 (11B)  zombie: 14 (13B)
 Zombie tables: 16 (15B, local: 30B)
 Backing tables: 1 (2.0MB)
@@ -66,7 +66,7 @@ total |     1   589B     0B       0 |     - |   28B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 17B  written: 28B (65% overhead)
 Flushes: 1
 Compactions: 0  estimated debt: 0B  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -121,7 +121,7 @@ total |     1   595B     0B       0 |     - |   56B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 34B  written: 56B (65% overhead)
 Flushes: 2
 Compactions: 1  estimated debt: 0B  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 2 (512KB)
 Zombie tables: 2 (1.2KB, local: 1.2KB)
 Backing tables: 0 (0B)
@@ -163,7 +163,7 @@ total |     1   595B     0B       0 |     - |   56B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 34B  written: 56B (65% overhead)
 Flushes: 2
 Compactions: 1  estimated debt: 0B  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 2 (512KB)
 Zombie tables: 2 (1.2KB, local: 1.2KB)
 Backing tables: 0 (0B)
@@ -202,7 +202,7 @@ total |     1   595B     0B       0 |     - |   56B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 34B  written: 56B (65% overhead)
 Flushes: 2
 Compactions: 1  estimated debt: 0B  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 2 (512KB)
 Zombie tables: 1 (589B, local: 589B)
 Backing tables: 0 (0B)
@@ -245,7 +245,7 @@ total |     1   595B     0B       0 |     - |   56B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 34B  written: 56B (65% overhead)
 Flushes: 2
 Compactions: 1  estimated debt: 0B  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -315,7 +315,7 @@ total |     4  2.6KB    38B       0 |     - |  149B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 116B  written: 149B (28% overhead)
 Flushes: 3
 Compactions: 1  estimated debt: 2.6KB  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -369,7 +369,7 @@ total |     3  2.0KB    41B       0 |     - |  149B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 116B  written: 149B (28% overhead)
 Flushes: 3
 Compactions: 2  estimated debt: 0B  in progress: 0 (0B)
-             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -472,7 +472,7 @@ total |     7  4.3KB    41B       0 |     - | 1.9KB |     3  1.7KB |     0     0
 WAL: 1 files (0B)  in: 176B  written: 187B (6% overhead)
 Flushes: 8
 Compactions: 2  estimated debt: 4.3KB  in progress: 0 (0B)
-             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (1.0MB)  zombie: 1 (1.0MB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -534,7 +534,7 @@ total |    10  6.1KB    41B       0 |     - | 2.0KB |     3  1.7KB |     0     0
 WAL: 1 files (0B)  in: 223B  written: 245B (10% overhead)
 Flushes: 9
 Compactions: 2  estimated debt: 6.1KB  in progress: 0 (0B)
-             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (1.0MB)  zombie: 1 (1.0MB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -610,7 +610,7 @@ total |    11  5.6KB    41B       2 |     - | 2.5KB |     4  2.3KB |     0     0
 WAL: 1 files (0B)  in: 223B  written: 245B (10% overhead)
 Flushes: 9
 Compactions: 2  estimated debt: 5.6KB  in progress: 0 (0B)
-             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 2  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (1.0MB)  zombie: 1 (1.0MB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 2 (1.2KB)
@@ -711,7 +711,7 @@ total |     6  3.8KB    41B       0 |     - | 3.1KB |     5  2.9KB |     0     0
 WAL: 1 files (0B)  in: 223B  written: 245B (10% overhead)
 Flushes: 9
 Compactions: 3  estimated debt: 0B  in progress: 0 (0B)
-             default: 3  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 3  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (1.0MB)  zombie: 1 (1.0MB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -764,7 +764,7 @@ total |     1   604B     0B       0 |     - |   38B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 27B  written: 38B (41% overhead)
 Flushes: 1
 Compactions: 0  estimated debt: 0B  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -800,8 +800,8 @@ total |     1   604B     0B       0 |     - |   38B |     0     0B |     0     0
 -------------------------------------------------------------------------------------------------------------------
 WAL: 1 files (0B)  in: 27B  written: 38B (41% overhead)
 Flushes: 1
-Compactions: 0  estimated debt: 0B  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+Compactions: 1  estimated debt: 0B  in progress: 0 (0B)
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 1  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -846,8 +846,8 @@ total |     2  1.2KB     0B       0 |     - |  627B |     1   589B |     0     0
 -------------------------------------------------------------------------------------------------------------------
 WAL: 1 files (0B)  in: 27B  written: 38B (41% overhead)
 Flushes: 1
-Compactions: 0  estimated debt: 1.2KB  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+Compactions: 1  estimated debt: 1.2KB  in progress: 0 (0B)
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 1  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -893,8 +893,8 @@ total |     3  1.7KB     0B       0 |     - |  655B |     1   589B |     0     0
 -------------------------------------------------------------------------------------------------------------------
 WAL: 1 files (0B)  in: 44B  written: 66B (50% overhead)
 Flushes: 2
-Compactions: 0  estimated debt: 1.7KB  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+Compactions: 1  estimated debt: 1.7KB  in progress: 0 (0B)
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 1  multi-level: 0
 MemTables: 1 (256KB)  zombie: 1 (256KB)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -932,7 +932,7 @@ total |     3  1.7KB     0B       0 |     - |    0B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 0B  written: 0B (0% overhead)
 Flushes: 0
 Compactions: 0  estimated debt: 1.7KB  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 0 (0B)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -969,7 +969,7 @@ total |     1   603B     0B       0 |     - |    0B |     0     0B |     0     0
 WAL: 1 files (0B)  in: 0B  written: 0B (0% overhead)
 Flushes: 0
 Compactions: 1  estimated debt: 0B  in progress: 0 (0B)
-             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 1  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 0 (0B)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
diff --git a/tool/testdata/db_lsm b/tool/testdata/db_lsm
index 7dff233b..58795366 100644
--- a/tool/testdata/db_lsm
+++ b/tool/testdata/db_lsm
@@ -25,7 +25,7 @@ total |     1   709B     0B       0 |     - |    0B |     0     0B |     0     0
 WAL: 0 files (0B)  in: 0B  written: 0B (0% overhead)
 Flushes: 0
 Compactions: 0  estimated debt: 0B  in progress: 0 (0B)
-             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  multi-level: 0
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
 MemTables: 1 (256KB)  zombie: 0 (0B)
 Zombie tables: 0 (0B, local: 0B)
 Backing tables: 0 (0B)
@@ -38,3 +38,40 @@ Snapshots: 0  earliest seq num: 0
 Table iters: 0
 Filter utility: 0.0%
 Ingestions: 0  as flushable: 0 (0B in 0 tables)
+
+db lsm --url
+../testdata/db-stage-4
+----
+----
+      |                             |       |       |   ingested   |     moved    |    written   |       |    amp
+level | tables  size val-bl vtables | score |   in  | tables  size | tables  size | tables  size |  read |   r   w
+------+-----------------------------+-------+-------+--------------+--------------+--------------+-------+---------
+    0 |     1   709B     0B       0 |  0.50 |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+    1 |     0     0B     0B       0 |  0.00 |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+    2 |     0     0B     0B       0 |  0.00 |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+    3 |     0     0B     0B       0 |  0.00 |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+    4 |     0     0B     0B       0 |  0.00 |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+    5 |     0     0B     0B       0 |  0.00 |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+    6 |     0     0B     0B       0 |     - |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+total |     1   709B     0B       0 |     - |    0B |     0     0B |     0     0B |     0     0B |    0B |   0  0.0
+-------------------------------------------------------------------------------------------------------------------
+WAL: 0 files (0B)  in: 0B  written: 0B (0% overhead)
+Flushes: 0
+Compactions: 0  estimated debt: 0B  in progress: 0 (0B)
+             default: 0  delete: 0  elision: 0  move: 0  read: 0  rewrite: 0  copy: 0  multi-level: 0
+MemTables: 1 (256KB)  zombie: 0 (0B)
+Zombie tables: 0 (0B, local: 0B)
+Backing tables: 0 (0B)
+Virtual tables: 0 (0B)
+Local tables size: 709B
+Block cache: 0 entries (0B)  hit rate: 0.0%
+Table cache: 0 entries (0B)  hit rate: 0.0%
+Secondary cache: 0 entries (0B)  hit rate: 0.0%
+Snapshots: 0  earliest seq num: 0
+Table iters: 0
+Filter utility: 0.0%
+Ingestions: 0  as flushable: 0 (0B in 0 tables)
+
+LSM viewer: https://raduberinde.github.io/lsmview/decode.html#eJyE0EFLw0AQBeC7v2J4uU5lN42W7lHsrTe9SSgTOi2hm13NRqGV_HdJCaUWMXvaxzfMwPuG1y_1Ce5t_G6CNAqHtbk3YHRSeR1ZKvVwKMBI9UnhFmbJSI14r6nbHPQIZxhe2v0lW8ZWO6nPJ2CGVziqpM1swc-rNc1oF2Nm5_yyeh0XO1qY5dMQ9CN8NsmRzWlGdjj8HuvQpf820JWN8ZTZ_KI3wyFSK2GvtB1qKPuy59sm7HUPf3g-4fMJLyb8YcIff3vJOOjx3HclLRi7GFH2dz8BAAD__2dulBM=
+----
+----
diff --git a/tool/testdata/manifest_summarize b/tool/testdata/manifest_summarize
index a4ec0716..4709f2c6 100644
--- a/tool/testdata/manifest_summarize
+++ b/tool/testdata/manifest_summarize
@@ -16,12 +16,14 @@ manifest summarize
 ./testdata/find-db/MANIFEST-000001
 ----
 MANIFEST-000001
-                     _______L0_______L1_______L2_______L3_______L4_______L5_______L6_____TOTAL
+                        _______L0_______L1_______L2_______L3_______L4_______L5_______L6_____TOTAL
 2023-12-12T18:55:00Z
-        Ingest+Flush      2.0KB        .        .        .        .        .     671B    2.7KB
-        Ingest+Flush    2.0KB/s        .        .        .        .        .   671B/s  2.7KB/s
-       Compact (out)      2.0KB        .        .        .        .        .        .    2.0KB
-       Compact (out)    2.0KB/s        .        .        .        .        .        .  2.0KB/s
+     Ingest+Flush Bytes      2.0KB        .        .        .        .        .     671B    2.7KB
+   Ingest+Flush Bytes/s    2.0KB/s        .        .        .        .        .   671B/s  2.7KB/s
+      Compact Out Bytes      2.0KB        .        .        .        .        .        .    2.0KB
+    Compact Out Bytes/s    2.0KB/s        .        .        .        .        .        .  2.0KB/s
+     Compact In Bytes/s          .        .        .        .        .        .  2.2KB/s  2.2KB/s
+     Compact In Files/s          .        .        .        .        .        .    3.0/s    3.0/s
 2023-12-12T18:55:00Z
 ---
 Estimated start time: 2023-12-12T18:55:00Z
@@ -32,12 +34,14 @@ manifest summarize
 ./testdata/mixed/MANIFEST-000001
 ----
 MANIFEST-000001
-                     _______L0_______L1_______L2_______L3_______L4_______L5_______L6_____TOTAL
+                        _______L0_______L1_______L2_______L3_______L4_______L5_______L6_____TOTAL
 2023-12-11T18:59:04Z
-        Ingest+Flush      1.0KB        .        .        .        .        .        .    1.0KB
-        Ingest+Flush    1.0KB/s        .        .        .        .        .        .  1.0KB/s
-       Compact (out)          .        .        .        .        .        .        .        .
-       Compact (out)          .        .        .        .        .        .        .        .
+     Ingest+Flush Bytes      1.0KB        .        .        .        .        .        .    1.0KB
+   Ingest+Flush Bytes/s    1.0KB/s        .        .        .        .        .        .  1.0KB/s
+      Compact Out Bytes          .        .        .        .        .        .        .        .
+    Compact Out Bytes/s          .        .        .        .        .        .        .        .
+     Compact In Bytes/s          .        .        .        .        .        .        .        .
+     Compact In Files/s          .        .        .        .        .        .        .        .
 2023-12-11T18:59:04Z
 ---
 Estimated start time: 2023-12-11T18:59:04Z