Repository: cockroachdb/pebble

Issue:
WAL corruptions are silently ignored
When replaying WAL, ErrInvalidChunk is returned if the recorded CRC doesn't match the data (say caused by disk hardware), code is [here](https://github.com/cockroachdb/pebble/blob/master/internal/record/record.go#L249). 

It causes the replayWAL method to stop processing further WAL records without notifying the user of such corruption, code is [here](https://github.com/cockroachdb/pebble/blob/master/open.go#L337). 

I'd expect pebble.Open() to return an error for such corruption.  

Jira issue: PEBBLE-179

Discussion:
@petermattis: @lni You are correct about the Pebble behavior. This is intentional and I believe matches the behavior of RocksDB's [`kPointInTimeRecovery`](https://github.com/facebook/rocksdb/blob/master/include/rocksdb/options.h#L332) `WALRecoveryMode`:

```
  // Recover to point-in-time consistency (default)
  // We stop the WAL playback on discovering WAL inconsistency
  // Use case : Ideal for systems that have disk controller cache like
  // hard disk, SSD without super capacitor that store related data
  kPointInTimeRecovery = 0x02,
```

> I'd expect pebble.Open() to return an error for such corruption.

Note that this case can occur without actual hardware corruption due to WAL recycling.@lni: @petermattis thanks for the quick response! 

Will you use pebble to store Raft logs in CockroachDB? For such use case I guess RocksDB's kAbsoluteConsistency mode is required?  
@petermattis: > Will you use pebble to store Raft logs in CockroachDB? 

Yes.

> For such use case I guess RocksDB's kAbsoluteConsistency mode is required?

Why? `kAbsoluteConsistency` requires that the DB was "shut down cleanly". Using it would prevent a DB from recovering if a node crashed. The requirement from Raft is that when a piece of data is reported as synced, it doesn't disappear. That is what `kPointInTimeRecovery` accomplishes in the absence of hardware corruption. `kAbsoluteConsistency` is providing something much stricter: there are zero errors when recovering the WAL. That means the previous incarnation could not have crashed in the middle of writing to the WAL, which is very high bar to clear. @lni: >  The requirement from Raft is that when a piece of data is reported as synced, it doesn't disappear. That is what `kPointInTimeRecovery` accomplishes in the absence of hardware corruption. 

Thanks for the detailed explanation.  I spent some time reading various papers/articles on this topic, it is more complicated than what I previously knew. 

My concern is still the hardware corruption part - all records after a corrupted record (say a bit flip) are silently dropped and the corruption itself is never reported. This is going to cause problems for Raft. @ajkr: > My concern is still the hardware corruption part - all records after a corrupted record (say a bit flip) are silently dropped and the corruption itself is never reported. This is going to cause problems for Raft.

It's a good observation. FWIW, RocksDB's `kTolerateCorruptedTailRecords`, despite its name, only tolerates some corrupted tail records. In particular, it tolerates those that imply they were unfinished due to a crash, like the WAL ends with a partial record. However it is not flexible. Some errors, like checksum corruption, might be caused by a crash during write or might be caused by a bit flip. In those cases, `kTolerateCorruptedTailRecords` acts conservatively and returns an error, whereas `kPointInTimeRecovery` logs a warning and returns success.@ajkr: `kPointInTimeRecovery` seems ideal, IMO, as long as there's a check to ensure recovery didn't lead to an inconsistent state (not sure exactly what this means for Raft), and there's a way to replicate the lost data. Lacking either of those, `kTolerateCorruptedTailRecords` with WAL recycling disabled seems safer as DBs with potentially inconsistent/missing state should be thrown away. I'm not that familiar with Raft to know whether the conditions are met.@lni: @ajkr Thanks for the info. 

I think kPointInTimeRecovery is not ideal for Raft. According to SSD vendor [specs](https://www.intel.com/content/www/us/en/products/memory-storage/solid-state-drives/data-center-ssds/d7-series/dc-p4510-series/dc-p4510-4tb-2-5inch-3d2.html), uncorrectable bit error happens at certain advertised rate (e.g. 1 sector per 1^17 bits read for Intel enterprise SSDs). This can cause some Raft log entries reported as persisted to disappear after a reboot. As mentioned by @petermattis above, that directly violates a core requirement of Raft. 

Any plan to support something similar to kTolerateCorruptedTailRecords as an option? 




 

 @petermattis: @lni Raft lives in a blissful world where data that has been written durably to disk is never later corrupted. CockroachDB adds additional verification of the consistency of Raft replicas on top of Raft, performing these consistency checks once every 24 hours. The story of what happens when there is an inconsistency is still a bit lacking, though an attempt is made to evict the replica in the minority and consider the majority to be correct. This isn't a Raft-level concept, but something CRDB built on top of Raft.

While `kTolerateCorruptedTailRecords` can allow recovery of more data in the RocksDB/Pebble WAL, it doesn't necessarily improve the store for Raft. Raft expects any committed log entry to not disappear. If an entry becomes corrupted and is no longer readable, it isn't clear at all what should be done with the subsequent entries. In CRDB, the Raft log contains KV operations. Missing one of those operations would lead to corruption of the KV state.

Recovery of the RocksDB/Pebble WAL is also only one aspect of Raft replica state. For the most part, the RockDB/Pebble WAL is never read as records are written to it and also written to sstables. Corruption of a Raft log record written to an sstable is the more likely scenario given that the sstable data is rewritten many times during compactions and there is only a small period of time where a record will only be written to the WAL. The Raft library we're using (`etcd/raft`) doesn't know how to handle a single corrupted Raft log entry, so instead we indicate that the Raft log is truncated at the point. Could something better be done here? Possibly, though it makes the recovery mechanism of the consensus algorithm more complex than it already is.

> Any plan to support something similar to kTolerateCorruptedTailRecords as an option?

Not at this time. We wouldn't want to add support for it until we had a plan to use it for CRDB. I don't currently see how we'd use `kTolerateCorruptedTailRecords` for CRDB, though I will pass along the idea that perhaps there is something better to be done with recovery from disk-level corruption.@sumeerbhola: @petermattis Regarding just the WAL, where we can't distinguish between a harmless corruption and a harmful one, I wonder if the story becomes slightly simpler when we separate the (Pebble/Rocsk)DB instances that store the state machine with no WAL, and the raft state, which will have a WAL.

The only raft state in the state-machine-db will be the `RaftAppliedIndex`, which lags the raft entries that this replica knows about. The raft-db may have some part of its WAL corrupted and then the node crashes -- when the node recovers, `kPointInTimeRecovery` will make it appear to have a shorter raft log. I briefly discussed with @ajwerner: (a) the simpler part here is to talk to the raft leader and if it thinks this replica should have more entries it gets these new entries from the leader, (b) the difficult (impossible?) part is that these missing entries at this replica were needed to make those entries committed -- but this should only be an issue if the leader is new and needs the state of this recovering replica, and so unless we have correlated failures we should be ok?
@petermattis: > so unless we have correlated failures we should be ok?

I'm not seeing any problems with this logic. I'll think about this more, though.@lni: @petermattis Thanks a lot for the detailed reply above, I spent a little bit more time to dig deeper on this. 

I just read [this](https://www.usenix.org/node/210499) FAST2018 paper on how to handle & recover from raft log corruptions. It covers quite a few issues we discussed here in this thread. 

It also has a taxonomy on possible responses to corruptions, kPointInTimeRecovery is basically the 'truncate' approach described in the paper and the paper provided an example (figure 2) on how such approach can cause committed entries to disappear from the entire cluster. 'Truncate' is thus marked as unsafe. 

I won't worry too much on how to recover from any hardware corrupted log. When there is a checksum mismatch that can be proven to be actual hardware data corruption (e.g. when sync write = true and  the next record is complete and valid), I just want to be notified so I can at least crash to ensure safety. 

> We wouldn't want to add support for it until we had a plan to use it for CRDB.

For PRs relate to new features that won't be used by CRDB, will they be considered & accepted? 



 

@petermattis: The FAST paper is a good read and I think it highlights that the work involve traverses both the storage layer and the replication layer. Naively skipping over corrupted log records at the storage layer is extremely problematic (which is what `kTolerateCorruptedTailRecords` does) can cause problems for the replication layer. Requiring absolute consistency in the log is also problematic. Incorporating protocol-aware recovery is on CockroachDB's radar, but not something that has been prioritized yet.

> I won't worry too much on how to recover from any hardware corrupted log. When there is a checksum mismatch that can be proven to be actual hardware data corruption (e.g. when sync write = true and the next record is complete and valid), I just want to be notified so I can at least crash to ensure safety.

Do you have a proposal for how to do this? The FAST paper uses knowledge at the replication layer to make this determination. 

> For PRs relate to new features that won't be used by CRDB, will they be considered & accepted?

From the README: `Pebble intentionally does not aspire to include every feature in RocksDB and is specifically targetting the use case and feature set needed by CockroachDB`. I think this can be generalized that there is a reluctance to add functionality that won't be used by CockroachDB. Why? Each additional feature and bit of functionality adds maintenance burden. The cross-section of all of the features in RocksDB has grown to be enormous and there are often surprising incompatibilities and gotchas. @lni: > Naively skipping over corrupted log records at the storage layer is extremely problematic (which is what kTolerateCorruptedTailRecords does)

My understanding is that kPointInTimeRecovery silently truncates the WAL from any hardware corrupted record while kTolerateCorruptedTailRecords returns an error on such hardware corrupted record.  As explained by @ajkr above, kTolerateCorruptedTailRecords only skips partially written tail record at the end of the WAL caused by a crash.  Anything I missed here? 

> The FAST paper uses knowledge at the replication layer to make this determination.

Section 3.3.3 "Disentangling Crashes and Corruption in Log" of the paper describes how hardware corruptions is determined in the storage layer. Could you please shed some light on which part of the paper uses replication layer info to determine hardware corruption? 

> I think this can be generalized that there is a reluctance to add functionality that won't be used by CockroachDB.

Fair enough. Thanks for the info and this wonderful project!  @bra-fsn: Sligthly related to this conversation: @petermattis what happens if we use CRDB (be it on Rocksdb or Pebble) on a checksumming file system (like ZFS), which will return an error if something wants to read a bad block (instead of returning corrupted data)?@github-actions: We have marked this issue as stale because it has been inactive for
18 months. If this issue is still relevant, removing the stale label
or adding a comment will keep it active. Otherwise, we'll close it
in 10 days to keep the issue queue tidy. Thank you for your
contribution to Pebble!@sumeerbhola: TODO: try to summarize this discussion here, or in another issue. It isn't clear we want to do anything here, but I've swapped out most state on this discussion and there is enough here to (re)consume that we shouldn't close this issue yet.@nicktrav: > the story becomes slightly simpler when we separate the (Pebble/Rocsk)DB instances that store the state machine with no WAL, and the raft state, which will have a WAL.

@sumeerbhola - given your recent work / investigation in this space, I'm wondering if you have an inclination one way or the other?

cc: @tbg @erikgrinaker for visibility, in case there are strong feelings here from a CRDB replication perspective.@jbowens: There's been one development since this discussion was active: Since #871, Pebble has begun requiring closed WALs to have clean tails. This guards against corruption in WALs other than the last active one, but does nothing for the most recent WAL. In the most recent WAL, Pebble continues to assume an invalid entry is the end the log.

As I understand it, there's two broad categories of solutions discussed here:

1. Replication independent: The relevant part of the linked FAST2018 paper suggests introducing a second `write()` to the WAL-write code path. If I understand correctly, the new write saves a small record to a secondary, parallel log file recording the expectation of the commit of the log entry in the primary log file. This secondary log file can be used on WAL replay to detect corruption in all log entries except the most recently written entry.
2. Replication dependent: After replay, we can detect whether we're missing Raft log entries due to a WAL that was truncated due to corruption.@jbowens: In Cockroach, we do expect missing raft entries to be detected promptly by the Raft implementation, which makes this not a priority from Cockroach's perspective.@tbg: > In Cockroach, we do expect missing raft entries to be detected promptly by the Raft implementation, which makes this not a priority from Cockroach's perspective.

How so? The replication layer relies on durability of the storage engine. Silently discarded data would not necessarily be detected and could lead to basically any split brain scenario if we're exceptionally unlucky.@jbowens: This was derived from [Ben’s slack comment](https://cockroachlabs.slack.com/archives/C0KB9Q03D/p1664897955407249). I don’t personally have any context on how likely it is for dropped raft log entries to go undetected.@sumeerbhola: @tbg split brain because of raft HardState loss? If we only lost raft log entries, am I correct in assuming that we would quickly detect this, so it would not be silent corruption? And for the state machine we can afford to lose a suffix, since we can reapply from the raft log.@tbg: Maybe I'm misunderstanding the question, but if a replica loses a tail of raft log entries, this is a classic violation of durability and wouldn't necessarily be detectable. For the most unlikely but most clear example, if all three replicas crash and lose the last log entry 100, then who should detect that? The entry is gone even though it was acked.
But even the loss of a vote on a single node can cause issues. If that node forgets its vote via an ill-timed crash, it can vote again. It can then help elect two leaders for the same term. In an academic sense, loss of durability is not generally detectable and pretty much anything follows from it if you allow for unfortunate circumstances.

Note that the ideas in https://github.com/cockroachdb/cockroach/issues/88442 are applicable here. We could say that if there's WAL tail corruption, we put all voters on this node into the mode described in the issue until they're known to have all indexes that they could've possibly acked in their previous life.@jbowens: > We could say that if there's WAL tail corruption, we put all voters on this node into the mode described in the issue until they're known to have all indexes that they could've possibly acked in their previous life.

Just to clarify a bit, we don't know when there's WAL tail corruption. We can't distinguish between WAL tail corruption and an in-progress but incomplete write. Maybe that's exactly what you're saying? After every crash, on boot all nodes' voters enter the described mode? Clean exits that closed the WAL by emitting an EOF trailer could skip the process on boot, because it's unambiguous where the true end of the WAL is.@tbg: See also:

<img width="947" alt="image" src="https://user-images.githubusercontent.com/5076964/201892568-91d7939f-6da4-425d-ae91-5547ec261681.png">

https://www.snia.org/sites/default/files/SDC/2018/presentations/DPCO/Alagappan_Ramnatthan_Protocol_Aware_Recovery_Consensus_Based_Storage.pdf

Judging from what was written above, it sounds as though pebble would stop replaying the WAL if there's a corrupted block somewhere in the middle of the committed part of the WAL. That could have bad consequences; are we sure we don't want to at least fail in such cases?@jbowens: With WAL reuse, there’s no way to know what the committed part of the WAL is. There are ways we can reduce the number scenarios where corruption goes undetected, but given that they can’t eliminate them, I think we should leverage replication.@jbowens: If we began padding WAL writes to block boundaries, like we would do if we began using direct I/O, we could read ahead to see if any future blocks entries successfully decode to a log entry encoding the same log number. If they do, we've detected corruption in the middle of the WAL. This still does nothing for the case where a section of the tail of the WAL is corrupted, in which case we can't distinguish between an in-progress, unacknowledged write and a committed, acknowledged but corrupted write.

To detect that without internode communication would require a second synchronous fsync, I think.@jbowens: >  we could read ahead to see if any future blocks entries successfully decode to a log entry encoding the same log number. If they do, we've detected corruption in the middle of the WAL.

I no longer think this is possible in the cloud environment as written. My understanding is that on at least one cloud provider, writes to blocks N and blocks N+1 in parallel will not necessarily complete in order; block N+1 may be persisted, while block N is not. We'd need to include in blocks some record of the offset to which we believe we've already durably synced. Then if we read ahead and observed an intact block that claimed we durably synced up to a higher offset than we successfully replayed, we know corruption occurred.

Solution patch:
diff --git a/compaction.go b/compaction.go
index 8af96432..b5f798aa 100644
--- a/compaction.go
+++ b/compaction.go
@@ -2562,8 +2562,10 @@ func (d *DB) runCopyCompaction(
 
 		// NB: external files are always virtual.
 		var wrote uint64
-		err = d.fileCache.withVirtualReader(inputMeta.VirtualMeta(), func(r sstable.VirtualReader) error {
+		err = d.fileCache.withVirtualReader(ctx, block.NoReadEnv, inputMeta.VirtualMeta(), func(r sstable.VirtualReader, _ block.ReadEnv) error {
 			var err error
+			// TODO(radu): plumb a ReadEnv to CopySpan (it could use the buffer pool
+			// or update category stats).
 			wrote, err = sstable.CopySpan(ctx,
 				src, r.UnsafeReader(), d.opts.MakeReaderOptions(),
 				w, d.opts.MakeWriterOptions(c.outputLevel.level, d.TableFormat()),
diff --git a/db.go b/db.go
index 6f260ae7..4a47f7c3 100644
--- a/db.go
+++ b/db.go
@@ -1619,11 +1619,21 @@ func (d *DB) NewIterWithContext(ctx context.Context, o *IterOptions) (*Iterator,
 // will not prevent memtables from being released or sstables from being
 // deleted. Instead, a snapshot prevents deletion of sequence numbers
 // referenced by the snapshot.
+//
+// There exists one violation of a Snapshot's point-in-time guarantee: An excise
+// (see DB.Excise and DB.IngestAndExcise) that occurs after the snapshot's
+// creation will be observed by iterators created from the snapshot after the
+// excise. See NewEventuallyFileOnlySnapshot for a variant of NewSnapshot that
+// provides a full point-in-time guarantee.
 func (d *DB) NewSnapshot() *Snapshot {
+	// TODO(jackson): Consider removal of regular, non-eventually-file-only
+	// snapshots given they no longer provide a true point-in-time snapshot of
+	// the database due to excises. If we had a mechanism to construct a maximal
+	// key range, we could implement NewSnapshot in terms of
+	// NewEventuallyFileOnlySnapshot and provide a true point-in-time guarantee.
 	if err := d.closed.Load(); err != nil {
 		panic(err)
 	}
-
 	d.mu.Lock()
 	s := &Snapshot{
 		db:     d,
diff --git a/event.go b/event.go
index 900203d6..023f2709 100644
--- a/event.go
+++ b/event.go
@@ -16,6 +16,8 @@ import (
 	"github.com/cockroachdb/pebble/internal/humanize"
 	"github.com/cockroachdb/pebble/internal/invariants"
 	"github.com/cockroachdb/pebble/internal/manifest"
+	"github.com/cockroachdb/pebble/objstorage"
+	"github.com/cockroachdb/pebble/objstorage/remote"
 	"github.com/cockroachdb/pebble/vfs"
 	"github.com/cockroachdb/redact"
 )
@@ -45,6 +47,35 @@ func formatFileNums(tables []TableInfo) string {
 	return buf.String()
 }
 
+// DataCorruptionInfo contains the information for a DataCorruption event.
+type DataCorruptionInfo struct {
+	// Path of the file that is corrupted. For remote files the path starts with
+	// "remote://".
+	Path     string
+	IsRemote bool
+	// Locator is only set when IsRemote is true (note that an empty Locator is
+	// valid even then).
+	Locator remote.Locator
+	// Bounds indicates the keyspace range that is affected.
+	Bounds base.UserKeyBounds
+	// Details of the error. See cockroachdb/error for how to format with or
+	// without redaction.
+	Details error
+}
+
+func (i DataCorruptionInfo) String() string {
+	return redact.StringWithoutMarkers(i)
+}
+
+// SafeFormat implements redact.SafeFormatter.
+func (i DataCorruptionInfo) SafeFormat(w redact.SafePrinter, _ rune) {
+	w.Printf("on-disk corruption: %s", redact.Safe(i.Path))
+	if i.IsRemote {
+		w.Printf(" (remote locator %q)", redact.Safe(i.Locator))
+	}
+	w.Printf("; bounds: %s; details: %+v", i.Bounds.String(), i.Details)
+}
+
 // LevelInfo contains info pertaining to a particular level.
 type LevelInfo struct {
 	Level  int
@@ -693,6 +724,10 @@ type EventListener struct {
 	// operation such as flush or compaction.
 	BackgroundError func(error)
 
+	// DataCorruption is invoked when an on-disk corruption is detected. It should
+	// not block, as it is called synchronously in read paths.
+	DataCorruption func(DataCorruptionInfo)
+
 	// CompactionBegin is invoked after the inputs to a compaction have been
 	// determined, but before the compaction has produced any output.
 	CompactionBegin func(CompactionInfo)
@@ -774,6 +809,15 @@ type EventListener struct {
 	PossibleAPIMisuse func(PossibleAPIMisuseInfo)
 }
 
+func backgroundError(logger Logger, err error) {
+	if errors.Is(err, ErrCancelledCompaction) {
+		// ErrCancelledCompaction is not an unexpected error, hence severity INFO.
+		logger.Infof("background error: %s", err)
+	} else {
+		logger.Errorf("background error: %s", err)
+	}
+}
+
 // EnsureDefaults ensures that background error events are logged to the
 // specified logger if a handler for those events hasn't been otherwise
 // specified. Ensure all handlers are non-nil so that we don't have to check
@@ -782,12 +826,21 @@ func (l *EventListener) EnsureDefaults(logger Logger) {
 	if l.BackgroundError == nil {
 		if logger != nil {
 			l.BackgroundError = func(err error) {
-				logger.Errorf("background error: %s", err)
+				backgroundError(logger, err)
 			}
 		} else {
 			l.BackgroundError = func(error) {}
 		}
 	}
+	if l.DataCorruption == nil {
+		if logger != nil {
+			l.DataCorruption = func(info DataCorruptionInfo) {
+				logger.Fatalf("%s", info)
+			}
+		} else {
+			l.DataCorruption = func(info DataCorruptionInfo) {}
+		}
+	}
 	if l.CompactionBegin == nil {
 		l.CompactionBegin = func(info CompactionInfo) {}
 	}
@@ -862,7 +915,10 @@ func MakeLoggingEventListener(logger Logger) EventListener {
 
 	return EventListener{
 		BackgroundError: func(err error) {
-			logger.Errorf("background error: %s", err)
+			backgroundError(logger, err)
+		},
+		DataCorruption: func(info DataCorruptionInfo) {
+			logger.Errorf("%s", info)
 		},
 		CompactionBegin: func(info CompactionInfo) {
 			logger.Infof("%s", info)
@@ -939,6 +995,10 @@ func TeeEventListener(a, b EventListener) EventListener {
 			a.BackgroundError(err)
 			b.BackgroundError(err)
 		},
+		DataCorruption: func(info DataCorruptionInfo) {
+			a.DataCorruption(info)
+			b.DataCorruption(info)
+		},
 		CompactionBegin: func(info CompactionInfo) {
 			a.CompactionBegin(info)
 			b.CompactionBegin(info)
@@ -1089,3 +1149,35 @@ func (r *lowDiskSpaceReporter) findThreshold(
 	}
 	return threshold, ok
 }
+
+func (d *DB) reportSSTableCorruption(meta *manifest.TableMetadata, err error) {
+	if invariants.Enabled && err == nil {
+		panic("nil error")
+	}
+	objMeta, lookupErr := d.objProvider.Lookup(base.FileTypeTable, meta.FileBacking.DiskFileNum)
+	if lookupErr != nil {
+		// If the object is not known to the provider, it must be a local object
+		// that was missing when we opened the store. Remote objects have their
+		// metadata in a catalog, so even if the backing object is deleted, the
+		// DiskFileNum would still be known.
+		objMeta = objstorage.ObjectMetadata{DiskFileNum: meta.FileBacking.DiskFileNum, FileType: base.FileTypeTable}
+	}
+	path := d.objProvider.Path(objMeta)
+	if objMeta.IsRemote() {
+		// Remote path (which include the locator and full path) might not always be
+		// safe.
+		err = errors.WithHintf(err, "path: %s", path)
+	} else {
+		// Local paths are safe: they start with the store directory and the
+		// filename is generated by Pebble.
+		err = errors.WithHintf(err, "path: %s", redact.Safe(path))
+	}
+	info := DataCorruptionInfo{
+		Path:     path,
+		IsRemote: objMeta.IsRemote(),
+		Locator:  objMeta.Remote.Locator,
+		Bounds:   meta.UserKeyBounds(),
+		Details:  err,
+	}
+	d.opts.EventListener.DataCorruption(info)
+}
diff --git a/excise.go b/excise.go
new file mode 100644
index 00000000..e249c1fd
--- /dev/null
+++ b/excise.go
@@ -0,0 +1,360 @@
+// Copyright 2025 The LevelDB-Go and Pebble Authors. All rights reserved. Use
+// of this source code is governed by a BSD-style license that can be found in
+// the LICENSE file.
+
+package pebble
+
+import (
+	"context"
+	"slices"
+
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/pebble/internal/base"
+	"github.com/cockroachdb/pebble/internal/invariants"
+	"github.com/cockroachdb/pebble/internal/manifest"
+)
+
+// Excise atomically deletes all data overlapping with the provided span. All
+// data overlapping with the span is removed, including from open snapshots.
+// Only currently-open iterators will still observe the removed data (because an
+// open iterator pins all memtables and sstables in its view of the LSM until
+// it's closed). Excise may initiate a flush if there exists unflushed data
+// overlapping the excise span.
+func (d *DB) Excise(ctx context.Context, span KeyRange) error {
+	if err := d.closed.Load(); err != nil {
+		panic(err)
+	}
+	if d.opts.ReadOnly {
+		return ErrReadOnly
+	}
+	// Excise is only supported on prefix keys.
+	if d.opts.Comparer.Split(span.Start) != len(span.Start) {
+		return errors.New("Excise called with suffixed start key")
+	}
+	if d.opts.Comparer.Split(span.End) != len(span.End) {
+		return errors.New("Excise called with suffixed end key")
+	}
+	if v := d.FormatMajorVersion(); v < FormatVirtualSSTables {
+		return errors.Newf(
+			"store has format major version %d; Excise requires at least %d",
+			v, FormatVirtualSSTables,
+		)
+	}
+	_, err := d.ingest(ctx, nil, nil, span, nil)
+	return err
+}
+
+// excise updates ve to include a replacement of the file m with new virtual
+// sstables that exclude exciseSpan, returning a slice of newly-created files if
+// any. If the entirety of m is deleted by exciseSpan, no new sstables are added
+// and m is deleted. Note that ve is updated in-place.
+//
+// This method is agnostic to whether d.mu is held or not. Some cases call it with
+// the db mutex held (eg. ingest-time excises), while in the case of compactions
+// the mutex is not held.
+func (d *DB) excise(
+	ctx context.Context, exciseSpan base.UserKeyBounds, m *tableMetadata, ve *versionEdit, level int,
+) ([]manifest.NewTableEntry, error) {
+	numCreatedFiles := 0
+	// Check if there's actually an overlap between m and exciseSpan.
+	mBounds := base.UserKeyBoundsFromInternal(m.Smallest, m.Largest)
+	if !exciseSpan.Overlaps(d.cmp, &mBounds) {
+		return nil, nil
+	}
+	ve.DeletedTables[deletedFileEntry{
+		Level:   level,
+		FileNum: m.FileNum,
+	}] = m
+	// Fast path: m sits entirely within the exciseSpan, so just delete it.
+	if exciseSpan.ContainsInternalKey(d.cmp, m.Smallest) && exciseSpan.ContainsInternalKey(d.cmp, m.Largest) {
+		return nil, nil
+	}
+
+	var iters iterSet
+	var itersLoaded bool
+	defer iters.CloseAll()
+	loadItersIfNecessary := func() error {
+		if itersLoaded {
+			return nil
+		}
+		var err error
+		iters, err = d.newIters(ctx, m, &IterOptions{
+			Category: categoryIngest,
+			layer:    manifest.Level(level),
+		}, internalIterOpts{}, iterPointKeys|iterRangeDeletions|iterRangeKeys)
+		itersLoaded = true
+		return err
+	}
+
+	needsBacking := false
+	// Create a file to the left of the excise span, if necessary.
+	// The bounds of this file will be [m.Smallest, lastKeyBefore(exciseSpan.Start)].
+	//
+	// We create bounds that are tight on user keys, and we make the effort to find
+	// the last key in the original sstable that's smaller than exciseSpan.Start
+	// even though it requires some sstable reads. We could choose to create
+	// virtual sstables on loose userKey bounds, in which case we could just set
+	// leftFile.Largest to an exclusive sentinel at exciseSpan.Start. The biggest
+	// issue with that approach would be that it'd lead to lots of small virtual
+	// sstables in the LSM that have no guarantee on containing even a single user
+	// key within the file bounds. This has the potential to increase both read and
+	// write-amp as we will be opening up these sstables only to find no relevant
+	// keys in the read path, and compacting sstables on top of them instead of
+	// directly into the space occupied by them. We choose to incur the cost of
+	// calculating tight bounds at this time instead of creating more work in the
+	// future.
+	//
+	// TODO(bilal): Some of this work can happen without grabbing the manifest
+	// lock; we could grab one currentVersion, release the lock, calculate excised
+	// files, then grab the lock again and recalculate for just the files that
+	// have changed since our previous calculation. Do this optimiaztino as part of
+	// https://github.com/cockroachdb/pebble/issues/2112 .
+	if d.cmp(m.Smallest.UserKey, exciseSpan.Start) < 0 {
+		leftFile := &tableMetadata{
+			Virtual:     true,
+			FileBacking: m.FileBacking,
+			FileNum:     d.mu.versions.getNextFileNum(),
+			// Note that these are loose bounds for smallest/largest seqnums, but they're
+			// sufficient for maintaining correctness.
+			SmallestSeqNum:           m.SmallestSeqNum,
+			LargestSeqNum:            m.LargestSeqNum,
+			LargestSeqNumAbsolute:    m.LargestSeqNumAbsolute,
+			SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix,
+		}
+		if m.HasPointKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.SmallestPointKey) {
+			// This file will probably contain point keys.
+			if err := loadItersIfNecessary(); err != nil {
+				return nil, err
+			}
+			smallestPointKey := m.SmallestPointKey
+			if kv := iters.Point().SeekLT(exciseSpan.Start, base.SeekLTFlagsNone); kv != nil {
+				leftFile.ExtendPointKeyBounds(d.cmp, smallestPointKey, kv.K.Clone())
+			}
+			// Store the min of (exciseSpan.Start, rdel.End) in lastRangeDel. This
+			// needs to be a copy if the key is owned by the range del iter.
+			var lastRangeDel []byte
+			if rdel, err := iters.RangeDeletion().SeekLT(exciseSpan.Start); err != nil {
+				return nil, err
+			} else if rdel != nil {
+				lastRangeDel = append(lastRangeDel[:0], rdel.End...)
+				if d.cmp(lastRangeDel, exciseSpan.Start) > 0 {
+					lastRangeDel = exciseSpan.Start
+				}
+			}
+			if lastRangeDel != nil {
+				leftFile.ExtendPointKeyBounds(d.cmp, smallestPointKey, base.MakeExclusiveSentinelKey(InternalKeyKindRangeDelete, lastRangeDel))
+			}
+		}
+		if m.HasRangeKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.SmallestRangeKey) {
+			// This file will probably contain range keys.
+			if err := loadItersIfNecessary(); err != nil {
+				return nil, err
+			}
+			smallestRangeKey := m.SmallestRangeKey
+			// Store the min of (exciseSpan.Start, rkey.End) in lastRangeKey. This
+			// needs to be a copy if the key is owned by the range key iter.
+			var lastRangeKey []byte
+			var lastRangeKeyKind InternalKeyKind
+			if rkey, err := iters.RangeKey().SeekLT(exciseSpan.Start); err != nil {
+				return nil, err
+			} else if rkey != nil {
+				lastRangeKey = append(lastRangeKey[:0], rkey.End...)
+				if d.cmp(lastRangeKey, exciseSpan.Start) > 0 {
+					lastRangeKey = exciseSpan.Start
+				}
+				lastRangeKeyKind = rkey.Keys[0].Kind()
+			}
+			if lastRangeKey != nil {
+				leftFile.ExtendRangeKeyBounds(d.cmp, smallestRangeKey, base.MakeExclusiveSentinelKey(lastRangeKeyKind, lastRangeKey))
+			}
+		}
+		if leftFile.HasRangeKeys || leftFile.HasPointKeys {
+			var err error
+			leftFile.Size, err = d.fileCache.estimateSize(m, leftFile.Smallest.UserKey, leftFile.Largest.UserKey)
+			if err != nil {
+				return nil, err
+			}
+			if leftFile.Size == 0 {
+				// On occasion, estimateSize gives us a low estimate, i.e. a 0 file size,
+				// such as if the excised file only has range keys/dels and no point
+				// keys. This can cause panics in places where we divide by file sizes.
+				// Correct for it here.
+				leftFile.Size = 1
+			}
+			if err := leftFile.Validate(d.cmp, d.opts.Comparer.FormatKey); err != nil {
+				return nil, err
+			}
+			leftFile.ValidateVirtual(m)
+			ve.NewTables = append(ve.NewTables, newTableEntry{Level: level, Meta: leftFile})
+			needsBacking = true
+			numCreatedFiles++
+		}
+	}
+	// Create a file to the right, if necessary.
+	if exciseSpan.ContainsInternalKey(d.cmp, m.Largest) {
+		// No key exists to the right of the excise span in this file.
+		if needsBacking && !m.Virtual {
+			// If m is virtual, then its file backing is already known to the manifest.
+			// We don't need to create another file backing. Note that there must be
+			// only one CreatedBackingTables entry per backing sstable. This is
+			// indicated by the VersionEdit.CreatedBackingTables invariant.
+			ve.CreatedBackingTables = append(ve.CreatedBackingTables, m.FileBacking)
+		}
+		return ve.NewTables[len(ve.NewTables)-numCreatedFiles:], nil
+	}
+	// Create a new file, rightFile, between [firstKeyAfter(exciseSpan.End), m.Largest].
+	//
+	// See comment before the definition of leftFile for the motivation behind
+	// calculating tight user-key bounds.
+	rightFile := &tableMetadata{
+		Virtual:     true,
+		FileBacking: m.FileBacking,
+		FileNum:     d.mu.versions.getNextFileNum(),
+		// Note that these are loose bounds for smallest/largest seqnums, but they're
+		// sufficient for maintaining correctness.
+		SmallestSeqNum:           m.SmallestSeqNum,
+		LargestSeqNum:            m.LargestSeqNum,
+		LargestSeqNumAbsolute:    m.LargestSeqNumAbsolute,
+		SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix,
+	}
+	if m.HasPointKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.LargestPointKey) {
+		// This file will probably contain point keys
+		if err := loadItersIfNecessary(); err != nil {
+			return nil, err
+		}
+		largestPointKey := m.LargestPointKey
+		if kv := iters.Point().SeekGE(exciseSpan.End.Key, base.SeekGEFlagsNone); kv != nil {
+			if exciseSpan.End.Kind == base.Inclusive && d.equal(exciseSpan.End.Key, kv.K.UserKey) {
+				return nil, base.AssertionFailedf("cannot excise with an inclusive end key and data overlap at end key")
+			}
+			rightFile.ExtendPointKeyBounds(d.cmp, kv.K.Clone(), largestPointKey)
+		}
+		// Store the max of (exciseSpan.End, rdel.Start) in firstRangeDel. This
+		// needs to be a copy if the key is owned by the range del iter.
+		var firstRangeDel []byte
+		rdel, err := iters.RangeDeletion().SeekGE(exciseSpan.End.Key)
+		if err != nil {
+			return nil, err
+		} else if rdel != nil {
+			firstRangeDel = append(firstRangeDel[:0], rdel.Start...)
+			if d.cmp(firstRangeDel, exciseSpan.End.Key) < 0 {
+				// NB: This can only be done if the end bound is exclusive.
+				if exciseSpan.End.Kind != base.Exclusive {
+					return nil, base.AssertionFailedf("cannot truncate rangedel during excise with an inclusive upper bound")
+				}
+				firstRangeDel = exciseSpan.End.Key
+			}
+		}
+		if firstRangeDel != nil {
+			smallestPointKey := rdel.SmallestKey()
+			smallestPointKey.UserKey = firstRangeDel
+			rightFile.ExtendPointKeyBounds(d.cmp, smallestPointKey, largestPointKey)
+		}
+	}
+	if m.HasRangeKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.LargestRangeKey) {
+		// This file will probably contain range keys.
+		if err := loadItersIfNecessary(); err != nil {
+			return nil, err
+		}
+		largestRangeKey := m.LargestRangeKey
+		// Store the max of (exciseSpan.End, rkey.Start) in firstRangeKey. This
+		// needs to be a copy if the key is owned by the range key iter.
+		var firstRangeKey []byte
+		rkey, err := iters.RangeKey().SeekGE(exciseSpan.End.Key)
+		if err != nil {
+			return nil, err
+		} else if rkey != nil {
+			firstRangeKey = append(firstRangeKey[:0], rkey.Start...)
+			if d.cmp(firstRangeKey, exciseSpan.End.Key) < 0 {
+				if exciseSpan.End.Kind != base.Exclusive {
+					return nil, base.AssertionFailedf("cannot truncate range key during excise with an inclusive upper bound")
+				}
+				firstRangeKey = exciseSpan.End.Key
+			}
+		}
+		if firstRangeKey != nil {
+			smallestRangeKey := rkey.SmallestKey()
+			smallestRangeKey.UserKey = firstRangeKey
+			// We call ExtendRangeKeyBounds so any internal boundType fields are
+			// set correctly. Note that this is mildly wasteful as we'll be comparing
+			// rightFile.{Smallest,Largest}RangeKey with themselves, which can be
+			// avoided if we exported ExtendOverallKeyBounds or so.
+			rightFile.ExtendRangeKeyBounds(d.cmp, smallestRangeKey, largestRangeKey)
+		}
+	}
+	if rightFile.HasRangeKeys || rightFile.HasPointKeys {
+		var err error
+		rightFile.Size, err = d.fileCache.estimateSize(m, rightFile.Smallest.UserKey, rightFile.Largest.UserKey)
+		if err != nil {
+			return nil, err
+		}
+		if rightFile.Size == 0 {
+			// On occasion, estimateSize gives us a low estimate, i.e. a 0 file size,
+			// such as if the excised file only has range keys/dels and no point keys.
+			// This can cause panics in places where we divide by file sizes. Correct
+			// for it here.
+			rightFile.Size = 1
+		}
+		if err := rightFile.Validate(d.cmp, d.opts.Comparer.FormatKey); err != nil {
+			return nil, err
+		}
+		rightFile.ValidateVirtual(m)
+		ve.NewTables = append(ve.NewTables, newTableEntry{Level: level, Meta: rightFile})
+		needsBacking = true
+		numCreatedFiles++
+	}
+
+	if needsBacking && !m.Virtual {
+		// If m is virtual, then its file backing is already known to the manifest.
+		// We don't need to create another file backing. Note that there must be
+		// only one CreatedBackingTables entry per backing sstable. This is
+		// indicated by the VersionEdit.CreatedBackingTables invariant.
+		ve.CreatedBackingTables = append(ve.CreatedBackingTables, m.FileBacking)
+	}
+
+	return ve.NewTables[len(ve.NewTables)-numCreatedFiles:], nil
+}
+
+// exciseOverlapBounds examines the provided list of snapshots, examining each
+// eventually file-only snapshot in the list and its bounds. If the snapshot is
+// visible at the excise's sequence number, then it accumulates all of the
+// eventually file-only snapshot's protected ranges.
+func exciseOverlapBounds(
+	cmp Compare, sl *snapshotList, exciseSpan KeyRange, exciseSeqNum base.SeqNum,
+) []bounded {
+	var extended []bounded
+	for s := sl.root.next; s != &sl.root; s = s.next {
+		if s.efos == nil {
+			continue
+		}
+		if base.Visible(exciseSeqNum, s.efos.seqNum, base.SeqNumMax) {
+			// We only worry about snapshots older than the excise. Any snapshots
+			// created after the excise should see the excised view of the LSM
+			// anyway.
+			//
+			// Since we delay publishing the excise seqnum as visible until after
+			// the apply step, this case will never be hit in practice until we
+			// make excises flushable ingests.
+			continue
+		}
+		if invariants.Enabled {
+			if s.efos.hasTransitioned() {
+				panic("unexpected transitioned EFOS in snapshots list")
+			}
+		}
+		for i := range s.efos.protectedRanges {
+			if !s.efos.protectedRanges[i].OverlapsKeyRange(cmp, exciseSpan) {
+				continue
+			}
+			// Our excise conflicts with this EFOS. We need to add its protected
+			// ranges to our extended overlap bounds. Grow extended in one
+			// allocation if necesary.
+			extended = slices.Grow(extended, len(s.efos.protectedRanges))
+			for i := range s.efos.protectedRanges {
+				extended = append(extended, &s.efos.protectedRanges[i])
+			}
+			break
+		}
+	}
+	return extended
+}
diff --git a/file_cache.go b/file_cache.go
index 65db546b..5880a317 100644
--- a/file_cache.go
+++ b/file_cache.go
@@ -104,24 +104,31 @@ type fileCacheHandle struct {
 	iterCount         atomic.Int32
 	sstStatsCollector block.CategoryStatsCollector
 
+	// reportCorruptionFn is used for block.ReadEnv.ReportCorruptionFn. It expects
+	// the first argument to be a `*TableMetadata`.
+	reportCorruptionFn func(any, error)
+
 	// This struct is only populated in race builds.
 	raceMu struct {
 		sync.Mutex
-		// iters maps sstable.Iterator objects to the stack trace recorded at
-		// creation time.
-		iters map[any][]byte
+		// nextRefID is the next ID to allocate for a new reference.
+		nextRefID uint64
+		// openRefs maps reference IDs to the stack trace recorded at creation
+		// time. It's used to track which call paths leaked open references to
+		// files.
+		openRefs map[uint64][]byte
 	}
 }
 
 // newHandle creates a handle for the FileCache which has its own options. Each
 // handle has its own set of files in the cache, separate from those of other
 // handles.
-// TODO(radu): don't pass entire options.
 func (c *FileCache) newHandle(
 	cacheHandle *cache.Handle,
 	objProvider objstorage.Provider,
 	loggerAndTracer LoggerAndTracer,
 	readerOpts sstable.ReaderOptions,
+	reportSSTableCorruptionFn func(*manifest.TableMetadata, error),
 ) *fileCacheHandle {
 	c.Ref()
 
@@ -133,8 +140,13 @@ func (c *FileCache) newHandle(
 	}
 	t.readerOpts = readerOpts
 	t.readerOpts.FilterMetricsTracker = &sstable.FilterMetricsTracker{}
+	if reportSSTableCorruptionFn != nil {
+		t.reportCorruptionFn = func(arg any, err error) {
+			reportSSTableCorruptionFn(arg.(*manifest.TableMetadata), err)
+		}
+	}
 	if invariants.RaceEnabled {
-		t.raceMu.iters = make(map[any][]byte)
+		t.raceMu.openRefs = make(map[uint64][]byte)
 	}
 	return t
 }
@@ -151,7 +163,7 @@ func (h *fileCacheHandle) Close() error {
 			err = errors.Errorf("leaked iterators: %d", errors.Safe(v))
 		} else {
 			var buf bytes.Buffer
-			for _, stack := range h.raceMu.iters {
+			for _, stack := range h.raceMu.openRefs {
 				fmt.Fprintf(&buf, "%s\n", stack)
 			}
 			err = errors.Errorf("leaked iterators: %d\n%s", errors.Safe(v), buf.String())
@@ -203,15 +215,21 @@ func (h *fileCacheHandle) openFile(
 	return r, objMeta, nil
 }
 
-// findOrCreate is a wrapper for h.fileCache.FindOrCreate.
+// findOrCreate retrieves an existing reader or creates a new one for the
+// backing file of the given table. If a corruption error is encountered,
+// reportCorruptionFn() is called.
 func (h *fileCacheHandle) findOrCreate(
-	ctx context.Context, fileNum base.DiskFileNum,
+	ctx context.Context, meta *manifest.TableMetadata,
 ) (genericcache.ValueRef[fileCacheKey, fileCacheValue], error) {
 	key := fileCacheKey{
 		handle:  h,
-		fileNum: fileNum,
+		fileNum: meta.FileBacking.DiskFileNum,
+	}
+	valRef, err := h.fileCache.c.FindOrCreate(ctx, key)
+	if err != nil && IsCorruptionError(err) {
+		h.reportCorruptionFn(meta, err)
 	}
-	return h.fileCache.c.FindOrCreate(ctx, key)
+	return valRef, err
 }
 
 // Evict the given file from the file cache and the block cache.
@@ -242,7 +260,7 @@ func (h *fileCacheHandle) Metrics() (CacheMetrics, FilterMetrics) {
 func (h *fileCacheHandle) estimateSize(
 	meta *tableMetadata, lower, upper []byte,
 ) (size uint64, err error) {
-	h.withCommonReader(meta, func(cr sstable.CommonReader) error {
+	err = h.withCommonReader(context.TODO(), block.NoReadEnv, meta, func(cr sstable.CommonReader, env block.ReadEnv) error {
 		size, err = cr.EstimateDiskUsage(lower, upper)
 		return err
 	})
@@ -264,36 +282,53 @@ func createCommonReader(v *fileCacheValue, file *tableMetadata) sstable.CommonRe
 }
 
 func (h *fileCacheHandle) withCommonReader(
-	meta *tableMetadata, fn func(sstable.CommonReader) error,
+	ctx context.Context,
+	env block.ReadEnv,
+	meta *tableMetadata,
+	fn func(sstable.CommonReader, block.ReadEnv) error,
 ) error {
-	ref, err := h.findOrCreate(context.TODO(), meta.FileBacking.DiskFileNum)
+	ref, err := h.findOrCreate(ctx, meta)
 	if err != nil {
 		return err
 	}
 	defer ref.Unref()
-	return fn(createCommonReader(ref.Value(), meta))
+	env.ReportCorruptionFn = h.reportCorruptionFn
+	env.ReportCorruptionArg = meta
+	return fn(createCommonReader(ref.Value(), meta), env)
 }
 
-func (h *fileCacheHandle) withReader(meta physicalMeta, fn func(*sstable.Reader) error) error {
-	ref, err := h.findOrCreate(context.TODO(), meta.FileBacking.DiskFileNum)
+func (h *fileCacheHandle) withReader(
+	ctx context.Context,
+	env block.ReadEnv,
+	meta physicalMeta,
+	fn func(*sstable.Reader, block.ReadEnv) error,
+) error {
+	ref, err := h.findOrCreate(ctx, meta.TableMetadata)
 	if err != nil {
 		return err
 	}
 	defer ref.Unref()
-	return fn(ref.Value().reader.(*sstable.Reader))
+	env.ReportCorruptionFn = h.reportCorruptionFn
+	env.ReportCorruptionArg = meta.TableMetadata
+	return fn(ref.Value().reader.(*sstable.Reader), env)
 }
 
 // withVirtualReader fetches a VirtualReader associated with a virtual sstable.
 func (h *fileCacheHandle) withVirtualReader(
-	meta virtualMeta, fn func(sstable.VirtualReader) error,
+	ctx context.Context,
+	env block.ReadEnv,
+	meta virtualMeta,
+	fn func(sstable.VirtualReader, block.ReadEnv) error,
 ) error {
-	ref, err := h.findOrCreate(context.TODO(), meta.FileBacking.DiskFileNum)
+	ref, err := h.findOrCreate(ctx, meta.TableMetadata)
 	if err != nil {
 		return err
 	}
 	defer ref.Unref()
 	v := ref.Value()
-	return fn(sstable.MakeVirtualReader(v.mustSSTableReader(), meta.VirtualReaderParams(v.isShared)))
+	env.ReportCorruptionFn = h.reportCorruptionFn
+	env.ReportCorruptionArg = &meta.TableMetadata
+	return fn(sstable.MakeVirtualReader(v.mustSSTableReader(), meta.VirtualReaderParams(v.isShared)), env)
 }
 
 func (h *fileCacheHandle) IterCount() int64 {
@@ -348,12 +383,7 @@ func NewFileCache(numShards int, size int) *FileCache {
 		v := vRef.Value()
 		handle := key.handle
 		v.readerProvider.init(c, key)
-		v.closeHook = func(iterator any) {
-			if invariants.RaceEnabled {
-				handle.raceMu.Lock()
-				delete(handle.raceMu.iters, iterator)
-				handle.raceMu.Unlock()
-			}
+		v.closeHook = func() {
 			// closeHook is called when an iterator is closed; the initialization of
 			// an iterator with this value will happen after a FindOrCreate() call
 			// with returns the same vRef.
@@ -427,16 +457,15 @@ func (h *fileCacheHandle) newIters(
 	internalOpts internalIterOpts,
 	kinds iterKinds,
 ) (iterSet, error) {
-	// TODO(sumeer): constructing the Reader should also use a plumbed context,
-	// since parts of the sstable are read during the construction. The Reader
-	// should not remember that context since the Reader can be long-lived.
-
 	// Calling findOrCreate gives us the responsibility of Unref()ing vRef.
-	vRef, err := h.findOrCreate(ctx, file.FileBacking.DiskFileNum)
+	vRef, err := h.findOrCreate(ctx, file)
 	if err != nil {
 		return iterSet{}, err
 	}
 
+	internalOpts.readEnv.ReportCorruptionFn = h.reportCorruptionFn
+	internalOpts.readEnv.ReportCorruptionArg = file
+
 	v := vRef.Value()
 	r := v.mustSSTableReader()
 	// Note: This suffers an allocation for virtual sstables.
@@ -572,17 +601,35 @@ func (h *fileCacheHandle) newPointIter(
 	if err != nil {
 		return nil, err
 	}
-	// NB: v.closeHook takes responsibility for calling unrefValue(v) here. Take
-	// care to avoid introducing an allocation here by adding a closure.
-	iter.SetCloseHook(v.closeHook)
-	handle.iterCount.Add(1)
+	// NB: closeHook (v.closeHook) takes responsibility for calling
+	// unrefValue(v) here. Take care to avoid introducing an allocation here by
+	// adding a closure.
+	closeHook := h.addReference(v)
+	iter.SetCloseHook(closeHook)
+	return iter, nil
+}
+
+func (h *fileCacheHandle) addReference(v *fileCacheValue) (closeHook func()) {
+	h.iterCount.Add(1)
+	closeHook = v.closeHook
 	if invariants.RaceEnabled {
 		stack := debug.Stack()
 		h.raceMu.Lock()
-		h.raceMu.iters[iter] = stack
+		refID := h.raceMu.nextRefID
+		h.raceMu.openRefs[refID] = stack
+		h.raceMu.nextRefID++
 		h.raceMu.Unlock()
+		// In race builds, this closeHook closure will force an allocation.
+		// Race builds are already unperformant (and allocate a stack trace), so
+		// we don't care.
+		closeHook = func() {
+			v.closeHook()
+			h.raceMu.Lock()
+			defer h.raceMu.Unlock()
+			delete(h.raceMu.openRefs, refID)
+		}
 	}
-	return iter, nil
+	return closeHook
 }
 
 // newRangeDelIter is an internal helper that constructs an iterator over a
@@ -722,10 +769,13 @@ func (rp *tableCacheShardReaderProvider) Close() {
 	}
 }
 
-// getTableProperties returns sst table properties for target file.
+// getTableProperties returns sst table properties for the backing file.
+//
+// WARNING! If file is a virtual table, we return the properties of the physical
+// table.
 func (h *fileCacheHandle) getTableProperties(file *tableMetadata) (*sstable.Properties, error) {
 	// Calling findOrCreate gives us the responsibility of decrementing v's refCount here
-	v, err := h.findOrCreate(context.TODO(), file.FileBacking.DiskFileNum)
+	v, err := h.findOrCreate(context.TODO(), file)
 	if err != nil {
 		return nil, err
 	}
@@ -736,7 +786,7 @@ func (h *fileCacheHandle) getTableProperties(file *tableMetadata) (*sstable.Prop
 }
 
 type fileCacheValue struct {
-	closeHook func(i any)
+	closeHook func()
 	reader    io.Closer // *sstable.Reader
 	isShared  bool
 
diff --git a/format_major_version.go b/format_major_version.go
index dd8ff5ce..6338eaf0 100644
--- a/format_major_version.go
+++ b/format_major_version.go
@@ -197,6 +197,13 @@ const (
 	// block.
 	FormatColumnarBlocks
 
+	// FormatWALSyncChunks is a format major version enabling the writing of
+	// WAL sync chunks. These new chunks are used to disambiguate between corruption
+	// and logical EOF during WAL replay. This is implemented by adding a new
+	// chunk wire format that encodes an additional "Synced Offset" field which acts
+	// as a commitment that the WAL should have been synced up until the offset.
+	FormatWALSyncChunks
+
 	// -- Add new versions here --
 
 	// FormatNewest is the most recent format major version.
@@ -235,7 +242,7 @@ func (v FormatMajorVersion) MaxTableFormat() sstable.TableFormat {
 	case FormatDeleteSizedAndObsolete, FormatVirtualSSTables, FormatSyntheticPrefixSuffix,
 		FormatFlushableIngestExcises:
 		return sstable.TableFormatPebblev4
-	case FormatColumnarBlocks:
+	case FormatColumnarBlocks, FormatWALSyncChunks:
 		return sstable.TableFormatPebblev5
 	default:
 		panic(fmt.Sprintf("pebble: unsupported format major version: %s", v))
@@ -248,7 +255,7 @@ func (v FormatMajorVersion) MinTableFormat() sstable.TableFormat {
 	switch v {
 	case FormatDefault, FormatFlushableIngest, FormatPrePebblev1MarkedCompacted,
 		FormatDeleteSizedAndObsolete, FormatVirtualSSTables, FormatSyntheticPrefixSuffix,
-		FormatFlushableIngestExcises, FormatColumnarBlocks:
+		FormatFlushableIngestExcises, FormatColumnarBlocks, FormatWALSyncChunks:
 		return sstable.TableFormatPebblev1
 	default:
 		panic(fmt.Sprintf("pebble: unsupported format major version: %s", v))
@@ -291,6 +298,9 @@ var formatMajorVersionMigrations = map[FormatMajorVersion]func(*DB) error{
 	FormatColumnarBlocks: func(d *DB) error {
 		return d.finalizeFormatVersUpgrade(FormatColumnarBlocks)
 	},
+	FormatWALSyncChunks: func(d *DB) error {
+		return d.finalizeFormatVersUpgrade(FormatWALSyncChunks)
+	},
 }
 
 const formatVersionMarkerName = `format-version`
diff --git a/go.mod b/go.mod
index e89527c5..42981f73 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
 	github.com/cockroachdb/errors v1.11.3
 	github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895
 	github.com/cockroachdb/redact v1.1.5
-	github.com/cockroachdb/swiss v0.0.0-20240612210725-f4de07ae6964
+	github.com/cockroachdb/swiss v0.0.0-20250304010804-34a2c6a59016
 	github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06
 	github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9
 	github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e
diff --git a/go.sum b/go.sum
index 727a1d29..c9d10711 100644
--- a/go.sum
+++ b/go.sum
@@ -83,8 +83,8 @@ github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895 h1:XANOgPY
 github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895/go.mod h1:aPd7gM9ov9M8v32Yy5NJrDyOcD8z642dqs+F0CeNXfA=
 github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
 github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
-github.com/cockroachdb/swiss v0.0.0-20240612210725-f4de07ae6964 h1:Ew0znI2JatzKy52N1iS5muUsHkf2UJuhocH7uFW7jjs=
-github.com/cockroachdb/swiss v0.0.0-20240612210725-f4de07ae6964/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg=
+github.com/cockroachdb/swiss v0.0.0-20250304010804-34a2c6a59016 h1:d0dxi6Q8yGtRBupybhtBC4GJk8WegtDAFr+Xk+/3sSE=
+github.com/cockroachdb/swiss v0.0.0-20250304010804-34a2c6a59016/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg=
 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
diff --git a/ingest.go b/ingest.go
index ccdab8c5..64ec2af9 100644
--- a/ingest.go
+++ b/ingest.go
@@ -1270,17 +1270,15 @@ func (d *DB) IngestAndExcise(
 	if d.opts.ReadOnly {
 		return IngestOperationStats{}, ErrReadOnly
 	}
-	if invariants.Enabled {
-		// Excise is only supported on prefix keys.
-		if d.opts.Comparer.Split(exciseSpan.Start) != len(exciseSpan.Start) {
-			panic("IngestAndExcise called with suffixed start key")
-		}
-		if d.opts.Comparer.Split(exciseSpan.End) != len(exciseSpan.End) {
-			panic("IngestAndExcise called with suffixed end key")
-		}
+	// Excise is only supported on prefix keys.
+	if d.opts.Comparer.Split(exciseSpan.Start) != len(exciseSpan.Start) {
+		return IngestOperationStats{}, errors.New("IngestAndExcise called with suffixed start key")
+	}
+	if d.opts.Comparer.Split(exciseSpan.End) != len(exciseSpan.End) {
+		return IngestOperationStats{}, errors.New("IngestAndExcise called with suffixed end key")
 	}
 	if v := d.FormatMajorVersion(); v < FormatMinForSharedObjects {
-		return IngestOperationStats{}, errors.Errorf(
+		return IngestOperationStats{}, errors.Newf(
 			"store has format major version %d; IngestAndExcise requires at least %d",
 			v, FormatMinForSharedObjects,
 		)
@@ -1302,6 +1300,12 @@ func (d *DB) newIngestedFlushableEntry(
 	fileSeqNumStart := seqNum
 	if exciseSpan.Valid() {
 		fileSeqNumStart = seqNum + 1 // the first seqNum is reserved for the excise.
+		// The excise span will be retained by the flushable, outliving the
+		// caller's ingestion call. Copy it.
+		exciseSpan = KeyRange{
+			Start: slices.Clone(exciseSpan.Start),
+			End:   slices.Clone(exciseSpan.End),
+		}
 	}
 	// Update the sequence number for all of the sstables in the
 	// metadata. Writing the metadata to the manifest when the
@@ -1381,11 +1385,6 @@ func (d *DB) handleIngestAsFlushable(
 		d.mu.Lock()
 	}
 
-	// The excise span is going to outlive this ingestion call. Copy it.
-	exciseSpan = KeyRange{
-		Start: slices.Clone(exciseSpan.Start),
-		End:   slices.Clone(exciseSpan.End),
-	}
 	entry, err := d.newIngestedFlushableEntry(meta, seqNum, logNum, exciseSpan)
 	if err != nil {
 		return err
@@ -1465,7 +1464,7 @@ func (d *DB) ingest(
 		return IngestOperationStats{}, err
 	}
 
-	if loadResult.fileCount() == 0 {
+	if loadResult.fileCount() == 0 && !exciseSpan.Valid() {
 		// All of the sstables to be ingested were empty. Nothing to do.
 		return IngestOperationStats{}, nil
 	}
@@ -1528,50 +1527,14 @@ func (d *DB) ingest(
 		d.mu.Lock()
 		defer d.mu.Unlock()
 
-		// Check if any of the currently-open EventuallyFileOnlySnapshots overlap
-		// in key ranges with the excise span. If so, we need to check for memtable
-		// overlaps with all bounds of that EventuallyFileOnlySnapshot in addition
-		// to the ingestion's own bounds too.
-
 		if exciseSpan.Valid() {
-			for s := d.mu.snapshots.root.next; s != &d.mu.snapshots.root; s = s.next {
-				if s.efos == nil {
-					continue
-				}
-				if base.Visible(seqNum, s.efos.seqNum, base.SeqNumMax) {
-					// We only worry about snapshots older than the excise. Any snapshots
-					// created after the excise should see the excised view of the LSM
-					// anyway.
-					//
-					// Since we delay publishing the excise seqnum as visible until after
-					// the apply step, this case will never be hit in practice until we
-					// make excises flushable ingests.
-					continue
-				}
-				if invariants.Enabled {
-					if s.efos.hasTransitioned() {
-						panic("unexpected transitioned EFOS in snapshots list")
-					}
-				}
-				for i := range s.efos.protectedRanges {
-					if !s.efos.protectedRanges[i].OverlapsKeyRange(d.cmp, exciseSpan) {
-						continue
-					}
-					// Our excise conflicts with this EFOS. We need to add its protected
-					// ranges to our overlapBounds. Grow overlapBounds in one allocation
-					// if necesary.
-					prs := s.efos.protectedRanges
-					if cap(overlapBounds) < len(overlapBounds)+len(prs) {
-						oldOverlapBounds := overlapBounds
-						overlapBounds = make([]bounded, len(oldOverlapBounds), len(oldOverlapBounds)+len(prs))
-						copy(overlapBounds, oldOverlapBounds)
-					}
-					for i := range prs {
-						overlapBounds = append(overlapBounds, &prs[i])
-					}
-					break
-				}
-			}
+			// Check if any of the currently-open EventuallyFileOnlySnapshots
+			// overlap in key ranges with the excise span. If so, we need to
+			// check for memtable overlaps with all bounds of that
+			// EventuallyFileOnlySnapshot in addition to the ingestion's own
+			// bounds too.
+			overlapBounds = append(overlapBounds, exciseOverlapBounds(
+				d.cmp, &d.mu.snapshots.snapshotList, exciseSpan, seqNum)...)
 		}
 
 		// Check to see if any files overlap with any of the memtables. The queue
@@ -1748,334 +1711,68 @@ func (d *DB) ingest(
 		}
 	}
 
-	info := TableIngestInfo{
-		JobID:     int(jobID),
-		Err:       err,
-		flushable: asFlushable,
-	}
-	if len(loadResult.local) > 0 {
-		info.GlobalSeqNum = loadResult.local[0].SmallestSeqNum
-	} else if len(loadResult.shared) > 0 {
-		info.GlobalSeqNum = loadResult.shared[0].SmallestSeqNum
-	} else {
-		info.GlobalSeqNum = loadResult.external[0].SmallestSeqNum
-	}
-	var stats IngestOperationStats
-	if ve != nil {
-		info.Tables = make([]struct {
-			TableInfo
-			Level int
-		}, len(ve.NewTables))
-		for i := range ve.NewTables {
-			e := &ve.NewTables[i]
-			info.Tables[i].Level = e.Level
-			info.Tables[i].TableInfo = e.Meta.TableInfo()
-			stats.Bytes += e.Meta.Size
-			if e.Level == 0 {
-				stats.ApproxIngestedIntoL0Bytes += e.Meta.Size
-			}
-			if metaFlushableOverlaps[e.Meta.FileNum] {
-				stats.MemtableOverlappingFiles++
-			}
-		}
-	} else if asFlushable {
-		// NB: If asFlushable == true, there are no shared sstables.
-		info.Tables = make([]struct {
-			TableInfo
-			Level int
-		}, len(loadResult.local))
-		for i, f := range loadResult.local {
-			info.Tables[i].Level = -1
-			info.Tables[i].TableInfo = f.TableInfo()
-			stats.Bytes += f.Size
-			// We don't have exact stats on which files will be ingested into
-			// L0, because actual ingestion into the LSM has been deferred until
-			// flush time. Instead, we infer based on memtable overlap.
-			//
-			// TODO(jackson): If we optimistically compute data overlap (#2112)
-			// before entering the commit pipeline, we can use that overlap to
-			// improve our approximation by incorporating overlap with L0, not
-			// just memtables.
-			if metaFlushableOverlaps[f.FileNum] {
-				stats.ApproxIngestedIntoL0Bytes += f.Size
-				stats.MemtableOverlappingFiles++
-			}
-		}
-	}
-	d.opts.EventListener.TableIngested(info)
-
-	return stats, err
-}
-
-// excise updates ve to include a replacement of the file m with new virtual
-// sstables that exclude exciseSpan, returning a slice of newly-created files if
-// any. If the entirety of m is deleted by exciseSpan, no new sstables are added
-// and m is deleted. Note that ve is updated in-place.
-//
-// This method is agnostic to whether d.mu is held or not. Some cases call it with
-// the db mutex held (eg. ingest-time excises), while in the case of compactions
-// the mutex is not held.
-func (d *DB) excise(
-	ctx context.Context, exciseSpan base.UserKeyBounds, m *tableMetadata, ve *versionEdit, level int,
-) ([]manifest.NewTableEntry, error) {
-	numCreatedFiles := 0
-	// Check if there's actually an overlap between m and exciseSpan.
-	mBounds := base.UserKeyBoundsFromInternal(m.Smallest, m.Largest)
-	if !exciseSpan.Overlaps(d.cmp, &mBounds) {
-		return nil, nil
-	}
-	ve.DeletedTables[deletedFileEntry{
-		Level:   level,
-		FileNum: m.FileNum,
-	}] = m
-	// Fast path: m sits entirely within the exciseSpan, so just delete it.
-	if exciseSpan.ContainsInternalKey(d.cmp, m.Smallest) && exciseSpan.ContainsInternalKey(d.cmp, m.Largest) {
-		return nil, nil
-	}
-
-	var iters iterSet
-	var itersLoaded bool
-	defer iters.CloseAll()
-	loadItersIfNecessary := func() error {
-		if itersLoaded {
-			return nil
-		}
-		var err error
-		iters, err = d.newIters(ctx, m, &IterOptions{
-			Category: categoryIngest,
-			layer:    manifest.Level(level),
-		}, internalIterOpts{}, iterPointKeys|iterRangeDeletions|iterRangeKeys)
-		itersLoaded = true
-		return err
-	}
+	// TODO(jackson): Refactor this so that the case where there are no files
+	// but a valid excise span is not so exceptional.
 
-	needsBacking := false
-	// Create a file to the left of the excise span, if necessary.
-	// The bounds of this file will be [m.Smallest, lastKeyBefore(exciseSpan.Start)].
-	//
-	// We create bounds that are tight on user keys, and we make the effort to find
-	// the last key in the original sstable that's smaller than exciseSpan.Start
-	// even though it requires some sstable reads. We could choose to create
-	// virtual sstables on loose userKey bounds, in which case we could just set
-	// leftFile.Largest to an exclusive sentinel at exciseSpan.Start. The biggest
-	// issue with that approach would be that it'd lead to lots of small virtual
-	// sstables in the LSM that have no guarantee on containing even a single user
-	// key within the file bounds. This has the potential to increase both read and
-	// write-amp as we will be opening up these sstables only to find no relevant
-	// keys in the read path, and compacting sstables on top of them instead of
-	// directly into the space occupied by them. We choose to incur the cost of
-	// calculating tight bounds at this time instead of creating more work in the
-	// future.
-	//
-	// TODO(bilal): Some of this work can happen without grabbing the manifest
-	// lock; we could grab one currentVersion, release the lock, calculate excised
-	// files, then grab the lock again and recalculate for just the files that
-	// have changed since our previous calculation. Do this optimiaztino as part of
-	// https://github.com/cockroachdb/pebble/issues/2112 .
-	if d.cmp(m.Smallest.UserKey, exciseSpan.Start) < 0 {
-		leftFile := &tableMetadata{
-			Virtual:     true,
-			FileBacking: m.FileBacking,
-			FileNum:     d.mu.versions.getNextFileNum(),
-			// Note that these are loose bounds for smallest/largest seqnums, but they're
-			// sufficient for maintaining correctness.
-			SmallestSeqNum:           m.SmallestSeqNum,
-			LargestSeqNum:            m.LargestSeqNum,
-			LargestSeqNumAbsolute:    m.LargestSeqNumAbsolute,
-			SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix,
-		}
-		if m.HasPointKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.SmallestPointKey) {
-			// This file will probably contain point keys.
-			if err := loadItersIfNecessary(); err != nil {
-				return nil, err
-			}
-			smallestPointKey := m.SmallestPointKey
-			if kv := iters.Point().SeekLT(exciseSpan.Start, base.SeekLTFlagsNone); kv != nil {
-				leftFile.ExtendPointKeyBounds(d.cmp, smallestPointKey, kv.K.Clone())
-			}
-			// Store the min of (exciseSpan.Start, rdel.End) in lastRangeDel. This
-			// needs to be a copy if the key is owned by the range del iter.
-			var lastRangeDel []byte
-			if rdel, err := iters.RangeDeletion().SeekLT(exciseSpan.Start); err != nil {
-				return nil, err
-			} else if rdel != nil {
-				lastRangeDel = append(lastRangeDel[:0], rdel.End...)
-				if d.cmp(lastRangeDel, exciseSpan.Start) > 0 {
-					lastRangeDel = exciseSpan.Start
-				}
-			}
-			if lastRangeDel != nil {
-				leftFile.ExtendPointKeyBounds(d.cmp, smallestPointKey, base.MakeExclusiveSentinelKey(InternalKeyKindRangeDelete, lastRangeDel))
-			}
-		}
-		if m.HasRangeKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.SmallestRangeKey) {
-			// This file will probably contain range keys.
-			if err := loadItersIfNecessary(); err != nil {
-				return nil, err
-			}
-			smallestRangeKey := m.SmallestRangeKey
-			// Store the min of (exciseSpan.Start, rkey.End) in lastRangeKey. This
-			// needs to be a copy if the key is owned by the range key iter.
-			var lastRangeKey []byte
-			var lastRangeKeyKind InternalKeyKind
-			if rkey, err := iters.RangeKey().SeekLT(exciseSpan.Start); err != nil {
-				return nil, err
-			} else if rkey != nil {
-				lastRangeKey = append(lastRangeKey[:0], rkey.End...)
-				if d.cmp(lastRangeKey, exciseSpan.Start) > 0 {
-					lastRangeKey = exciseSpan.Start
+	var stats IngestOperationStats
+	if loadResult.fileCount() > 0 {
+		info := TableIngestInfo{
+			JobID:     int(jobID),
+			Err:       err,
+			flushable: asFlushable,
+		}
+		if len(loadResult.local) > 0 {
+			info.GlobalSeqNum = loadResult.local[0].SmallestSeqNum
+		} else if len(loadResult.shared) > 0 {
+			info.GlobalSeqNum = loadResult.shared[0].SmallestSeqNum
+		} else {
+			info.GlobalSeqNum = loadResult.external[0].SmallestSeqNum
+		}
+		if ve != nil {
+			info.Tables = make([]struct {
+				TableInfo
+				Level int
+			}, len(ve.NewTables))
+			for i := range ve.NewTables {
+				e := &ve.NewTables[i]
+				info.Tables[i].Level = e.Level
+				info.Tables[i].TableInfo = e.Meta.TableInfo()
+				stats.Bytes += e.Meta.Size
+				if e.Level == 0 {
+					stats.ApproxIngestedIntoL0Bytes += e.Meta.Size
 				}
-				lastRangeKeyKind = rkey.Keys[0].Kind()
-			}
-			if lastRangeKey != nil {
-				leftFile.ExtendRangeKeyBounds(d.cmp, smallestRangeKey, base.MakeExclusiveSentinelKey(lastRangeKeyKind, lastRangeKey))
-			}
-		}
-		if leftFile.HasRangeKeys || leftFile.HasPointKeys {
-			var err error
-			leftFile.Size, err = d.fileCache.estimateSize(m, leftFile.Smallest.UserKey, leftFile.Largest.UserKey)
-			if err != nil {
-				return nil, err
-			}
-			if leftFile.Size == 0 {
-				// On occasion, estimateSize gives us a low estimate, i.e. a 0 file size,
-				// such as if the excised file only has range keys/dels and no point
-				// keys. This can cause panics in places where we divide by file sizes.
-				// Correct for it here.
-				leftFile.Size = 1
-			}
-			if err := leftFile.Validate(d.cmp, d.opts.Comparer.FormatKey); err != nil {
-				return nil, err
-			}
-			leftFile.ValidateVirtual(m)
-			ve.NewTables = append(ve.NewTables, newTableEntry{Level: level, Meta: leftFile})
-			needsBacking = true
-			numCreatedFiles++
-		}
-	}
-	// Create a file to the right, if necessary.
-	if exciseSpan.ContainsInternalKey(d.cmp, m.Largest) {
-		// No key exists to the right of the excise span in this file.
-		if needsBacking && !m.Virtual {
-			// If m is virtual, then its file backing is already known to the manifest.
-			// We don't need to create another file backing. Note that there must be
-			// only one CreatedBackingTables entry per backing sstable. This is
-			// indicated by the VersionEdit.CreatedBackingTables invariant.
-			ve.CreatedBackingTables = append(ve.CreatedBackingTables, m.FileBacking)
-		}
-		return ve.NewTables[len(ve.NewTables)-numCreatedFiles:], nil
-	}
-	// Create a new file, rightFile, between [firstKeyAfter(exciseSpan.End), m.Largest].
-	//
-	// See comment before the definition of leftFile for the motivation behind
-	// calculating tight user-key bounds.
-	rightFile := &tableMetadata{
-		Virtual:     true,
-		FileBacking: m.FileBacking,
-		FileNum:     d.mu.versions.getNextFileNum(),
-		// Note that these are loose bounds for smallest/largest seqnums, but they're
-		// sufficient for maintaining correctness.
-		SmallestSeqNum:           m.SmallestSeqNum,
-		LargestSeqNum:            m.LargestSeqNum,
-		LargestSeqNumAbsolute:    m.LargestSeqNumAbsolute,
-		SyntheticPrefixAndSuffix: m.SyntheticPrefixAndSuffix,
-	}
-	if m.HasPointKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.LargestPointKey) {
-		// This file will probably contain point keys
-		if err := loadItersIfNecessary(); err != nil {
-			return nil, err
-		}
-		largestPointKey := m.LargestPointKey
-		if kv := iters.Point().SeekGE(exciseSpan.End.Key, base.SeekGEFlagsNone); kv != nil {
-			if exciseSpan.End.Kind == base.Inclusive && d.equal(exciseSpan.End.Key, kv.K.UserKey) {
-				return nil, base.AssertionFailedf("cannot excise with an inclusive end key and data overlap at end key")
-			}
-			rightFile.ExtendPointKeyBounds(d.cmp, kv.K.Clone(), largestPointKey)
-		}
-		// Store the max of (exciseSpan.End, rdel.Start) in firstRangeDel. This
-		// needs to be a copy if the key is owned by the range del iter.
-		var firstRangeDel []byte
-		rdel, err := iters.RangeDeletion().SeekGE(exciseSpan.End.Key)
-		if err != nil {
-			return nil, err
-		} else if rdel != nil {
-			firstRangeDel = append(firstRangeDel[:0], rdel.Start...)
-			if d.cmp(firstRangeDel, exciseSpan.End.Key) < 0 {
-				// NB: This can only be done if the end bound is exclusive.
-				if exciseSpan.End.Kind != base.Exclusive {
-					return nil, base.AssertionFailedf("cannot truncate rangedel during excise with an inclusive upper bound")
+				if metaFlushableOverlaps[e.Meta.FileNum] {
+					stats.MemtableOverlappingFiles++
 				}
-				firstRangeDel = exciseSpan.End.Key
 			}
-		}
-		if firstRangeDel != nil {
-			smallestPointKey := rdel.SmallestKey()
-			smallestPointKey.UserKey = firstRangeDel
-			rightFile.ExtendPointKeyBounds(d.cmp, smallestPointKey, largestPointKey)
-		}
-	}
-	if m.HasRangeKeys && !exciseSpan.ContainsInternalKey(d.cmp, m.LargestRangeKey) {
-		// This file will probably contain range keys.
-		if err := loadItersIfNecessary(); err != nil {
-			return nil, err
-		}
-		largestRangeKey := m.LargestRangeKey
-		// Store the max of (exciseSpan.End, rkey.Start) in firstRangeKey. This
-		// needs to be a copy if the key is owned by the range key iter.
-		var firstRangeKey []byte
-		rkey, err := iters.RangeKey().SeekGE(exciseSpan.End.Key)
-		if err != nil {
-			return nil, err
-		} else if rkey != nil {
-			firstRangeKey = append(firstRangeKey[:0], rkey.Start...)
-			if d.cmp(firstRangeKey, exciseSpan.End.Key) < 0 {
-				if exciseSpan.End.Kind != base.Exclusive {
-					return nil, base.AssertionFailedf("cannot truncate range key during excise with an inclusive upper bound")
+		} else if asFlushable {
+			// NB: If asFlushable == true, there are no shared sstables.
+			info.Tables = make([]struct {
+				TableInfo
+				Level int
+			}, len(loadResult.local))
+			for i, f := range loadResult.local {
+				info.Tables[i].Level = -1
+				info.Tables[i].TableInfo = f.TableInfo()
+				stats.Bytes += f.Size
+				// We don't have exact stats on which files will be ingested into
+				// L0, because actual ingestion into the LSM has been deferred until
+				// flush time. Instead, we infer based on memtable overlap.
+				//
+				// TODO(jackson): If we optimistically compute data overlap (#2112)
+				// before entering the commit pipeline, we can use that overlap to
+				// improve our approximation by incorporating overlap with L0, not
+				// just memtables.
+				if metaFlushableOverlaps[f.FileNum] {
+					stats.ApproxIngestedIntoL0Bytes += f.Size
+					stats.MemtableOverlappingFiles++
 				}
-				firstRangeKey = exciseSpan.End.Key
 			}
 		}
-		if firstRangeKey != nil {
-			smallestRangeKey := rkey.SmallestKey()
-			smallestRangeKey.UserKey = firstRangeKey
-			// We call ExtendRangeKeyBounds so any internal boundType fields are
-			// set correctly. Note that this is mildly wasteful as we'll be comparing
-			// rightFile.{Smallest,Largest}RangeKey with themselves, which can be
-			// avoided if we exported ExtendOverallKeyBounds or so.
-			rightFile.ExtendRangeKeyBounds(d.cmp, smallestRangeKey, largestRangeKey)
-		}
-	}
-	if rightFile.HasRangeKeys || rightFile.HasPointKeys {
-		var err error
-		rightFile.Size, err = d.fileCache.estimateSize(m, rightFile.Smallest.UserKey, rightFile.Largest.UserKey)
-		if err != nil {
-			return nil, err
-		}
-		if rightFile.Size == 0 {
-			// On occasion, estimateSize gives us a low estimate, i.e. a 0 file size,
-			// such as if the excised file only has range keys/dels and no point keys.
-			// This can cause panics in places where we divide by file sizes. Correct
-			// for it here.
-			rightFile.Size = 1
-		}
-		if err := rightFile.Validate(d.cmp, d.opts.Comparer.FormatKey); err != nil {
-			return nil, err
-		}
-		rightFile.ValidateVirtual(m)
-		ve.NewTables = append(ve.NewTables, newTableEntry{Level: level, Meta: rightFile})
-		needsBacking = true
-		numCreatedFiles++
+		d.opts.EventListener.TableIngested(info)
 	}
 
-	if needsBacking && !m.Virtual {
-		// If m is virtual, then its file backing is already known to the manifest.
-		// We don't need to create another file backing. Note that there must be
-		// only one CreatedBackingTables entry per backing sstable. This is
-		// indicated by the VersionEdit.CreatedBackingTables invariant.
-		ve.CreatedBackingTables = append(ve.CreatedBackingTables, m.FileBacking)
-	}
-
-	return ve.NewTables[len(ve.NewTables)-numCreatedFiles:], nil
+	return stats, err
 }
 
 type ingestSplitFile struct {
@@ -2590,14 +2287,16 @@ func (d *DB) validateSSTables() {
 		}
 
 		var err error
+		// TOOD(radu): plumb a ReadEnv with a CategoryIngest stats collector through
+		// to ValidateBlockChecksums.
 		if f.Meta.Virtual {
-			err = d.fileCache.withVirtualReader(
-				f.Meta.VirtualMeta(), func(v sstable.VirtualReader) error {
+			err = d.fileCache.withVirtualReader(context.TODO(), block.NoReadEnv,
+				f.Meta.VirtualMeta(), func(v sstable.VirtualReader, _ block.ReadEnv) error {
 					return v.ValidateBlockChecksumsOnBacking()
 				})
 		} else {
-			err = d.fileCache.withReader(
-				f.Meta.PhysicalMeta(), func(r *sstable.Reader) error {
+			err = d.fileCache.withReader(context.TODO(), block.NoReadEnv,
+				f.Meta.PhysicalMeta(), func(r *sstable.Reader, _ block.ReadEnv) error {
 					return r.ValidateBlockChecksums()
 				})
 		}
diff --git a/internal.go b/internal.go
index d6997c6b..bd26623b 100644
--- a/internal.go
+++ b/internal.go
@@ -55,8 +55,15 @@ type internalIterator = base.InternalIterator
 
 type topLevelIterator = base.TopLevelIterator
 
+// IsCorruptionError returns true if the given error indicates database
+// corruption.
+func IsCorruptionError(err error) bool {
+	return base.IsCorruptionError(err)
+}
+
 // ErrCorruption is a marker to indicate that data in a file (WAL, MANIFEST,
 // sstable) isn't in the expected format.
+// DEPRECATED: should use IsCorruptionError() instead.
 var ErrCorruption = base.ErrCorruption
 
 // AttributeAndLen exports the base.AttributeAndLen type.
diff --git a/internal/base/error.go b/internal/base/error.go
index fa56e7c3..9f2ff8ed 100644
--- a/internal/base/error.go
+++ b/internal/base/error.go
@@ -13,7 +13,7 @@ import (
 var ErrNotFound = errors.New("pebble: not found")
 
 // ErrCorruption is a marker to indicate that data in a file (WAL, MANIFEST,
-// sstable) isn't in the expected format.
+// sstable) isn't in the expected format (or the file is missing).
 var ErrCorruption = errors.New("pebble: corruption")
 
 // MarkCorruptionError marks given error as a corruption error.
@@ -24,6 +24,11 @@ func MarkCorruptionError(err error) error {
 	return errors.Mark(err, ErrCorruption)
 }
 
+// IsCorruptionError returns true if the given error indicates corruption.
+func IsCorruptionError(err error) bool {
+	return errors.Is(err, ErrCorruption)
+}
+
 // CorruptionErrorf formats according to a format specifier and returns
 // the string as an error value that is marked as a corruption error.
 func CorruptionErrorf(format string, args ...interface{}) error {
diff --git a/internal/base/filenames.go b/internal/base/filenames.go
index 01051cc7..331dafd7 100644
--- a/internal/base/filenames.go
+++ b/internal/base/filenames.go
@@ -10,6 +10,7 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/errors/oserror"
 	"github.com/cockroachdb/pebble/vfs"
 	"github.com/cockroachdb/redact"
@@ -200,14 +201,20 @@ func MustExist(fs vfs.FS, filename string, fataler Fataler, err error) {
 	if err == nil || !oserror.IsNotExist(err) {
 		return
 	}
+	err = AddDetailsToNotExistError(fs, filename, err)
+	fataler.Fatalf("%+v", err)
+}
 
+// AddDetailsToNotExistError annotates an unexpected not-exist error with
+// information about the directory contents.
+func AddDetailsToNotExistError(fs vfs.FS, filename string, err error) error {
 	ls, lsErr := fs.List(fs.PathDir(filename))
 	if lsErr != nil {
 		// TODO(jackson): if oserror.IsNotExist(lsErr), the data directory
 		// doesn't exist anymore. Another process likely deleted it before
 		// killing the process. We want to fatal the process, but without
 		// triggering error reporting like Sentry.
-		fataler.Fatalf("%s:\norig err: %s\nlist err: %s", redact.Safe(fs.PathBase(filename)), err, lsErr)
+		return errors.WithDetailf(err, "list err: %+v", lsErr)
 	}
 	var total, unknown, tables, logs, manifests int
 	total = len(ls)
@@ -234,8 +241,8 @@ func MustExist(fs vfs.FS, filename string, fataler Fataler, err error) {
 		}
 	}
 
-	fataler.Fatalf("%s:\n%s\ndirectory contains %d files, %d unknown, %d tables, %d logs, %d manifests",
-		fs.PathBase(filename), err, total, unknown, tables, logs, manifests)
+	return errors.WithDetailf(err, "filename: %s; directory contains %d files, %d unknown, %d tables, %d logs, %d manifests",
+		filename, total, unknown, tables, logs, manifests)
 }
 
 // FileInfo provides some rudimentary information about a file.
diff --git a/internal/base/lazy_value.go b/internal/base/lazy_value.go
index 83e03390..0e6c557f 100644
--- a/internal/base/lazy_value.go
+++ b/internal/base/lazy_value.go
@@ -40,7 +40,7 @@ type ShortAttributeExtractor func(
 // AttributeAndLen represents the pair of value length and the short
 // attribute.
 type AttributeAndLen struct {
-	ValueLen       int32
+	ValueLen       uint32
 	ShortAttribute ShortAttribute
 }
 
@@ -165,7 +165,10 @@ type LazyFetcher struct {
 	err     error
 	value   []byte
 	// Attribute includes the short attribute and value length.
-	Attribute   AttributeAndLen
+	Attribute AttributeAndLen
+	// BlobFileNum identifies the blob file containing the value. It is only
+	// populated if the value is stored in a blob file.
+	BlobFileNum DiskFileNum
 	fetched     bool
 	callerOwned bool
 }
@@ -184,7 +187,7 @@ type ValueFetcher interface {
 	// will allocate a new slice for the value. In either case it will set
 	// callerOwned to true.
 	Fetch(
-		ctx context.Context, handle []byte, valLen int32, buf []byte,
+		ctx context.Context, handle []byte, blobFileNum DiskFileNum, valLen uint32, buf []byte,
 	) (val []byte, callerOwned bool, err error)
 }
 
@@ -213,7 +216,7 @@ func (lv *LazyValue) fetchValue(
 	if !f.fetched {
 		f.fetched = true
 		f.value, f.callerOwned, f.err = f.Fetcher.Fetch(ctx,
-			lv.ValueOrHandle, lv.Fetcher.Attribute.ValueLen, buf)
+			lv.ValueOrHandle, lv.Fetcher.BlobFileNum, lv.Fetcher.Attribute.ValueLen, buf)
 	}
 	return f.value, f.callerOwned, f.err
 }
diff --git a/internal/base/value.go b/internal/base/value.go
index b62b39ae..bde6a3ef 100644
--- a/internal/base/value.go
+++ b/internal/base/value.go
@@ -17,7 +17,7 @@ type InternalValue struct {
 	lazyValue LazyValue
 }
 
-// MakeLazyValue constructs an IntrenalValue from a LazyValue.
+// MakeLazyValue constructs an InternalValue from a LazyValue.
 func MakeLazyValue(v LazyValue) InternalValue {
 	return InternalValue{lazyValue: v}
 }
@@ -60,6 +60,13 @@ func (v *InternalValue) InternalLen() int {
 	return len(v.lazyValue.ValueOrHandle)
 }
 
+// ValueOrHandle returns the value or handle that is stored inlined. If the
+// value is stored out-of-band, the returned slice contains a binary-encoded
+// value handle.
+func (v *InternalValue) ValueOrHandle() []byte {
+	return v.lazyValue.ValueOrHandle
+}
+
 // Value returns the KV's underlying value.
 func (v *InternalValue) Value(buf []byte) (val []byte, callerOwned bool, err error) {
 	return v.lazyValue.Value(buf)
diff --git a/internal/manifest/version.go b/internal/manifest/version.go
index 70ff7df0..5d9e4c25 100644
--- a/internal/manifest/version.go
+++ b/internal/manifest/version.go
@@ -237,6 +237,44 @@ type TableMetadata struct {
 	// BlobReferences is a list of blob files containing values that are
 	// referenced by this sstable.
 	BlobReferences []BlobReference
+	// BlobReferenceDepth is an upper bound on the number of blob files that a
+	// reader scanning the table would need to keep open if they only open and
+	// close referenced blob files once. In other words, it's the stack depth of
+	// blob files referenced by this sstable. If a flush or compaction rewrites
+	// an sstable's values to a new blob file, the resulting sstable has a blob
+	// reference depth of 1. When a compaction reuses blob references, the max
+	// blob reference depth of the files in each level is used, and then the
+	// depth is summed, and assigned to the output. This is a loose upper bound
+	// (assuming worst case distribution of keys in all inputs) but avoids
+	// tracking key spans for references and using key comparisons.
+	//
+	// Because the blob reference depth is the size of the working set of blob
+	// files referenced by the table, it cannot exceed the count of distinct
+	// blob file references.
+	//
+	// Example: Consider a compaction of file f0 from L0 and files f1, f2, f3
+	// from L1, where the former has blob reference depth of 1 and files f1, f2,
+	// f3 all happen to have a blob-reference-depth of 1. Say we produce many
+	// output files, one of which is f4. We are assuming here that the blobs
+	// referenced by f0 whose keys happened to be written to f4 are spread all
+	// across the key span of f4. Say keys from f1 and f2 also made their way to
+	// f4. Then we will first have keys that refer to blobs referenced by f1,f0
+	// and at some point once we move past the keys of f1, we will have keys
+	// that refer to blobs referenced by f2,f0. In some sense, we have a working
+	// set of 2 blob files at any point in time, and this is similar to the idea
+	// of level stack depth for reads -- hence we adopt the depth terminology.
+	// We want to keep this stack depth in check, since locality is important,
+	// while allowing it to be higher than 1, since otherwise we will need to
+	// rewrite blob files in every compaction (defeating the write amp benefit
+	// we are looking for). Similar to the level depth, this simplistic analysis
+	// does not take into account distribution of keys involved in the
+	// compaction and which of them have blob references. Also the locality is
+	// actually better than in this analysis because more of the keys will be
+	// from the lower level.
+	//
+	// INVARIANT: BlobReferenceDepth == 0 iff len(BlobReferences) == 0
+	// INVARIANT: BlobReferenceDepth <= len(BlobReferences)
+	BlobReferenceDepth int
 
 	// refs is the reference count for the table, used to determine when a table
 	// is obsolete. When a table's reference count falls to zero, the table is
@@ -832,14 +870,14 @@ func (m *TableMetadata) DebugString(format base.FormatKey, verbose bool) string
 		fmt.Fprintf(&b, " size:%d", m.Size)
 	}
 	if len(m.BlobReferences) > 0 {
-		fmt.Fprintf(&b, " blobrefs:[")
+		fmt.Fprint(&b, " blobrefs:[")
 		for i, r := range m.BlobReferences {
 			if i > 0 {
 				fmt.Fprint(&b, ", ")
 			}
 			fmt.Fprintf(&b, "(%s: %d)", r.FileNum, r.ValueSize)
 		}
-		fmt.Fprint(&b, "]")
+		fmt.Fprintf(&b, "; depth:%d]", m.BlobReferenceDepth)
 	}
 	return b.String()
 }
@@ -903,7 +941,7 @@ func ParseTableMetadataDebug(s string) (_ *TableMetadata, err error) {
 
 		case "blobrefs":
 			p.Expect("[")
-			for p.Peek() != "]" {
+			for p.Peek() != ";" {
 				if p.Peek() == "," {
 					p.Expect(",")
 				}
@@ -915,6 +953,10 @@ func ParseTableMetadataDebug(s string) (_ *TableMetadata, err error) {
 				m.BlobReferences = append(m.BlobReferences, ref)
 				p.Expect(")")
 			}
+			p.Expect(";")
+			p.Expect("depth")
+			p.Expect(":")
+			m.BlobReferenceDepth = int(p.Uint64())
 			p.Expect("]")
 
 		default:
@@ -1017,7 +1059,13 @@ func (m *TableMetadata) Validate(cmp Compare, formatKey base.FormatKey) error {
 	if m.FileBacking == nil {
 		return base.CorruptionErrorf("table metadata FileBacking not set")
 	}
-
+	// Assert that there's a nonzero blob reference depth if and only if the
+	// table has a nonzero count of blob references. Additionally, the file's
+	// blob reference depth should be bounded by the number of blob references.
+	if (len(m.BlobReferences) == 0) != (m.BlobReferenceDepth == 0) || m.BlobReferenceDepth > len(m.BlobReferences) {
+		return base.CorruptionErrorf("table %s with %d blob refs but %d blob ref depth",
+			m.FileNum, len(m.BlobReferences), m.BlobReferenceDepth)
+	}
 	if m.SyntheticPrefixAndSuffix.HasPrefix() {
 		if !m.Virtual {
 			return base.CorruptionErrorf("non-virtual file with synthetic prefix")
@@ -1029,7 +1077,6 @@ func (m *TableMetadata) Validate(cmp Compare, formatKey base.FormatKey) error {
 			return base.CorruptionErrorf("virtual file with synthetic prefix has largest key with a different prefix: %s", m.Largest.Pretty(formatKey))
 		}
 	}
-
 	if m.SyntheticPrefixAndSuffix.HasSuffix() {
 		if !m.Virtual {
 			return base.CorruptionErrorf("non-virtual file with synthetic suffix")
diff --git a/internal/manifest/version_edit.go b/internal/manifest/version_edit.go
index 431727da..67ec6ab1 100644
--- a/internal/manifest/version_edit.go
+++ b/internal/manifest/version_edit.go
@@ -357,6 +357,7 @@ func (v *VersionEdit) Decode(r io.Reader) error {
 			var syntheticPrefix sstable.SyntheticPrefix
 			var syntheticSuffix sstable.SyntheticSuffix
 			var blobReferences []BlobReference
+			var blobReferenceDepth uint64
 			if tag == tagNewFile4 || tag == tagNewFile5 {
 				for {
 					customTag, err := d.readUvarint()
@@ -410,6 +411,12 @@ func (v *VersionEdit) Decode(r io.Reader) error {
 						}
 
 					case customTagBlobReferences:
+						// The first varint encodes the 'blob reference depth'
+						// of the table.
+						blobReferenceDepth, err = d.readUvarint()
+						if err != nil {
+							return err
+						}
 						n, err := d.readUvarint()
 						if err != nil {
 							return err
@@ -449,6 +456,7 @@ func (v *VersionEdit) Decode(r io.Reader) error {
 				LargestSeqNum:            largestSeqNum,
 				LargestSeqNumAbsolute:    largestSeqNum,
 				BlobReferences:           blobReferences,
+				BlobReferenceDepth:       int(blobReferenceDepth),
 				MarkedForCompaction:      markedForCompaction,
 				Virtual:                  virtualState.virtual,
 				SyntheticPrefixAndSuffix: sstable.MakeSyntheticPrefixAndSuffix(syntheticPrefix, syntheticSuffix),
@@ -793,6 +801,7 @@ func (v *VersionEdit) Encode(w io.Writer) error {
 			}
 			if len(x.Meta.BlobReferences) > 0 {
 				e.writeUvarint(customTagBlobReferences)
+				e.writeUvarint(uint64(x.Meta.BlobReferenceDepth))
 				e.writeUvarint(uint64(len(x.Meta.BlobReferences)))
 				for _, ref := range x.Meta.BlobReferences {
 					e.writeUvarint(uint64(ref.FileNum))
diff --git a/objstorage/objstorage.go b/objstorage/objstorage.go
index c747055f..53c90a92 100644
--- a/objstorage/objstorage.go
+++ b/objstorage/objstorage.go
@@ -217,8 +217,8 @@ const (
 
 // OpenOptions contains optional arguments for OpenForReading.
 type OpenOptions struct {
-	// MustExist triggers a fatal error if the file does not exist. The fatal
-	// error message contains extra information helpful for debugging.
+	// MustExist converts a not-exist error into a corruption error, and adds
+	// extra information helpful for debugging.
 	MustExist bool
 }
 
diff --git a/objstorage/objstorageprovider/provider.go b/objstorage/objstorageprovider/provider.go
index 389ae247..952dda2b 100644
--- a/objstorage/objstorageprovider/provider.go
+++ b/objstorage/objstorageprovider/provider.go
@@ -294,7 +294,7 @@ func (p *provider) OpenForReading(
 	meta, err := p.Lookup(fileType, fileNum)
 	if err != nil {
 		if opts.MustExist {
-			p.st.Logger.Fatalf("%v", err)
+			err = base.MarkCorruptionError(err)
 		}
 		return nil, err
 	}
@@ -307,6 +307,7 @@ func (p *provider) OpenForReading(
 		if err != nil && p.isNotExistError(meta, err) {
 			// Wrap the error so that IsNotExistError functions properly.
 			err = errors.Mark(err, os.ErrNotExist)
+			err = base.MarkCorruptionError(err)
 		}
 	}
 	if err != nil {
diff --git a/objstorage/objstorageprovider/remote.go b/objstorage/objstorageprovider/remote.go
index 26d79ad0..63d89bb5 100644
--- a/objstorage/objstorageprovider/remote.go
+++ b/objstorage/objstorageprovider/remote.go
@@ -19,7 +19,6 @@ import (
 	"github.com/cockroachdb/pebble/objstorage/objstorageprovider/remoteobjcat"
 	"github.com/cockroachdb/pebble/objstorage/objstorageprovider/sharedcache"
 	"github.com/cockroachdb/pebble/objstorage/remote"
-	"github.com/cockroachdb/redact"
 )
 
 // remoteSubsystem contains the provider fields related to remote storage.
@@ -331,11 +330,11 @@ func (p *provider) remoteOpenForReading(
 		refName := p.sharedObjectRefName(meta)
 		if _, err := meta.Remote.Storage.Size(refName); err != nil {
 			if meta.Remote.Storage.IsNotExistError(err) {
+				err = errors.Wrapf(err, "marker object %q does not exist", errors.Safe(refName))
 				if opts.MustExist {
-					p.st.Logger.Fatalf("marker object %q does not exist", errors.Safe(refName))
-					// TODO(radu): maybe list references for the object.
+					err = base.MarkCorruptionError(err)
 				}
-				return nil, errors.Errorf("marker object %q does not exist", errors.Safe(refName))
+				return nil, err
 			}
 			return nil, errors.Wrapf(err, "checking marker object %q", errors.Safe(refName))
 		}
@@ -344,8 +343,8 @@ func (p *provider) remoteOpenForReading(
 	reader, size, err := meta.Remote.Storage.ReadObject(ctx, objName)
 	if err != nil {
 		if opts.MustExist && meta.Remote.Storage.IsNotExistError(err) {
-			p.st.Logger.Fatalf("object %q does not exist", redact.SafeString(objName))
 			// TODO(radu): maybe list references for the object.
+			err = base.MarkCorruptionError(err)
 		}
 		return nil, err
 	}
diff --git a/objstorage/objstorageprovider/vfs.go b/objstorage/objstorageprovider/vfs.go
index 57285315..e492b3f8 100644
--- a/objstorage/objstorageprovider/vfs.go
+++ b/objstorage/objstorageprovider/vfs.go
@@ -26,8 +26,9 @@ func (p *provider) vfsOpenForReading(
 	filename := p.vfsPath(fileType, fileNum)
 	file, err := p.st.FS.Open(filename, vfs.RandomReadsOption)
 	if err != nil {
-		if opts.MustExist {
-			base.MustExist(p.st.FS, filename, p.st.Logger, err)
+		if opts.MustExist && p.IsNotExistError(err) {
+			err = base.AddDetailsToNotExistError(p.st.FS, filename, err)
+			err = base.MarkCorruptionError(err)
 		}
 		return nil, err
 	}
diff --git a/open.go b/open.go
index b4cb1e6b..7dc4d510 100644
--- a/open.go
+++ b/open.go
@@ -74,6 +74,9 @@ func FileCacheSize(maxOpenFiles int) int {
 }
 
 // Open opens a DB whose files live in the given directory.
+//
+// IsCorruptionError() can be use to determine if the error is caused by on-disk
+// corruption.
 func Open(dirname string, opts *Options) (db *DB, err error) {
 	// Make a copy of the options so that we don't mutate the passed in options.
 	opts = opts.Clone()
@@ -366,6 +369,7 @@ func Open(dirname string, opts *Options) (db *DB, err error) {
 		QueueSemChan:         d.commit.logSyncQSem,
 		Logger:               opts.Logger,
 		EventListener:        walEventListenerAdaptor{l: opts.EventListener},
+		WriteWALSyncOffsets:  FormatMajorVersion(d.mu.formatVers.vers.Load()) >= FormatWALSyncChunks,
 	}
 	if opts.WALFailover != nil {
 		walOpts.Secondary = opts.WALFailover.Secondary
@@ -405,7 +409,7 @@ func Open(dirname string, opts *Options) (db *DB, err error) {
 		opts.FileCache = NewFileCache(opts.Experimental.FileCacheShards, fileCacheSize)
 		defer opts.FileCache.Unref()
 	}
-	d.fileCache = opts.FileCache.newHandle(d.cacheHandle, d.objProvider, d.opts.LoggerAndTracer, d.opts.MakeReaderOptions())
+	d.fileCache = opts.FileCache.newHandle(d.cacheHandle, d.objProvider, d.opts.LoggerAndTracer, d.opts.MakeReaderOptions(), d.reportSSTableCorruption)
 	d.newIters = d.fileCache.newIters
 	d.tableNewRangeKeyIter = tableNewRangeKeyIter(d.newIters)
 
@@ -930,18 +934,31 @@ func (d *DB) replayWAL(
 		}
 		if err != nil {
 			// It is common to encounter a zeroed or invalid chunk due to WAL
-			// preallocation and WAL recycling. We need to distinguish these
-			// errors from EOF in order to recognize that the record was
-			// truncated and to avoid replaying subsequent WALs, but want
-			// to otherwise treat them like EOF.
-			if err == io.EOF {
+			// preallocation and WAL recycling. However zeroed or invalid chunks
+			// can also be a consequence of corruption / disk rot. When the log
+			// reader encounters one of these cases, it attempts to disambiguate
+			// by reading ahead looking for a future record. If a future chunk
+			// indicates the chunk at the original offset should've been valid, it
+			// surfaces record.ErrInvalidChunk or record.ErrZeroedChunk. These
+			// errors are always indicative of corruption and data loss.
+			//
+			// Otherwise, the reader surfaces io.ErrUnexpectedEOF indicating that
+			// the WAL terminated uncleanly and ambiguously. If the WAL is the
+			// most recent logical WAL, the caller passes in (strictWALTail=false),
+			// indicating we should tolerate the unclean ending. If the WAL is an
+			// older WAL, the caller passes in (strictWALTail=true), indicating that
+			// the WAL should have been closed cleanly, and we should interpret
+			// the `io.ErrUnexpectedEOF` as corruption and stop recovery.
+			if errors.Is(err, io.EOF) {
 				break
-			} else if record.IsInvalidRecord(err) {
-				if !strictWALTail {
-					break
-				}
+			} else if errors.Is(err, io.ErrUnexpectedEOF) && !strictWALTail {
+				break
+			} else if errors.Is(err, record.ErrInvalidChunk) || errors.Is(err, record.ErrZeroedChunk) {
+				// If a read-ahead returns one of these errors, they should be marked with corruption.
+				// Other I/O related errors should not be marked with corruption and simply returned.
 				err = errors.Mark(err, ErrCorruption)
 			}
+
 			return nil, 0, errors.Wrap(err, "pebble: error when replaying WAL")
 		}
 
@@ -1230,12 +1247,6 @@ var ErrDBAlreadyExists = errors.New("pebble: database already exists")
 // Note that errors can be wrapped with more details; use errors.Is().
 var ErrDBNotPristine = errors.New("pebble: database already exists and is not pristine")
 
-// IsCorruptionError returns true if the given error indicates database
-// corruption.
-func IsCorruptionError(err error) bool {
-	return errors.Is(err, base.ErrCorruption)
-}
-
 func checkConsistency(v *manifest.Version, objProvider objstorage.Provider) error {
 	var errs []error
 	dedup := make(map[base.DiskFileNum]struct{})
diff --git a/record/log_writer.go b/record/log_writer.go
index 8ea1e17a..87974b39 100644
--- a/record/log_writer.go
+++ b/record/log_writer.go
@@ -473,6 +473,17 @@ type LogWriter struct {
 	pendingSyncsBackingIndex pendingSyncsWithHighestSyncIndex
 
 	pendingSyncForSyncQueueBacking pendingSyncForSyncQueue
+
+	// syncedOffset is the offset in the log that is durably synced after a
+	// flush. This member is used to write the WAL Sync chunk format's "Offset"
+	// field in the header.
+	syncedOffset atomic.Uint64
+
+	// emitFragment is set at runtime depending on which FormatMajorVersion
+	// is used. emitFragment will be set to writing WAL Sync chunk formats
+	// if the FormatMajorVersion is greater than or equal to FormatWALSyncChunks,
+	// otherwise it will write the recyclable chunk format.
+	emitFragment func(n int, p []byte) (remainingP []byte)
 }
 
 // LogWriterConfig is a struct used for configuring new LogWriters
@@ -497,6 +508,9 @@ type LogWriterConfig struct {
 	// package) precede the lower layer locks (in the record package). These
 	// callbacks are serialized since they are invoked from the flushLoop.
 	ExternalSyncQueueCallback ExternalSyncQueueCallback
+
+	// WriteWALSyncOffsets represents whether to write the WAL sync chunk format.
+	WriteWALSyncOffsets bool
 }
 
 // ExternalSyncQueueCallback is to be run when a PendingSync has been
@@ -537,6 +551,13 @@ func NewLogWriter(
 			return time.AfterFunc(d, f)
 		},
 	}
+
+	if logWriterConfig.WriteWALSyncOffsets {
+		r.emitFragment = r.emitFragmentSyncOffsets
+	} else {
+		r.emitFragment = r.emitFragmentRecyclable
+	}
+
 	m := &LogWriterMetrics{}
 	if logWriterConfig.ExternalSyncQueueCallback != nil {
 		r.pendingSyncsBackingIndex.init(logWriterConfig.ExternalSyncQueueCallback)
@@ -584,6 +605,11 @@ func (w *LogWriter) flushLoop(context.Context) {
 		f.Unlock()
 	}()
 
+	// writtenOffset is the amount of data that has been written
+	// but not necessarily synced. This is used to update logWriter's
+	// syncedOffset after a sync.
+	var writtenOffset uint64 = 0
+
 	// The flush loop performs flushing of full and partial data blocks to the
 	// underlying writer (LogWriter.w), syncing of the writer, and notification
 	// to sync requests that they have completed.
@@ -694,9 +720,11 @@ func (w *LogWriter) flushLoop(context.Context) {
 			f.Lock()
 			continue
 		}
+		writtenOffset += uint64(len(data))
 		synced, syncLatency, bytesWritten, err := w.flushPending(data, pending, snap)
 		f.Lock()
 		if synced && f.fsyncLatency != nil {
+			w.syncedOffset.Store(writtenOffset)
 			f.fsyncLatency.Observe(float64(syncLatency))
 		}
 		f.err = err
@@ -954,6 +982,11 @@ func (w *LogWriter) Size() int64 {
 	return w.blockNum*blockSize + int64(w.block.written.Load())
 }
 
+// emitEOFTrailer writes a special recyclable chunk header to signal EOF.
+// The reason why this function writes the recyclable chunk header instead
+// of having a function for writing recyclable and WAL sync chunks as
+// emitFragment does it because there is no reason to add 8 additional
+// bytes to the EOFTrailer for the SyncedOffset as it will be zeroed out anyway.
 func (w *LogWriter) emitEOFTrailer() {
 	// Write a recyclable chunk header with a different log number.  Readers
 	// will treat the header as EOF when the log number does not match.
@@ -966,7 +999,7 @@ func (w *LogWriter) emitEOFTrailer() {
 	b.written.Store(i + int32(recyclableHeaderSize))
 }
 
-func (w *LogWriter) emitFragment(n int, p []byte) (remainingP []byte) {
+func (w *LogWriter) emitFragmentRecyclable(n int, p []byte) (remainingP []byte) {
 	b := w.block
 	i := b.written.Load()
 	first := n == 0
@@ -1003,6 +1036,44 @@ func (w *LogWriter) emitFragment(n int, p []byte) (remainingP []byte) {
 	return p[r:]
 }
 
+func (w *LogWriter) emitFragmentSyncOffsets(n int, p []byte) (remainingP []byte) {
+	b := w.block
+	i := b.written.Load()
+	first := n == 0
+	last := blockSize-i-walSyncHeaderSize >= int32(len(p))
+
+	if last {
+		if first {
+			b.buf[i+6] = walSyncFullChunkEncoding
+		} else {
+			b.buf[i+6] = walSyncLastChunkEncoding
+		}
+	} else {
+		if first {
+			b.buf[i+6] = walSyncFirstChunkEncoding
+		} else {
+			b.buf[i+6] = walSyncMiddleChunkEncoding
+		}
+	}
+
+	binary.LittleEndian.PutUint32(b.buf[i+7:i+11], w.logNum)
+	binary.LittleEndian.PutUint64(b.buf[i+11:i+19], w.syncedOffset.Load())
+
+	r := copy(b.buf[i+walSyncHeaderSize:], p)
+	j := i + int32(walSyncHeaderSize+r)
+	binary.LittleEndian.PutUint32(b.buf[i+0:i+4], crc.New(b.buf[i+6:j]).Value())
+	binary.LittleEndian.PutUint16(b.buf[i+4:i+6], uint16(r))
+	b.written.Store(j)
+
+	if blockSize-b.written.Load() < walSyncHeaderSize {
+		// There is no room for another fragment in the block, so fill the
+		// remaining bytes with zeros and queue the block for flushing.
+		clear(b.buf[b.written.Load():])
+		w.queueBlock()
+	}
+	return p[r:]
+}
+
 // Metrics must typically be called after Close, since the callee will no
 // longer modify the returned LogWriterMetrics. It is also current if there is
 // nothing left to flush in the flush loop, but that is an implementation
diff --git a/record/record.go b/record/record.go
index e5065eb2..aaa1dbd0 100644
--- a/record/record.go
+++ b/record/record.go
@@ -276,26 +276,58 @@ func (r *Reader) nextChunk(wantFirst bool) error {
 			chunkEncoding := r.buf[r.end+6]
 
 			if int(chunkEncoding) >= len(headerFormatMappings) {
+				r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 				return ErrInvalidChunk
 			}
 			headerFormat := headerFormatMappings[chunkEncoding]
 			chunkPosition, wireFormat, headerSize := headerFormat.chunkPosition, headerFormat.wireFormat, headerFormat.headerSize
 
 			if checksum == 0 && length == 0 && chunkPosition == invalidChunkPosition {
+				// remaining bytes < 11
+				// The remaining bytes in the block is < 11 so regardless of which chunk format is
+				// being written (Recyclable or walSync), we should skip to the next block.
 				if r.end+recyclableHeaderSize > r.n {
 					// Skip the rest of the block if the recyclable header size does not
-					// fit within it.
+					// fit within it. The end of a block will be zeroed out if the log writer
+					// cannot fit another chunk into it, even a chunk with no payload like
+					// the EOF Trailer.
 					r.end = r.n
 					continue
 				}
+
+				// check if 11 <= remaining bytes < 19
+				// If so, the remaining bytes in the block can fit a recyclable header but not a
+				// walSync header. In this case, check if the remainder of the chunk is all zeroes.
+				//
+				// If the remainder was all zeroes, then we tolerate this and continue to
+				// the next block. However, if there was non-zero content in the remaining chunk,
+				// then was possibly an artifact of corruption found and ErrZeroedChunk should be
+				// returned.
+				if r.end+walSyncHeaderSize > r.n {
+					// Check that the remainder of the chunk is all zeroes.
+					for i := r.end; i < r.n; i++ {
+						if r.buf[i] != 0 {
+							r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
+							return ErrZeroedChunk
+						}
+					}
+					r.end = r.n
+					continue
+				}
+
+				// The last case is when there was more than 19 bytes which means there shouldn't be
+				// a zeroed header. Thus, this case should also return ErrZeroedChunk.
+				r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 				return ErrZeroedChunk
 			}
 
 			if wireFormat == invalidWireFormat {
+				r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 				return ErrInvalidChunk
 			}
-			if wireFormat == recyclableWireFormat {
+			if wireFormat == recyclableWireFormat || wireFormat == walSyncWireFormat {
 				if r.end+headerSize > r.n {
+					r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 					return ErrInvalidChunk
 				}
 
@@ -308,6 +340,7 @@ func (r *Reader) nextChunk(wantFirst bool) error {
 					}
 					// Otherwise, treat this chunk as invalid in order to prevent reading
 					// of a partial record.
+					r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 					return ErrInvalidChunk
 				}
 			}
@@ -316,9 +349,11 @@ func (r *Reader) nextChunk(wantFirst bool) error {
 			r.end = r.begin + int(length)
 			if r.end > r.n {
 				// The chunk straddles a 32KB boundary (or the end of file).
+				r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 				return ErrInvalidChunk
 			}
 			if checksum != crc.New(r.buf[r.begin-headerSize+6:r.end]).Value() {
+				r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 				return ErrInvalidChunk
 			}
 			if wantFirst {
@@ -334,6 +369,7 @@ func (r *Reader) nextChunk(wantFirst bool) error {
 				// This can happen if the previous instance of the log ended with a
 				// partial block at the same blockNum as the new log but extended
 				// beyond the partial block of the new log.
+				r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 				return ErrInvalidChunk
 			}
 			return io.EOF
@@ -341,6 +377,7 @@ func (r *Reader) nextChunk(wantFirst bool) error {
 		n, err := io.ReadFull(r.r, r.buf[:])
 		if err != nil && err != io.ErrUnexpectedEOF {
 			if err == io.EOF && !wantFirst {
+				r.invalidOffset = uint64(r.blockNum)*blockSize + uint64(r.begin)
 				return io.ErrUnexpectedEOF
 			}
 			return err
@@ -360,11 +397,10 @@ func (r *Reader) Next() (io.Reader, error) {
 	}
 	r.begin = r.end
 	r.err = r.nextChunk(true)
-	// TODO(edward) usage of readAheadForCorruption; uncomment in follow PR
-	// if r.err == ErrInvalidChunk || r.err == ErrZeroedChunk {
-	// 	readAheadResult := r.readAheadForCorruption()
-	// 	return nil, readAheadResult
-	// }
+	if errors.Is(r.err, ErrInvalidChunk) || errors.Is(r.err, ErrZeroedChunk) {
+		readAheadResult := r.readAheadForCorruption()
+		return nil, readAheadResult
+	}
 	if r.err != nil {
 		return nil, r.err
 	}
@@ -389,6 +425,9 @@ func (r *Reader) readAheadForCorruption() error {
 	for {
 		// Load the next block into r.buf.
 		n, err := io.ReadFull(r.r, r.buf[:])
+		r.begin, r.end, r.n = 0, 0, n
+		r.blockNum++
+
 		if errors.Is(err, io.EOF) {
 			// io.ErrUnexpectedEOF is returned instead of
 			// io.EOF because io library functions clear
@@ -411,9 +450,6 @@ func (r *Reader) readAheadForCorruption() error {
 			return err
 		}
 
-		r.begin, r.end, r.n = 0, 0, n
-		r.blockNum++
-
 		for r.end+legacyHeaderSize <= r.n {
 			checksum := binary.LittleEndian.Uint32(r.buf[r.end+0 : r.end+4])
 			length := binary.LittleEndian.Uint16(r.buf[r.end+4 : r.end+6])
@@ -431,7 +467,7 @@ func (r *Reader) readAheadForCorruption() error {
 				break
 			}
 			if wireFormat == recyclableWireFormat || wireFormat == walSyncWireFormat {
-				if r.begin+headerSize > r.n {
+				if r.end+headerSize > r.n {
 					break
 				}
 				logNum := binary.LittleEndian.Uint32(r.buf[r.end+7 : r.end+11])
@@ -533,15 +569,14 @@ func (x singleReader) Read(p []byte) (int, error) {
 		if r.last {
 			return 0, io.EOF
 		}
-		if r.err = r.nextChunk(false); r.err != nil {
+		r.err = r.nextChunk(false)
+		if errors.Is(r.err, ErrInvalidChunk) || errors.Is(r.err, ErrZeroedChunk) {
+			readAheadResult := r.readAheadForCorruption()
+			return 0, readAheadResult
+		}
+		if r.err != nil {
 			return 0, r.err
 		}
-		// TODO(edward) usage of readAheadForCorruption; uncomment in follow PR
-		// r.err = r.nextChunk(false)
-		// if r.err == ErrInvalidChunk || r.err == ErrZeroedChunk {
-		// 	readAheadResult := r.readAheadForCorruption()
-		// 	return 0, readAheadResult
-		// }
 	}
 	n := copy(p, r.buf[r.begin:r.end])
 	r.begin += n
diff --git a/replay/replay.go b/replay/replay.go
index d58a1554..58c545ef 100644
--- a/replay/replay.go
+++ b/replay/replay.go
@@ -610,6 +610,10 @@ func (r *Runner) eventListener() pebble.EventListener {
 			r.err.Store(err)
 			r.cancel()
 		},
+		DataCorruption: func(info pebble.DataCorruptionInfo) {
+			r.err.Store(info.Details)
+			r.cancel()
+		},
 		WriteStallBegin: func(info pebble.WriteStallBeginInfo) {
 			r.writeStallMetrics.Lock()
 			defer r.writeStallMetrics.Unlock()
diff --git a/sstable/blob/blob.go b/sstable/blob/blob.go
index fec88b2f..2fbf2087 100644
--- a/sstable/blob/blob.go
+++ b/sstable/blob/blob.go
@@ -99,8 +99,9 @@ type FileWriter struct {
 }
 
 type compressedBlock struct {
-	pb block.PhysicalBlock
-	bh *block.BufHandle
+	pb  block.PhysicalBlock
+	bh  *block.BufHandle
+	off uint64
 }
 
 // NewFileWriter creates a new FileWriter.
@@ -141,11 +142,12 @@ func (w *FileWriter) AddValue(v []byte) Handle {
 
 func (w *FileWriter) flush() {
 	pb, bh := w.b.CompressAndChecksum()
-	compressedLen := uint64(pb.LengthWithTrailer())
+	compressedLen := uint64(pb.LengthWithoutTrailer())
 	w.stats.BlockCount++
 	w.stats.BlockLenLongest = max(w.stats.BlockLenLongest, compressedLen)
-	w.stats.FileLen += compressedLen
-	w.writeQueue.ch <- compressedBlock{pb: pb, bh: bh}
+	off := w.stats.FileLen
+	w.stats.FileLen += compressedLen + block.TrailerLen
+	w.writeQueue.ch <- compressedBlock{pb: pb, bh: bh, off: off}
 }
 
 // drainWriteQueue runs in its own goroutine and is responsible for writing
@@ -154,12 +156,12 @@ func (w *FileWriter) flush() {
 func (w *FileWriter) drainWriteQueue() {
 	defer w.writeQueue.wg.Done()
 	for cb := range w.writeQueue.ch {
-		n, err := cb.pb.WriteTo(w.w)
+		_, err := cb.pb.WriteTo(w.w)
 		if err != nil {
 			w.writeQueue.err = err
 			continue
 		}
-		w.blockOffsets = append(w.blockOffsets, uint64(n))
+		w.blockOffsets = append(w.blockOffsets, cb.off)
 		// We're done with the buffer associated with this physical block.
 		// Release it back to its pool.
 		cb.bh.Release()
@@ -206,7 +208,7 @@ func (w *FileWriter) Close() (FileWriterStats, error) {
 		BlockLengthByteLength: uint8(lenLittleEndian(stats.BlockLenLongest)),
 	}
 	indexBlockLen := vbih.RowWidth() * int(stats.BlockCount)
-	vbih.Handle.Length = uint64(indexBlockLen) + block.TrailerLen
+	vbih.Handle.Length = uint64(indexBlockLen)
 	{
 		w.b.Resize(indexBlockLen)
 		b := w.b.Get()
@@ -217,9 +219,9 @@ func (w *FileWriter) Close() (FileWriterStats, error) {
 			b = b[int(vbih.BlockOffsetByteLength):]
 			var l uint64
 			if i == len(w.blockOffsets)-1 {
-				l = stats.FileLen - w.blockOffsets[i]
+				l = stats.FileLen - w.blockOffsets[i] - block.TrailerLen
 			} else {
-				l = w.blockOffsets[i+1] - w.blockOffsets[i]
+				l = w.blockOffsets[i+1] - w.blockOffsets[i] - block.TrailerLen
 			}
 			littleEndianPut(l, b, int(vbih.BlockLengthByteLength))
 			b = b[int(vbih.BlockLengthByteLength):]
@@ -234,7 +236,7 @@ func (w *FileWriter) Close() (FileWriterStats, error) {
 			return FileWriterStats{}, w.err
 		}
 		bh.Release()
-		stats.FileLen += vbih.Handle.Length
+		stats.FileLen += vbih.Handle.Length + block.TrailerLen
 	}
 
 	// Write the footer.
@@ -328,6 +330,9 @@ type FileReader struct {
 	footer fileFooter
 }
 
+// Assert that FileReader implements the ValueReader interface.
+var _ ValueReader = (*FileReader)(nil)
+
 // FileReaderOptions configures a reader of a blob file.
 type FileReaderOptions struct {
 	block.ReaderOptions
@@ -378,6 +383,35 @@ func (r *FileReader) Close() error {
 	return r.r.Close()
 }
 
+// InitReadHandle initializes a read handle for the file reader, using the
+// provided preallocated read handle.
+func (r *FileReader) InitReadHandle(
+	rh *objstorageprovider.PreallocatedReadHandle,
+) objstorage.ReadHandle {
+	return objstorageprovider.UsePreallocatedReadHandle(r.r.Readable(), objstorage.NoReadBefore, rh)
+}
+
+// ReadValueBlock reads a value block from the file.
+func (r *FileReader) ReadValueBlock(
+	ctx context.Context, env block.ReadEnv, rh objstorage.ReadHandle, h block.Handle,
+) (block.BufferHandle, error) {
+	return r.r.Read(ctx, env, rh, h, noInitBlockMetadata)
+}
+
+// ReadValueIndexBlock reads the index block from the file.
+func (r *FileReader) ReadValueIndexBlock(
+	ctx context.Context, env block.ReadEnv, rh objstorage.ReadHandle,
+) (block.BufferHandle, error) {
+	return r.r.Read(ctx, env, rh, r.footer.indexHandle.Handle, noInitBlockMetadata)
+}
+
+// ValueIndexHandle returns the index handle for the file's value block.
+func (r *FileReader) ValueIndexHandle() valblk.IndexHandle {
+	return r.footer.indexHandle
+}
+
+func noInitBlockMetadata(_ *block.Metadata, _ []byte) error { return nil }
+
 // lenLittleEndian returns the minimum number of bytes needed to encode v
 // using little endian encoding.
 func lenLittleEndian(v uint64) int {
diff --git a/sstable/blob/fetcher.go b/sstable/blob/fetcher.go
new file mode 100644
index 00000000..f5a9c151
--- /dev/null
+++ b/sstable/blob/fetcher.go
@@ -0,0 +1,211 @@
+// Copyright 2025 The LevelDB-Go and Pebble Authors. All rights reserved. Use
+// of this source code is governed by a BSD-style license that can be found in
+// the LICENSE file.
+
+package blob
+
+import (
+	"context"
+
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/pebble/internal/base"
+	"github.com/cockroachdb/pebble/objstorage"
+	"github.com/cockroachdb/pebble/objstorage/objstorageprovider"
+	"github.com/cockroachdb/pebble/objstorage/objstorageprovider/objiotracing"
+	"github.com/cockroachdb/pebble/sstable/block"
+	"github.com/cockroachdb/pebble/sstable/valblk"
+)
+
+const maxCachedReaders = 5
+
+// A ValueReader is an interface defined over a file that can be used to read
+// value blocks.
+type ValueReader interface {
+	// ValueIndexHandle returns the handle for the file's value index block.
+	ValueIndexHandle() valblk.IndexHandle
+
+	// InitReadHandle initializes a ReadHandle for the file, using the provided
+	// preallocated read handle to avoid an allocation.
+	InitReadHandle(rh *objstorageprovider.PreallocatedReadHandle) objstorage.ReadHandle
+
+	// ReadValueBlock retrieves a value block described by the provided block
+	// handle from the block cache, or reads it from the blob file if it's not
+	// already cached.
+	ReadValueBlock(context.Context, block.ReadEnv, objstorage.ReadHandle,
+		block.Handle) (block.BufferHandle, error)
+
+	// ReadValueIndexBlock retrieves the index block from the block cache, or
+	// reads it from the blob file if it's not already cached.
+	ReadValueIndexBlock(context.Context, block.ReadEnv, objstorage.ReadHandle) (block.BufferHandle, error)
+}
+
+// A ReaderProvider is an interface that can be used to retrieve a ValueReader
+// for a given file number.
+type ReaderProvider interface {
+	// GetValueReader returns a ValueReader for the given file number.
+	GetValueReader(ctx context.Context, fileNum base.DiskFileNum) (r ValueReader, closeFunc func(), err error)
+}
+
+// A ValueFetcher retrieves values stored out-of-band in separate blob files.
+// The ValueFetcher caches accessed file readers to avoid redundant file cache
+// and block cache lookups when performing consecutive value retrievals.
+//
+// A single ValueFetcher can be used to fetch values from multiple files, and it
+// will internally cache readers for each file.
+type ValueFetcher struct {
+	readerProvider ReaderProvider
+	env            block.ReadEnv
+	fetchCount     int
+	readers        [maxCachedReaders]cachedReader
+}
+
+// TODO(jackson): Support setting up a read handle for compaction when relevant.
+
+// Assert that ValueFetcher implements the ValueFetcher interface.
+var _ base.ValueFetcher = (*ValueFetcher)(nil)
+
+// Init initializes the ValueFetcher.
+func (r *ValueFetcher) Init(rp ReaderProvider, env block.ReadEnv) {
+	r.readerProvider = rp
+	r.env = env
+	if r.readerProvider == nil {
+		panic("readerProvider is nil")
+	}
+}
+
+// Fetch returns the value, given the handle. Fetch must not be called after
+// Close.
+func (r *ValueFetcher) Fetch(
+	ctx context.Context, handle []byte, fileNum base.DiskFileNum, valLen uint32, buf []byte,
+) (val []byte, callerOwned bool, err error) {
+	vblkHandle := valblk.DecodeRemainingHandle(handle)
+	vh := Handle{
+		FileNum:       fileNum,
+		BlockNum:      vblkHandle.BlockNum,
+		OffsetInBlock: vblkHandle.OffsetInBlock,
+		ValueLen:      valLen,
+	}
+	v, err := r.retrieve(ctx, vh)
+	return v, false, err
+}
+
+func (r *ValueFetcher) retrieve(ctx context.Context, vh Handle) (val []byte, err error) {
+	// Look for a cached reader for the file. Also, find the least-recently used
+	// reader. If we don't find a cached reader, we'll replace the
+	// least-recently used reader with the new one for the file indicated by
+	// vh.FileNum.
+	var cr *cachedReader
+	var oldestFetchIndex int
+	for i := range r.readers {
+		if r.readers[i].fileNum == vh.FileNum && r.readers[i].r != nil {
+			cr = &r.readers[i]
+			break
+		} else if r.readers[i].lastFetchCount < r.readers[oldestFetchIndex].lastFetchCount {
+			oldestFetchIndex = i
+		}
+	}
+
+	if cr == nil {
+		// No cached reader found for the file. Get one from the file cache.
+		cr = &r.readers[oldestFetchIndex]
+		// Release the previous reader, if any.
+		if cr.r != nil {
+			if err = cr.Close(); err != nil {
+				return nil, err
+			}
+		}
+		if cr.r, cr.closeFunc, err = r.readerProvider.GetValueReader(ctx, vh.FileNum); err != nil {
+			return nil, err
+		}
+		cr.fileNum = vh.FileNum
+		cr.rh = cr.r.InitReadHandle(&cr.preallocRH)
+	}
+
+	r.fetchCount++
+	cr.lastFetchCount = r.fetchCount
+	val, err = cr.GetUnsafeValue(ctx, vh, r.env)
+	return val, err
+}
+
+// Close closes the ValueFetcher and releases all cached readers. Once Close is
+// called, the ValueFetcher is no longer usable.
+func (r *ValueFetcher) Close() error {
+	var err error
+	for i := range r.readers {
+		if r.readers[i].r != nil {
+			err = errors.CombineErrors(err, r.readers[i].Close())
+		}
+	}
+	return err
+}
+
+// cachedReader holds a Reader into an open file, and possibly blocks retrieved
+// from the block cache.
+type cachedReader struct {
+	fileNum            base.DiskFileNum
+	r                  ValueReader
+	closeFunc          func()
+	rh                 objstorage.ReadHandle
+	lastFetchCount     int
+	currentBlockNum    uint32
+	currentBlockLoaded bool
+	currentBlockBuf    block.BufferHandle
+	indexBlockBuf      block.BufferHandle
+	preallocRH         objstorageprovider.PreallocatedReadHandle
+}
+
+// GetUnsafeValue retrieves the value for the given handle. The value is
+// returned as a byte slice pointing directly into the block cache's data. The
+// value is only guaranteed to be stable until the next call to GetUnsafeValue
+// or until the cachedReader is closed.
+func (cr *cachedReader) GetUnsafeValue(
+	ctx context.Context, vh Handle, env block.ReadEnv,
+) ([]byte, error) {
+	ctx = objiotracing.WithBlockType(ctx, objiotracing.ValueBlock)
+
+	if !cr.indexBlockBuf.Valid() {
+		// Read the index block.
+		var err error
+		cr.indexBlockBuf, err = cr.r.ReadValueIndexBlock(ctx, env, cr.rh)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if !cr.currentBlockLoaded || vh.BlockNum != cr.currentBlockNum {
+		// Translate the handle's block number into a block handle via the blob
+		// file's index block.
+		h, err := valblk.DecodeBlockHandleFromIndex(cr.indexBlockBuf.BlockData(), vh.BlockNum, cr.r.ValueIndexHandle())
+		if err != nil {
+			return nil, err
+		}
+		cr.currentBlockBuf.Release()
+		cr.currentBlockLoaded = false
+		cr.currentBlockBuf, err = cr.r.ReadValueBlock(ctx, env, cr.rh, h)
+		if err != nil {
+			return nil, err
+		}
+		cr.currentBlockNum = vh.BlockNum
+		cr.currentBlockLoaded = true
+	}
+	data := cr.currentBlockBuf.BlockData()
+	if len(data) < int(vh.OffsetInBlock+vh.ValueLen) {
+		return nil, base.CorruptionErrorf("blob file %s: block %d: expected block length %d, got %d",
+			vh.FileNum, vh.BlockNum, vh.OffsetInBlock+vh.ValueLen, len(data))
+	}
+	return data[vh.OffsetInBlock : vh.OffsetInBlock+vh.ValueLen], nil
+}
+
+// Close releases resources associated with the reader.
+func (cfr *cachedReader) Close() (err error) {
+	if cfr.rh != nil {
+		err = cfr.rh.Close()
+	}
+	cfr.indexBlockBuf.Release()
+	cfr.currentBlockBuf.Release()
+	// Release the cfg.Reader. closeFunc is provided by the file cache and
+	// decrements the refcount on the open file reader.
+	cfr.closeFunc()
+	*cfr = cachedReader{}
+	return err
+}
diff --git a/sstable/block/block.go b/sstable/block/block.go
index 239ac05e..9d52c0b1 100644
--- a/sstable/block/block.go
+++ b/sstable/block/block.go
@@ -26,7 +26,10 @@ import (
 
 // Handle is the file offset and length of a block.
 type Handle struct {
-	Offset, Length uint64
+	// Offset identifies the offset of the block within the file.
+	Offset uint64
+	// Length is the length of the block data (excludes the trailer).
+	Length uint64
 }
 
 // EncodeVarints encodes the block handle into dst using a variable-width
@@ -328,6 +331,12 @@ type ReadEnv struct {
 	// BufferPool is not-nil if we read blocks into a buffer pool and not into the
 	// cache. This is used during compactions.
 	BufferPool *BufferPool
+
+	// ReportCorruptionFn is called with ReportCorruptionArg and the error
+	// whenever an SSTable corruption is detected. The argument is used to avoid
+	// allocating a separate function for each object.
+	ReportCorruptionFn  func(opaque any, err error)
+	ReportCorruptionArg any
 }
 
 // BlockServedFromCache updates the stats when a block was found in the cache.
@@ -352,6 +361,14 @@ func (env *ReadEnv) BlockRead(blockLength uint64, readDuration time.Duration) {
 	}
 }
 
+// maybeReportCorruption calls the ReportCorruptionFn if the given error
+// indicates corruption.
+func (env *ReadEnv) maybeReportCorruption(err error) {
+	if env.ReportCorruptionFn != nil && base.IsCorruptionError(err) {
+		env.ReportCorruptionFn(env.ReportCorruptionArg, err)
+	}
+}
+
 // A Reader reads blocks from a single file, handling caching, checksum
 // validation and decompression.
 type Reader struct {
@@ -411,9 +428,10 @@ func (r *Reader) Read(
 		}
 		value, err := r.doRead(ctx, env, readHandle, bh, initBlockMetadataFn)
 		if err != nil {
+			env.maybeReportCorruption(err)
 			return BufferHandle{}, err
 		}
-		return value.MakeHandle(), err
+		return value.MakeHandle(), nil
 	}
 
 	cv, crh, errorDuration, hit, err := r.opts.CacheOpts.CacheHandle.GetWithReadHandle(
@@ -425,6 +443,11 @@ func (r *Reader) Read(
 	// TODO(sumeer): consider tracing when waited longer than some duration
 	// for turn to do the read.
 	if err != nil {
+		// Another caller tried to read this block and failed. We want each caller
+		// to report corruption errors separately, since the ReportCorruptionArg
+		// could be different. In particular, we might read the same physical block
+		// (e.g. an index block) for two different virtual tables.
+		env.maybeReportCorruption(err)
 		return BufferHandle{}, err
 	}
 
@@ -441,6 +464,7 @@ func (r *Reader) Read(
 	value, err := r.doRead(ctx, env, readHandle, bh, initBlockMetadataFn)
 	if err != nil {
 		crh.SetReadError(err)
+		env.maybeReportCorruption(err)
 		return BufferHandle{}, err
 	}
 	crh.SetReadValue(value.v)
diff --git a/sstable/block/kv.go b/sstable/block/kv.go
index 6d70c7b6..1560878e 100644
--- a/sstable/block/kv.go
+++ b/sstable/block/kv.go
@@ -13,9 +13,10 @@ type ValuePrefix byte
 
 const (
 	// 2 most-significant bits of valuePrefix encodes the value-kind.
-	valueKindMask           ValuePrefix = 0xC0
-	valueKindIsValueHandle  ValuePrefix = 0x80
-	valueKindIsInPlaceValue ValuePrefix = 0x00
+	valueKindMask               ValuePrefix = 0xC0
+	valueKindIsValueBlockHandle ValuePrefix = 0x80
+	valueKindIsBlobHandle       ValuePrefix = 0x40
+	valueKindIsInPlaceValue     ValuePrefix = 0x00
 
 	// 1 bit indicates SET has same key prefix as immediately preceding key that
 	// is also a SET. If the immediately preceding key in the same block is a
@@ -32,9 +33,19 @@ const (
 	userDefinedShortAttributeMask ValuePrefix = 0x07
 )
 
-// IsValueHandle returns true if the ValuePrefix is for a valueHandle.
-func (vp ValuePrefix) IsValueHandle() bool {
-	return vp&valueKindMask == valueKindIsValueHandle
+// IsInPlaceValue returns true if the ValuePrefix is for an in-place value.
+func (vp ValuePrefix) IsInPlaceValue() bool {
+	return vp&valueKindMask == valueKindIsInPlaceValue
+}
+
+// IsValueBlockHandle returns true if the ValuePrefix is for a valblk.Handle.
+func (vp ValuePrefix) IsValueBlockHandle() bool {
+	return vp&valueKindMask == valueKindIsValueBlockHandle
+}
+
+// IsBlobValueHandle returns true if the ValuePrefix is for a blob.
+func (vp ValuePrefix) IsBlobValueHandle() bool {
+	return vp&valueKindMask == valueKindIsBlobHandle
 }
 
 // SetHasSamePrefix returns true if the ValuePrefix encodes that the key is a
@@ -46,14 +57,14 @@ func (vp ValuePrefix) SetHasSamePrefix() bool {
 // ShortAttribute returns the user-defined base.ShortAttribute encoded in the
 // ValuePrefix.
 //
-// REQUIRES: IsValueHandle()
+// REQUIRES: !IsInPlaceValue()
 func (vp ValuePrefix) ShortAttribute() base.ShortAttribute {
 	return base.ShortAttribute(vp & userDefinedShortAttributeMask)
 }
 
-// ValueHandlePrefix returns the ValuePrefix for a valueHandle.
-func ValueHandlePrefix(setHasSameKeyPrefix bool, attribute base.ShortAttribute) ValuePrefix {
-	prefix := valueKindIsValueHandle | ValuePrefix(attribute)
+// ValueBlockHandlePrefix returns the ValuePrefix for a valblk.Handle.
+func ValueBlockHandlePrefix(setHasSameKeyPrefix bool, attribute base.ShortAttribute) ValuePrefix {
+	prefix := valueKindIsValueBlockHandle | ValuePrefix(attribute)
 	if setHasSameKeyPrefix {
 		prefix = prefix | setHasSameKeyPrefixMask
 	}
@@ -69,6 +80,15 @@ func InPlaceValuePrefix(setHasSameKeyPrefix bool) ValuePrefix {
 	return prefix
 }
 
+// BlobValueHandlePrefix returns the ValuePrefix for a blob.
+func BlobValueHandlePrefix(setHasSameKeyPrefix bool) ValuePrefix {
+	prefix := valueKindIsBlobHandle
+	if setHasSameKeyPrefix {
+		prefix = prefix | setHasSameKeyPrefixMask
+	}
+	return prefix
+}
+
 // GetInternalValueForPrefixAndValueHandler is an interface for getting an
 // InternalValue from a value prefix and value.
 type GetInternalValueForPrefixAndValueHandler interface {
diff --git a/sstable/colblk/data_block.go b/sstable/colblk/data_block.go
index 272616bc..4c1fcc08 100644
--- a/sstable/colblk/data_block.go
+++ b/sstable/colblk/data_block.go
@@ -581,7 +581,7 @@ func (w *DataBlockEncoder) Add(
 		w.isObsolete.Set(w.rows)
 	}
 	w.trailers.Set(w.rows, uint64(ikey.Trailer))
-	if valuePrefix.IsValueHandle() {
+	if valuePrefix.IsValueBlockHandle() {
 		w.isValueExternal.Set(w.rows)
 		// Write the value with the value prefix byte preceding the value.
 		w.valuePrefixTmp[0] = byte(valuePrefix)
diff --git a/sstable/colblk/raw_bytes.go b/sstable/colblk/raw_bytes.go
index 2854d6c9..1fe144cd 100644
--- a/sstable/colblk/raw_bytes.go
+++ b/sstable/colblk/raw_bytes.go
@@ -11,6 +11,7 @@ import (
 	"unsafe"
 
 	"github.com/cockroachdb/pebble/internal/binfmt"
+	"github.com/cockroachdb/pebble/internal/invariants"
 	"github.com/cockroachdb/pebble/internal/treeprinter"
 )
 
@@ -183,7 +184,9 @@ func (b *RawBytesBuilder) UnsafeGet(i int) []byte {
 	if b.rows == 0 {
 		return nil
 	}
-	return b.data[b.offsets.array.elems.At(i):b.offsets.array.elems.At(i+1)]
+	start := unsafeGetUint64(b.offsets.elems, i)
+	end := unsafeGetUint64(b.offsets.elems, i+1)
+	return b.data[start:end]
 }
 
 // Finish writes the serialized byte slices to buf starting at offset. The buf
@@ -219,3 +222,11 @@ func (b *RawBytesBuilder) Size(rows int, offset uint32) uint32 {
 func (b *RawBytesBuilder) WriteDebug(w io.Writer, rows int) {
 	fmt.Fprintf(w, "bytes: %d rows set; %d bytes in data", b.rows, len(b.data))
 }
+
+//gcassert:inline
+func unsafeGetUint64(slice []uint64, idx int) uint64 {
+	if invariants.Enabled {
+		_ = slice[idx]
+	}
+	return *(*uint64)(unsafe.Add(unsafe.Pointer(unsafe.SliceData(slice)), uintptr(idx)<<3))
+}
diff --git a/sstable/colblk/uints.go b/sstable/colblk/uints.go
index cf462519..5f79bdec 100644
--- a/sstable/colblk/uints.go
+++ b/sstable/colblk/uints.go
@@ -157,16 +157,8 @@ type UintBuilder struct {
 	// configuration fixed on Init; preserved across Reset
 	useDefault bool
 
-	// array holds the underlying heap-allocated array in which values are
-	// stored.
-	array struct {
-		// n is the size of the array (in count of T elements; not bytes). n is
-		// NOT the number of elements that have been populated by the user.
-		n int
-		// elems provides access to elements without bounds checking. elems is
-		// grown automatically in Set.
-		elems UnsafeRawSlice[uint64]
-	}
+	elems []uint64
+
 	// stats holds state for the purpose of tracking which UintEncoding would
 	// be used if the caller Finished the column including all elements Set so
 	// far. The stats state is used by Size (and Finish) to cheaply determine
@@ -233,15 +225,15 @@ func (b *UintBuilder) Reset() {
 		// will include at least one default value.
 		b.stats.minimum = 0
 		b.stats.maximum = 0
-		clear(b.array.elems.Slice(b.array.n))
+		clear(b.elems)
 	} else {
 		b.stats.minimum = math.MaxUint64
 		b.stats.maximum = 0
 		// We could reset all values as a precaution, but it has a visible cost
 		// in benchmarks.
 		if invariants.Enabled && invariants.Sometimes(50) {
-			for i := 0; i < b.array.n; i++ {
-				b.array.elems.set(i, math.MaxUint64)
+			for i := range b.elems {
+				b.elems[i] = math.MaxUint64
 			}
 		}
 	}
@@ -254,32 +246,29 @@ func (b *UintBuilder) Reset() {
 func (b *UintBuilder) Get(row int) uint64 {
 	// If the UintBuilder is configured to use a zero value for unset rows, it's
 	// possible that the array has not been grown to a size that includes [row].
-	if b.array.n <= row {
+	if len(b.elems) <= row {
 		if invariants.Enabled && !b.useDefault {
-			panic(errors.AssertionFailedf("Get(%d) on UintBuilder with array of size %d", row, b.array.n))
+			panic(errors.AssertionFailedf("Get(%d) on UintBuilder with array of size %d", row, len(b.elems)))
 		}
 		return 0
 	}
-	return b.array.elems.At(row)
+	return b.elems[row]
 }
 
 // Set sets the value of the provided row index to v.
 func (b *UintBuilder) Set(row int, v uint64) {
-	if b.array.n <= row {
+	if len(b.elems) <= row {
 		// Double the size of the allocated array, or initialize it to at least 32
 		// values (256 bytes) if this is the first allocation. Then double until
 		// there's sufficient space.
-		n2 := max(b.array.n<<1, 32)
+		n2 := max(len(b.elems)<<1, 32)
 		for n2 <= row {
-			n2 <<= 1 /* double the size */
+			n2 <<= 1 // double the size
 		}
 		// NB: Go guarantees the allocated array will be 64-bit aligned.
-		newDataTyped := make([]uint64, n2)
-		copy(newDataTyped, b.array.elems.Slice(b.array.n))
-		newElems := makeUnsafeRawSlice[uint64](unsafe.Pointer(&newDataTyped[0]))
-		b.array.n = n2
-		b.array.elems = newElems
-
+		newElems := make([]uint64, n2)
+		copy(newElems, b.elems)
+		b.elems = newElems
 	}
 	// Maintain the running minimum and maximum for the purpose of maintaining
 	// knowledge of the delta encoding that would be used.
@@ -293,7 +282,7 @@ func (b *UintBuilder) Set(row int, v uint64) {
 			b.stats.encodingRow = row
 		}
 	}
-	b.array.elems.set(row, v)
+	b.elems[row] = v
 }
 
 // Size implements ColumnWriter and returns the size of the column if its first
@@ -332,7 +321,7 @@ func (b *UintBuilder) determineEncoding(rows int) (_ UintEncoding, deltaBase uin
 
 func (b *UintBuilder) recalculateEncoding(rows int) (_ UintEncoding, deltaBase uint64) {
 	// We have to recalculate the minimum and maximum.
-	minimum, maximum := computeMinMax(b.array.elems.Slice(min(rows, b.array.n)))
+	minimum, maximum := computeMinMax(b.elems[:min(rows, len(b.elems))])
 	if b.useDefault {
 		// Mirror the pessimism of the fast path so that the result is consistent.
 		// Otherwise, adding a row can result in a different encoding even when not
@@ -371,17 +360,8 @@ func (b *UintBuilder) Finish(col, rows int, offset uint32, buf []byte) uint32 {
 
 	e, minimum := b.determineEncoding(rows)
 
-	// NB: In some circumstances, it's possible for b.array.elems.ptr to be nil.
-	// Specifically, if the builder is initialized using InitWithDefault and no
-	// non-default values exist, no array will have been allocated (we lazily
-	// allocate b.array.elems.ptr). It's illegal to try to construct an unsafe
-	// slice from a nil ptr with non-zero rows. Only attempt to construct the
-	// values slice if there's actually a non-nil ptr.
-	var valuesSlice []uint64
-	if b.array.elems.ptr != nil {
-		valuesSlice = b.array.elems.Slice(min(rows, b.array.n))
-	}
-	return uintColumnFinish(rows, minimum, valuesSlice, e, offset, buf)
+	values := b.elems[:min(rows, len(b.elems))]
+	return uintColumnFinish(rows, minimum, values, e, offset, buf)
 }
 
 // uintColumnFinish finishes the column of unsigned integers of type T, applying
@@ -447,6 +427,7 @@ func (b *UintBuilder) WriteDebug(w io.Writer, rows int) {
 // The values slice can be smaller than dst; in that case, the values between
 // len(values) and len(dst) are assumed to be 0.
 func reduceUints[N constraints.Integer](minimum uint64, values []uint64, dst []N) {
+	_ = dst[len(values)-1]
 	for i, v := range values {
 		if invariants.Enabled {
 			if v < minimum {
@@ -456,12 +437,11 @@ func reduceUints[N constraints.Integer](minimum uint64, values []uint64, dst []N
 				panic("incorrect target width")
 			}
 		}
+		//gcassert:bce
 		dst[i] = N(v - minimum)
 	}
-	if invariants.Enabled && len(values) < len(dst) {
-		if minimum != 0 {
-			panic("incorrect minimum value")
-		}
+	if invariants.Enabled && len(values) < len(dst) && minimum != 0 {
+		panic("incorrect minimum value")
 	}
 	for i := len(values); i < len(dst); i++ {
 		dst[i] = 0
diff --git a/sstable/colblk_writer.go b/sstable/colblk_writer.go
index 9a63bc85..3b312150 100644
--- a/sstable/colblk_writer.go
+++ b/sstable/colblk_writer.go
@@ -372,7 +372,7 @@ func (w *RawColumnWriter) AddWithForceObsolete(
 				return err
 			}
 		}
-		valuePrefix = block.ValueHandlePrefix(eval.kcmp.PrefixEqual(), attribute)
+		valuePrefix = block.ValueBlockHandlePrefix(eval.kcmp.PrefixEqual(), attribute)
 	} else {
 		valueStoredWithKey = value
 		if len(value) > 0 {
diff --git a/sstable/layout.go b/sstable/layout.go
index 3bafe837..84997d1e 100644
--- a/sstable/layout.go
+++ b/sstable/layout.go
@@ -444,11 +444,16 @@ func formatRowblkDataBlock(
 		if fmtRecord != nil {
 			if r.tableFormat < TableFormatPebblev3 || key.Kind() != InternalKeyKindSet {
 				fmt.Fprintf(w, "\n         %s", fmtRecord(key, value))
-			} else if !block.ValuePrefix(value[0]).IsValueHandle() {
+				return
+			}
+			vp := block.ValuePrefix(value[0])
+			if vp.IsInPlaceValue() {
 				fmt.Fprintf(w, "\n         %s", fmtRecord(key, value[1:]))
-			} else {
+			} else if vp.IsValueBlockHandle() {
 				vh := valblk.DecodeHandle(value[1:])
 				fmt.Fprintf(w, "\n         %s", fmtRecord(key, []byte(fmt.Sprintf("value handle %+v", vh))))
+			} else {
+				panic(fmt.Sprintf("unknown value prefix: %d", value[0]))
 			}
 		}
 	})
diff --git a/sstable/reader_iter.go b/sstable/reader_iter.go
index e479c38f..e0a8e8bb 100644
--- a/sstable/reader_iter.go
+++ b/sstable/reader_iter.go
@@ -50,11 +50,9 @@ type Iterator interface {
 	NextPrefix(succKey []byte) *base.InternalKV
 
 	// SetCloseHook sets a function that will be called when the iterator is
-	// closed.  This is used by the file cache to release the reference count on
-	// the open sstable.Reader when the iterator is closed. The closures takes
-	// the iterator as a parameter to enable invariant-build tracking of leaked
-	// iterators.
-	SetCloseHook(fn func(any))
+	// closed. This is used by the file cache to release the reference count on
+	// the open sstable.Reader when the iterator is closed.
+	SetCloseHook(func())
 }
 
 // Iterator positioning optimizations and singleLevelIterator and
diff --git a/sstable/reader_iter_single_lvl.go b/sstable/reader_iter_single_lvl.go
index e50556be..8ee11336 100644
--- a/sstable/reader_iter_single_lvl.go
+++ b/sstable/reader_iter_single_lvl.go
@@ -71,7 +71,7 @@ type singleLevelIterator[I any, PI indexBlockIterator[I], D any, PD dataBlockIte
 	vbRH         objstorage.ReadHandle
 	vbRHPrealloc objstorageprovider.PreallocatedReadHandle
 	err          error
-	closeHook    func(i any)
+	closeHook    func()
 
 	readBlockEnv block.ReadEnv
 
@@ -224,13 +224,13 @@ func newColumnBlockSingleLevelIterator(
 		ctx, r, v, transforms, lower, upper, filterer, useFilterBlock,
 		env,
 	)
-	var getLazyValuer block.GetInternalValueForPrefixAndValueHandler
+	var getInternalValuer block.GetInternalValueForPrefixAndValueHandler
 	if r.Properties.NumValueBlocks > 0 {
 		i.vbReader = valblk.MakeReader(i, rp, r.valueBIH, env.Stats)
-		getLazyValuer = &i.vbReader
+		getInternalValuer = &i.vbReader
 		i.vbRH = r.blockReader.UsePreallocatedReadHandle(objstorage.NoReadBefore, &i.vbRHPrealloc)
 	}
-	i.data.InitOnce(r.keySchema, r.Comparer, getLazyValuer)
+	i.data.InitOnce(r.keySchema, r.Comparer, getInternalValuer)
 	indexH, err := r.readTopLevelIndexBlock(ctx, i.readBlockEnv, i.indexFilterRH)
 	if err == nil {
 		err = i.index.InitHandle(r.Comparer, indexH, transforms)
@@ -1515,9 +1515,8 @@ func (i *singleLevelIterator[I, PI, D, PD]) Error() error {
 
 // SetCloseHook sets a function that will be called when the iterator is closed.
 // This is used by the file cache to release the reference count on the open
-// sstable.Reader when the iterator is closed. The closures takes the iterator
-// as a parameter to enable invariant-build tracking of leaked iterators.
-func (i *singleLevelIterator[I, PI, D, PD]) SetCloseHook(fn func(i any)) {
+// sstable.Reader when the iterator is closed.
+func (i *singleLevelIterator[I, PI, D, PD]) SetCloseHook(fn func()) {
 	i.closeHook = fn
 }
 
@@ -1546,7 +1545,7 @@ func (i *singleLevelIterator[I, PI, D, PD]) closeInternal() error {
 	}
 
 	if i.closeHook != nil {
-		i.closeHook(i)
+		i.closeHook()
 	}
 	var err error
 	err = firstError(err, PD(&i.data).Close())
diff --git a/sstable/reader_iter_two_lvl.go b/sstable/reader_iter_two_lvl.go
index 48e6db7d..76b507f9 100644
--- a/sstable/reader_iter_two_lvl.go
+++ b/sstable/reader_iter_two_lvl.go
@@ -173,7 +173,7 @@ func newColumnBlockTwoLevelIterator(
 	i.secondLevel.init(ctx, r, v, transforms, lower, upper, filterer,
 		false, // Disable the use of the filter block in the second level.
 		env)
-	var getLazyValuer block.GetInternalValueForPrefixAndValueHandler
+	var getInternalValuer block.GetInternalValueForPrefixAndValueHandler
 	if r.Properties.NumValueBlocks > 0 {
 		// NB: we cannot avoid this ~248 byte allocation, since valueBlockReader
 		// can outlive the singleLevelIterator due to be being embedded in a
@@ -185,11 +185,11 @@ func newColumnBlockTwoLevelIterator(
 		// separated to their callers, they can put this valueBlockReader into a
 		// sync.Pool.
 		i.secondLevel.vbReader = valblk.MakeReader(&i.secondLevel, rp, r.valueBIH, env.Stats)
-		getLazyValuer = &i.secondLevel.vbReader
+		getInternalValuer = &i.secondLevel.vbReader
 		i.secondLevel.vbRH = r.blockReader.UsePreallocatedReadHandle(
 			objstorage.NoReadBefore, &i.secondLevel.vbRHPrealloc)
 	}
-	i.secondLevel.data.InitOnce(r.keySchema, r.Comparer, getLazyValuer)
+	i.secondLevel.data.InitOnce(r.keySchema, r.Comparer, getInternalValuer)
 	i.useFilterBlock = shouldUseFilterBlock(r, filterBlockSizeLimit)
 	topLevelIndexH, err := r.readTopLevelIndexBlock(ctx, i.secondLevel.readBlockEnv, i.secondLevel.indexFilterRH)
 	if err == nil {
@@ -1015,7 +1015,7 @@ func (i *twoLevelIterator[I, PI, D, PD]) SetContext(ctx context.Context) {
 	i.secondLevel.SetContext(ctx)
 }
 
-func (i *twoLevelIterator[I, PI, D, PD]) SetCloseHook(fn func(i any)) {
+func (i *twoLevelIterator[I, PI, D, PD]) SetCloseHook(fn func()) {
 	i.secondLevel.SetCloseHook(fn)
 }
 
diff --git a/sstable/rowblk/rowblk_iter.go b/sstable/rowblk/rowblk_iter.go
index 0f51e955..ee6e1077 100644
--- a/sstable/rowblk/rowblk_iter.go
+++ b/sstable/rowblk/rowblk_iter.go
@@ -703,7 +703,7 @@ func (i *Iter) SeekGE(key []byte, flags base.SeekGEFlags) *base.InternalKV {
 		if !i.lazyValueHandling.hasValuePrefix ||
 			i.ikv.K.Kind() != base.InternalKeyKindSet {
 			i.ikv.V = base.MakeInPlaceValue(i.val)
-		} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+		} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 			i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 		} else {
 			i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
@@ -989,7 +989,7 @@ func (i *Iter) SeekLT(key []byte, flags base.SeekLTFlags) *base.InternalKV {
 	if !i.lazyValueHandling.hasValuePrefix ||
 		i.ikv.K.Kind() != base.InternalKeyKindSet {
 		i.ikv.V = base.MakeInPlaceValue(i.val)
-	} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+	} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 		i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 	} else {
 		i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
@@ -1018,7 +1018,7 @@ func (i *Iter) First() *base.InternalKV {
 	if !i.lazyValueHandling.hasValuePrefix ||
 		i.ikv.K.Kind() != base.InternalKeyKindSet {
 		i.ikv.V = base.MakeInPlaceValue(i.val)
-	} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+	} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 		i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 	} else {
 		i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
@@ -1064,7 +1064,7 @@ func (i *Iter) Last() *base.InternalKV {
 	if !i.lazyValueHandling.hasValuePrefix ||
 		i.ikv.K.Kind() != base.InternalKeyKindSet {
 		i.ikv.V = base.MakeInPlaceValue(i.val)
-	} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+	} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 		i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 	} else {
 		i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
@@ -1123,7 +1123,7 @@ start:
 	if !i.lazyValueHandling.hasValuePrefix ||
 		i.ikv.K.Kind() != base.InternalKeyKindSet {
 		i.ikv.V = base.MakeInPlaceValue(i.val)
-	} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+	} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 		i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 	} else {
 		i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
@@ -1413,7 +1413,7 @@ func (i *Iter) nextPrefixV3(succKey []byte) *base.InternalKV {
 			}
 			if i.ikv.K.Kind() != base.InternalKeyKindSet {
 				i.ikv.V = base.MakeInPlaceValue(i.val)
-			} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+			} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 				i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 			} else {
 				i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
@@ -1471,7 +1471,7 @@ start:
 		if !i.lazyValueHandling.hasValuePrefix ||
 			i.ikv.K.Kind() != base.InternalKeyKindSet {
 			i.ikv.V = base.MakeInPlaceValue(i.val)
-		} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+		} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 			i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 		} else {
 			i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
@@ -1553,7 +1553,7 @@ start:
 	if !i.lazyValueHandling.hasValuePrefix ||
 		i.ikv.K.Kind() != base.InternalKeyKindSet {
 		i.ikv.V = base.MakeInPlaceValue(i.val)
-	} else if i.lazyValueHandling.getValue == nil || !block.ValuePrefix(i.val[0]).IsValueHandle() {
+	} else if i.lazyValueHandling.getValue == nil || block.ValuePrefix(i.val[0]).IsInPlaceValue() {
 		i.ikv.V = base.MakeInPlaceValue(i.val[1:])
 	} else {
 		i.ikv.V = i.lazyValueHandling.getValue.GetInternalValueForPrefixAndValueHandle(i.val)
diff --git a/sstable/rowblk_writer.go b/sstable/rowblk_writer.go
index 97d237dd..e3e65623 100644
--- a/sstable/rowblk_writer.go
+++ b/sstable/rowblk_writer.go
@@ -786,7 +786,7 @@ func (w *RawRowWriter) addPoint(key InternalKey, value []byte, forceObsolete boo
 				return err
 			}
 		}
-		prefix = block.ValueHandlePrefix(setHasSameKeyPrefix, attribute)
+		prefix = block.ValueBlockHandlePrefix(setHasSameKeyPrefix, attribute)
 	} else {
 		valueStoredWithKey = value
 		valueStoredWithKeyLen = len(value)
diff --git a/sstable/valblk/reader.go b/sstable/valblk/reader.go
index 529cb417..e068cad2 100644
--- a/sstable/valblk/reader.go
+++ b/sstable/valblk/reader.go
@@ -9,7 +9,6 @@ import (
 	"math/rand/v2"
 	"unsafe"
 
-	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/internal/invariants"
 	"github.com/cockroachdb/pebble/objstorage/objstorageprovider/objiotracing"
@@ -121,7 +120,7 @@ func (r *Reader) GetInternalValueForPrefixAndValueHandle(handle []byte) base.Int
 	*lazyFetcher = base.LazyFetcher{
 		Fetcher: r.fetcher,
 		Attribute: base.AttributeAndLen{
-			ValueLen:       int32(valLen),
+			ValueLen:       valLen,
 			ShortAttribute: block.ValuePrefix(handle[0]).ShortAttribute(),
 		},
 	}
@@ -191,7 +190,7 @@ func newValueBlockFetcher(
 
 // Fetch implements base.ValueFetcher.
 func (f *valueBlockFetcher) Fetch(
-	ctx context.Context, handle []byte, valLen int32, buf []byte,
+	ctx context.Context, handle []byte, blobFileNum base.DiskFileNum, valLen uint32, buf []byte,
 ) (val []byte, callerOwned bool, err error) {
 	if !f.closed {
 		val, err := f.getValueInternal(handle, valLen)
@@ -251,9 +250,9 @@ func (f *valueBlockFetcher) doValueMangling(v []byte) []byte {
 	return f.bufToMangle
 }
 
-func (f *valueBlockFetcher) getValueInternal(handle []byte, valLen int32) (val []byte, err error) {
+func (f *valueBlockFetcher) getValueInternal(handle []byte, valLen uint32) (val []byte, err error) {
 	vh := DecodeRemainingHandle(handle)
-	vh.ValueLen = uint32(valLen)
+	vh.ValueLen = valLen
 	if f.vbiBlock == nil {
 		ch, err := f.bpOpen.ReadValueBlock(f.vbih.Handle, f.stats)
 		if err != nil {
@@ -284,26 +283,5 @@ func (f *valueBlockFetcher) getValueInternal(handle []byte, valLen int32) (val [
 }
 
 func (f *valueBlockFetcher) getBlockHandle(blockNum uint32) (block.Handle, error) {
-	indexEntryLen :=
-		int(f.vbih.BlockNumByteLength + f.vbih.BlockOffsetByteLength + f.vbih.BlockLengthByteLength)
-	offsetInIndex := indexEntryLen * int(blockNum)
-	if len(f.vbiBlock) < offsetInIndex+indexEntryLen {
-		return block.Handle{}, base.AssertionFailedf(
-			"index entry out of bounds: offset %d length %d block length %d",
-			offsetInIndex, indexEntryLen, len(f.vbiBlock))
-	}
-	b := f.vbiBlock[offsetInIndex : offsetInIndex+indexEntryLen]
-	n := int(f.vbih.BlockNumByteLength)
-	bn := littleEndianGet(b, n)
-	if uint32(bn) != blockNum {
-		return block.Handle{},
-			errors.Errorf("expected block num %d but found %d", blockNum, bn)
-	}
-	b = b[n:]
-	n = int(f.vbih.BlockOffsetByteLength)
-	blockOffset := littleEndianGet(b, n)
-	b = b[n:]
-	n = int(f.vbih.BlockLengthByteLength)
-	blockLen := littleEndianGet(b, n)
-	return block.Handle{Offset: blockOffset, Length: blockLen}, nil
+	return DecodeBlockHandleFromIndex(f.vbiBlock, blockNum, f.vbih)
 }
diff --git a/sstable/valblk/valblk.go b/sstable/valblk/valblk.go
index d6463f12..32d56fd3 100644
--- a/sstable/valblk/valblk.go
+++ b/sstable/valblk/valblk.go
@@ -172,6 +172,7 @@ import (
 	"unsafe"
 
 	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/sstable/block"
 )
 
@@ -367,6 +368,34 @@ func DecodeIndex(data []byte, vbih IndexHandle) ([]block.Handle, error) {
 	return valueBlocks, nil
 }
 
+// DecodeBlockHandleFromIndex decodes the block handle for the given block
+// number from the provided index block.
+func DecodeBlockHandleFromIndex(
+	vbiBlock []byte, blockNum uint32, indexHandle IndexHandle,
+) (block.Handle, error) {
+	w := indexHandle.RowWidth()
+	off := w * int(blockNum)
+	if len(vbiBlock) < off+w {
+		return block.Handle{}, base.CorruptionErrorf(
+			"index entry out of bounds: offset %d length %d block length %d",
+			off, w, len(vbiBlock))
+	}
+	b := vbiBlock[off : off+w]
+	n := int(indexHandle.BlockNumByteLength)
+	bn := littleEndianGet(b, n)
+	if uint32(bn) != blockNum {
+		return block.Handle{},
+			errors.Errorf("expected block num %d but found %d", blockNum, bn)
+	}
+	b = b[n:]
+	n = int(indexHandle.BlockOffsetByteLength)
+	blockOffset := littleEndianGet(b, n)
+	b = b[n:]
+	n = int(indexHandle.BlockLengthByteLength)
+	blockLen := littleEndianGet(b, n)
+	return block.Handle{Offset: blockOffset, Length: blockLen}, nil
+}
+
 // littleEndianPut writes v to b using little endian encoding, under the
 // assumption that v can be represented using n bytes.
 func littleEndianPut(v uint64, b []byte, n int) {
diff --git a/table_stats.go b/table_stats.go
index 952b77cc..3855d02b 100644
--- a/table_stats.go
+++ b/table_stats.go
@@ -305,7 +305,8 @@ func (d *DB) loadTableStats(
 	var stats manifest.TableStats
 	var compactionHints []deleteCompactionHint
 	err := d.fileCache.withCommonReader(
-		meta, func(r sstable.CommonReader) (err error) {
+		context.TODO(), block.NoReadEnv,
+		meta, func(r sstable.CommonReader, _ block.ReadEnv) (err error) {
 			props := r.CommonProperties()
 			stats.NumEntries = props.NumEntries
 			stats.NumDeletions = props.NumDeletions
@@ -487,14 +488,14 @@ func (d *DB) estimateSizesBeneath(
 	keySum += fileProps.RawKeySize
 	valSum += fileProps.RawValueSize
 
-	addPhysicalTableStats := func(r *sstable.Reader) (err error) {
+	addPhysicalTableStats := func(r *sstable.Reader, _ block.ReadEnv) (err error) {
 		fileSum += file.Size
 		entryCount += r.Properties.NumEntries
 		keySum += r.Properties.RawKeySize
 		valSum += r.Properties.RawValueSize
 		return nil
 	}
-	addVirtualTableStats := func(v sstable.VirtualReader) (err error) {
+	addVirtualTableStats := func(v sstable.VirtualReader, _ block.ReadEnv) (err error) {
 		fileSum += file.Size
 		entryCount += file.Stats.NumEntries
 		keySum += v.Properties.RawKeySize
@@ -508,9 +509,9 @@ func (d *DB) estimateSizesBeneath(
 		for file = iter.First(); file != nil; file = iter.Next() {
 			var err error
 			if file.Virtual {
-				err = d.fileCache.withVirtualReader(file.VirtualMeta(), addVirtualTableStats)
+				err = d.fileCache.withVirtualReader(context.TODO(), block.NoReadEnv, file.VirtualMeta(), addVirtualTableStats)
 			} else {
-				err = d.fileCache.withReader(file.PhysicalMeta(), addPhysicalTableStats)
+				err = d.fileCache.withReader(context.TODO(), block.NoReadEnv, file.PhysicalMeta(), addPhysicalTableStats)
 			}
 			if err != nil {
 				return 0, 0, err
@@ -616,13 +617,15 @@ func (d *DB) estimateReclaimedSizeBeneath(
 				var err error
 				if file.Virtual {
 					err = d.fileCache.withVirtualReader(
-						file.VirtualMeta(), func(r sstable.VirtualReader) (err error) {
+						context.TODO(), block.NoReadEnv,
+						file.VirtualMeta(), func(r sstable.VirtualReader, _ block.ReadEnv) (err error) {
 							size, err = r.EstimateDiskUsage(start, end)
 							return err
 						})
 				} else {
 					err = d.fileCache.withReader(
-						file.PhysicalMeta(), func(r *sstable.Reader) (err error) {
+						context.TODO(), block.NoReadEnv,
+						file.PhysicalMeta(), func(r *sstable.Reader, _ block.ReadEnv) (err error) {
 							size, err = r.EstimateDiskUsage(start, end)
 							return err
 						})
diff --git a/vfs/mem_fs.go b/vfs/mem_fs.go
index a6c289a2..391aa3c6 100644
--- a/vfs/mem_fs.go
+++ b/vfs/mem_fs.go
@@ -620,6 +620,18 @@ func (y *MemFS) GetDiskUsage(string) (DiskUsage, error) {
 // Unwrap implements FS.Unwrap.
 func (*MemFS) Unwrap() FS { return nil }
 
+// UnsafeGetFileDataBuffer returns the buffer holding the data for a file. Must
+// not be used while concurrent updates are happening to the file. The buffer
+// cannot be modified while concurrent file reads are happening.
+func (y *MemFS) UnsafeGetFileDataBuffer(fullname string) ([]byte, error) {
+	f, err := y.Open(fullname)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return f.(*memFile).n.mu.data, nil
+}
+
 // memNode holds a file's data or a directory's children.
 type memNode struct {
 	isDir bool
diff --git a/wal/failover_manager.go b/wal/failover_manager.go
index 7acaf2a4..6814bcb4 100644
--- a/wal/failover_manager.go
+++ b/wal/failover_manager.go
@@ -631,6 +631,7 @@ func (wm *failoverManager) Create(wn NumWAL, jobID int) (Writer, error) {
 		failoverWriteAndSyncLatency: wm.opts.FailoverWriteAndSyncLatency,
 		writerClosed:                wm.writerClosed,
 		writerCreatedForTest:        wm.opts.logWriterCreatedForTesting,
+		writeWALSyncOffsets:         wm.opts.WriteWALSyncOffsets,
 	}
 	var err error
 	var ww *failoverWriter
diff --git a/wal/failover_writer.go b/wal/failover_writer.go
index 198e4bd8..0b73e3a5 100644
--- a/wal/failover_writer.go
+++ b/wal/failover_writer.go
@@ -463,6 +463,9 @@ type failoverWriterOpts struct {
 	writerClosed                func(logicalLogWithSizesEtc)
 
 	writerCreatedForTest chan<- struct{}
+
+	// writeWALSyncOffsets represents whether to write the WAL sync chunk format.
+	writeWALSyncOffsets bool
 }
 
 func simpleLogCreator(
@@ -633,6 +636,7 @@ func (ww *failoverWriter) switchToNewDir(dir dirAndFileHandle) error {
 				WALFsyncLatency:           ww.opts.fsyncLatency,
 				QueueSemChan:              ww.opts.queueSemChan,
 				ExternalSyncQueueCallback: ww.doneSyncCallback,
+				WriteWALSyncOffsets:       ww.opts.writeWALSyncOffsets,
 			})
 		closeWriter := func() bool {
 			ww.mu.Lock()
diff --git a/wal/standalone_manager.go b/wal/standalone_manager.go
index 2c228369..26f70741 100644
--- a/wal/standalone_manager.go
+++ b/wal/standalone_manager.go
@@ -194,9 +194,10 @@ func (m *StandaloneManager) Create(wn NumWAL, jobID int) (Writer, error) {
 		PreallocateSize: m.o.PreallocateSize(),
 	})
 	w := record.NewLogWriter(newLogFile, newLogNum, record.LogWriterConfig{
-		WALFsyncLatency:    m.o.FsyncLatency,
-		WALMinSyncInterval: m.o.MinSyncInterval,
-		QueueSemChan:       m.o.QueueSemChan,
+		WALFsyncLatency:     m.o.FsyncLatency,
+		WALMinSyncInterval:  m.o.MinSyncInterval,
+		QueueSemChan:        m.o.QueueSemChan,
+		WriteWALSyncOffsets: m.o.WriteWALSyncOffsets,
 	})
 	m.w = &standaloneWriter{
 		m: m,
diff --git a/wal/wal.go b/wal/wal.go
index 2616442c..5291d016 100644
--- a/wal/wal.go
+++ b/wal/wal.go
@@ -146,6 +146,10 @@ type Options struct {
 	// FailoverWriteAndSyncLatency is only populated when WAL failover is
 	// configured.
 	FailoverWriteAndSyncLatency prometheus.Histogram
+
+	// WriteWALSyncOffsets represents whether to write the WAL sync chunk format.
+	// It is plumbed down from wal.Options to record.newLogWriter.
+	WriteWALSyncOffsets bool
 }
 
 // Init constructs and initializes a WAL manager from the provided options and



Test patch:
diff --git a/checkpoint_test.go b/checkpoint_test.go
index 112d37b7..7f1a16c7 100644
--- a/checkpoint_test.go
+++ b/checkpoint_test.go
@@ -108,7 +108,7 @@ func testCheckpointImpl(t *testing.T, ddFile string, createOnShared bool) {
 
 			// Hacky but the command doesn't expect a db string. Get rid of it.
 			td.CmdArgs = td.CmdArgs[1:]
-			if err := runIngestAndExciseCmd(td, d, mem); err != nil {
+			if err := runIngestAndExciseCmd(td, d); err != nil {
 				return err.Error()
 			}
 			return ""
diff --git a/cockroachkvs/cockroachkvs_bench_test.go b/cockroachkvs/cockroachkvs_bench_test.go
index 7c5edbd9..e7ea2256 100644
--- a/cockroachkvs/cockroachkvs_bench_test.go
+++ b/cockroachkvs/cockroachkvs_bench_test.go
@@ -149,30 +149,15 @@ func benchmarkRandSeekInSST(
 	b.StopTimer()
 }
 
-func BenchmarkCockroachDataBlockWriter(b *testing.B) {
-	for _, alphaLen := range []int{4, 8, 26} {
-		for _, lenSharedPct := range []float64{0.25, 0.5} {
-			for _, roachKeyLen := range []int{8, 32, 128} {
-				lenShared := int(float64(roachKeyLen) * lenSharedPct)
-				for _, valueLen := range []int{8, 128, 1024} {
-					keyConfig := keyGenConfig{
-						PrefixAlphabetLen: alphaLen,
-						RoachKeyLen:       roachKeyLen,
-						PrefixLenShared:   lenShared,
-						PercentLogical:    0,
-						AvgKeysPerPrefix:  2,
-						BaseWallTime:      uint64(time.Now().UnixNano()),
-					}
-					b.Run(fmt.Sprintf("%s,valueLen=%d", keyConfig, valueLen), func(b *testing.B) {
-						benchmarkCockroachDataBlockWriter(b, keyConfig, valueLen)
-					})
-				}
-			}
-		}
+func BenchmarkCockroachDataColBlockWriter(b *testing.B) {
+	for _, cfg := range benchConfigs {
+		b.Run(cfg.String(), func(b *testing.B) {
+			benchmarkCockroachDataColBlockWriter(b, cfg.keyGenConfig, cfg.ValueLen)
+		})
 	}
 }
 
-func benchmarkCockroachDataBlockWriter(b *testing.B, keyConfig keyGenConfig, valueLen int) {
+func benchmarkCockroachDataColBlockWriter(b *testing.B, keyConfig keyGenConfig, valueLen int) {
 	const targetBlockSize = 32 << 10
 	seed := uint64(time.Now().UnixNano())
 	rng := rand.New(rand.NewPCG(0, seed))
@@ -195,36 +180,7 @@ func benchmarkCockroachDataBlockWriter(b *testing.B, keyConfig keyGenConfig, val
 	}
 }
 
-func BenchmarkCockroachDataBlockIterFull(b *testing.B) {
-	for _, alphaLen := range []int{4, 8, 26} {
-		for _, lenSharedPct := range []float64{0.25, 0.5} {
-			for _, roachKeyLen := range []int{8, 32, 128} {
-				lenShared := int(float64(roachKeyLen) * lenSharedPct)
-				for _, avgKeysPerPrefix := range []int{1, 10, 100} {
-					for _, percentLogical := range []int{0, 50} {
-						for _, valueLen := range []int{8, 128, 1024} {
-							cfg := benchConfig{
-								keyGenConfig: keyGenConfig{
-									PrefixAlphabetLen: alphaLen,
-									RoachKeyLen:       roachKeyLen,
-									PrefixLenShared:   lenShared,
-									AvgKeysPerPrefix:  avgKeysPerPrefix,
-									PercentLogical:    percentLogical,
-								},
-								ValueLen: valueLen,
-							}
-							b.Run(cfg.String(), func(b *testing.B) {
-								benchmarkCockroachDataBlockIter(b, cfg, block.IterTransforms{})
-							})
-						}
-					}
-				}
-			}
-		}
-	}
-}
-
-var shortBenchConfigs = []benchConfig{
+var benchConfigs = []benchConfig{
 	{
 		keyGenConfig: keyGenConfig{
 			PrefixAlphabetLen: 8,
@@ -241,21 +197,31 @@ var shortBenchConfigs = []benchConfig{
 			RoachKeyLen:       128,
 			PrefixLenShared:   64,
 			AvgKeysPerPrefix:  4,
-			PercentLogical:    10,
+			PercentLogical:    50,
 		},
 		ValueLen: 128,
 	},
+	{
+		keyGenConfig: keyGenConfig{
+			PrefixAlphabetLen: 26,
+			RoachKeyLen:       1024,
+			PrefixLenShared:   512,
+			AvgKeysPerPrefix:  1,
+			PercentLogical:    0,
+		},
+		ValueLen: 1024,
+	},
 }
 
-func BenchmarkCockroachDataBlockIterShort(b *testing.B) {
-	for _, cfg := range shortBenchConfigs {
+func BenchmarkCockroachDataColBlockIter(b *testing.B) {
+	for _, cfg := range benchConfigs {
 		b.Run(cfg.String(), func(b *testing.B) {
-			benchmarkCockroachDataBlockIter(b, cfg, block.IterTransforms{})
+			benchmarkCockroachDataColBlockIter(b, cfg, block.IterTransforms{})
 		})
 	}
 }
 
-func BenchmarkCockroachDataBlockIterTransforms(b *testing.B) {
+func BenchmarkCockroachDataColBlockIterTransforms(b *testing.B) {
 	transforms := []struct {
 		description string
 		transforms  block.IterTransforms
@@ -289,11 +255,11 @@ func BenchmarkCockroachDataBlockIterTransforms(b *testing.B) {
 			},
 		},
 	}
-	for _, cfg := range shortBenchConfigs {
+	for _, cfg := range benchConfigs {
 		for _, t := range transforms {
 			name := cfg.String() + crstrings.If(t.description != "", ","+t.description)
 			b.Run(name, func(b *testing.B) {
-				benchmarkCockroachDataBlockIter(b, cfg, t.transforms)
+				benchmarkCockroachDataColBlockIter(b, cfg, t.transforms)
 			})
 		}
 	}
@@ -308,7 +274,7 @@ func (cfg benchConfig) String() string {
 	return fmt.Sprintf("%s,ValueLen=%d", cfg.keyGenConfig, cfg.ValueLen)
 }
 
-func benchmarkCockroachDataBlockIter(
+func benchmarkCockroachDataColBlockIter(
 	b *testing.B, cfg benchConfig, transforms block.IterTransforms,
 ) {
 	const targetBlockSize = 32 << 10
diff --git a/cockroachkvs/rowblk_bench_test.go b/cockroachkvs/rowblk_bench_test.go
index c17af433..9839e388 100644
--- a/cockroachkvs/rowblk_bench_test.go
+++ b/cockroachkvs/rowblk_bench_test.go
@@ -6,7 +6,6 @@ package cockroachkvs
 
 import (
 	"bytes"
-	"fmt"
 	"math/rand/v2"
 	"testing"
 	"time"
@@ -17,25 +16,10 @@ import (
 )
 
 func BenchmarkCockroachDataRowBlockWriter(b *testing.B) {
-	for _, alphaLen := range []int{4, 8, 26} {
-		for _, lenSharedPct := range []float64{0.25, 0.5} {
-			for _, roachKeyLen := range []int{8, 32, 128} {
-				lenShared := int(float64(roachKeyLen) * lenSharedPct)
-				for _, valueLen := range []int{8, 128, 1024} {
-					keyConfig := keyGenConfig{
-						PrefixAlphabetLen: alphaLen,
-						RoachKeyLen:       roachKeyLen,
-						PrefixLenShared:   lenShared,
-						AvgKeysPerPrefix:  2,
-						PercentLogical:    0,
-						BaseWallTime:      uint64(time.Now().UnixNano()),
-					}
-					b.Run(fmt.Sprintf("%s,valueLen=%d", keyConfig, valueLen), func(b *testing.B) {
-						benchmarkCockroachDataRowBlockWriter(b, keyConfig, valueLen)
-					})
-				}
-			}
-		}
+	for _, cfg := range benchConfigs {
+		b.Run(cfg.String(), func(b *testing.B) {
+			benchmarkCockroachDataRowBlockWriter(b, cfg.keyGenConfig, cfg.ValueLen)
+		})
 	}
 }
 
@@ -67,28 +51,11 @@ func benchmarkCockroachDataRowBlockWriter(b *testing.B, keyConfig keyGenConfig,
 	}
 }
 
-func BenchmarkCockroachDataBlockIter(b *testing.B) {
-	for _, alphaLen := range []int{4, 8, 26} {
-		for _, lenSharedPct := range []float64{0.25, 0.5} {
-			for _, roachKeyLen := range []int{8, 32, 128} {
-				lenShared := int(float64(roachKeyLen) * lenSharedPct)
-				for _, logical := range []int{0, 100} {
-					for _, valueLen := range []int{8, 128, 1024} {
-						keyConfig := keyGenConfig{
-							PrefixAlphabetLen: alphaLen,
-							RoachKeyLen:       roachKeyLen,
-							PrefixLenShared:   lenShared,
-							PercentLogical:    logical,
-							BaseWallTime:      uint64(time.Now().UnixNano()),
-						}
-						b.Run(fmt.Sprintf("%s,value=%d", keyConfig, valueLen),
-							func(b *testing.B) {
-								benchmarkCockroachDataRowBlockIter(b, keyConfig, valueLen)
-							})
-					}
-				}
-			}
-		}
+func BenchmarkCockroachDataRowBlockIter(b *testing.B) {
+	for _, cfg := range benchConfigs {
+		b.Run(cfg.String(), func(b *testing.B) {
+			benchmarkCockroachDataRowBlockIter(b, cfg.keyGenConfig, cfg.ValueLen)
+		})
 	}
 }
 
diff --git a/compaction_picker_test.go b/compaction_picker_test.go
index 9667aa4b..9571ae94 100644
--- a/compaction_picker_test.go
+++ b/compaction_picker_test.go
@@ -1386,7 +1386,7 @@ func TestCompactionPickerPickFile(t *testing.T) {
 			return ""
 
 		case "ingest-and-excise":
-			if err := runIngestAndExciseCmd(td, d, d.opts.FS); err != nil {
+			if err := runIngestAndExciseCmd(td, d); err != nil {
 				return err.Error()
 			}
 			return ""
diff --git a/data_test.go b/data_test.go
index 87b6ec70..58e2e53c 100644
--- a/data_test.go
+++ b/data_test.go
@@ -1252,7 +1252,18 @@ func (d *DB) waitTableStats() {
 	}
 }
 
-func runIngestAndExciseCmd(td *datadriven.TestData, d *DB, fs vfs.FS) error {
+func runExciseCmd(td *datadriven.TestData, d *DB) error {
+	if len(td.CmdArgs) != 2 {
+		return errors.New("excise expects two arguments: <start-key> <end-key>")
+	}
+	exciseSpan := KeyRange{
+		Start: []byte(td.CmdArgs[0].String()),
+		End:   []byte(td.CmdArgs[1].String()),
+	}
+	return d.Excise(context.Background(), exciseSpan)
+}
+
+func runIngestAndExciseCmd(td *datadriven.TestData, d *DB) error {
 	var exciseSpan KeyRange
 	paths := make([]string, 0, len(td.CmdArgs))
 	for i, arg := range td.CmdArgs {
diff --git a/db_test.go b/db_test.go
index f3f3bbce..6a13f1df 100644
--- a/db_test.go
+++ b/db_test.go
@@ -2198,6 +2198,13 @@ func TestDeterminism(t *testing.T) {
 					require.NoError(t, runBuildCmd(td, d, fs))
 					return ""
 				})
+			case "excise":
+				return addStep(td, func(td *datadriven.TestData) string {
+					if err := runExciseCmd(td, d); err != nil {
+						return err.Error()
+					}
+					return ""
+				})
 			case "flush":
 				return addStep(td, func(td *datadriven.TestData) string {
 					_, err := d.AsyncFlush()
@@ -2208,7 +2215,7 @@ func TestDeterminism(t *testing.T) {
 				})
 			case "ingest-and-excise":
 				return addStep(td, func(td *datadriven.TestData) string {
-					if err := runIngestAndExciseCmd(td, d, fs); err != nil {
+					if err := runIngestAndExciseCmd(td, d); err != nil {
 						return err.Error()
 					}
 					return ""
diff --git a/error_test.go b/error_test.go
index 9a128c20..ccbb7b9e 100644
--- a/error_test.go
+++ b/error_test.go
@@ -16,6 +16,7 @@ import (
 	"time"
 
 	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/internal/testkeys"
 	"github.com/cockroachdb/pebble/vfs"
 	"github.com/cockroachdb/pebble/vfs/errorfs"
@@ -271,7 +272,7 @@ func TestCorruptReadError(t *testing.T) {
 		opts := &Options{
 			DisableTableStats:  true,
 			FS:                 fs,
-			Logger:             panicLogger{},
+			Logger:             base.NoopLoggerAndTracer{},
 			FormatMajorVersion: formatVersion,
 		}
 		d, err := Open("", opts)
diff --git a/event_listener_test.go b/event_listener_test.go
index c6a1f782..ad3cce5b 100644
--- a/event_listener_test.go
+++ b/event_listener_test.go
@@ -10,6 +10,7 @@ import (
 	"fmt"
 	"reflect"
 	"runtime"
+	"slices"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -20,6 +21,7 @@ import (
 	"github.com/cockroachdb/datadriven"
 	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble/internal/base"
+	"github.com/cockroachdb/pebble/internal/testutils"
 	"github.com/cockroachdb/pebble/objstorage/objstorageprovider"
 	"github.com/cockroachdb/pebble/sstable"
 	"github.com/cockroachdb/pebble/vfs"
@@ -350,12 +352,6 @@ func TestEventListenerRedact(t *testing.T) {
 	require.Equal(t, "[JOB 5] WAL delete error: unredacted error: ‹×›\n", log.String())
 }
 
-func TestEventListenerEnsureDefaultsBackgroundError(t *testing.T) {
-	e := EventListener{}
-	e.EnsureDefaults(nil)
-	e.BackgroundError(errors.New("an example error"))
-}
-
 func TestEventListenerEnsureDefaultsSetsAllCallbacks(t *testing.T) {
 	e := EventListener{}
 	e.EnsureDefaults(nil)
@@ -367,6 +363,67 @@ func TestMakeLoggingEventListenerSetsAllCallbacks(t *testing.T) {
 	testAllCallbacksSetInEventListener(t, e)
 }
 
+type mockLogger struct {
+	infoFunc  func(format string, args ...interface{})
+	errorFunc func(format string, args ...interface{})
+	fatalFunc func(format string, args ...interface{})
+}
+
+func (l *mockLogger) Infof(format string, args ...interface{}) {
+	if l.infoFunc != nil {
+		l.infoFunc(format, args...)
+	}
+}
+
+func (l *mockLogger) Errorf(format string, args ...interface{}) {
+	if l.errorFunc != nil {
+		l.errorFunc(format, args...)
+	}
+}
+
+func (l *mockLogger) Fatalf(format string, args ...interface{}) {
+	if l.fatalFunc != nil {
+		l.fatalFunc(format, args...)
+	}
+}
+
+func newCountingMockLogger(t *testing.T) (*mockLogger, *int, *int) {
+	var infoCount, errorCount int
+	return &mockLogger{
+		infoFunc: func(format string, args ...interface{}) {
+			infoCount++
+		},
+		errorFunc: func(format string, args ...interface{}) {
+			errorCount++
+		},
+		fatalFunc: func(format string, args ...interface{}) {
+			t.Fatal("Unexpected call to Fatalf")
+		},
+	}, &infoCount, &errorCount
+}
+
+func TestMakeLoggingEventListenerBackgroundErrorCancelledCompaction(t *testing.T) {
+	mockLogger, infoCount, errorCount := newCountingMockLogger(t)
+	e := MakeLoggingEventListener(mockLogger)
+
+	e.BackgroundError(ErrCancelledCompaction)
+	require.Equal(t, 1, *infoCount)
+	require.Equal(t, 0, *errorCount)
+
+	testAllCallbacksSetInEventListener(t, e)
+}
+
+func TestMakeLoggingEventListenerBackgroundErrorOtherError(t *testing.T) {
+	mockLogger, infoCount, errorCount := newCountingMockLogger(t)
+	e := MakeLoggingEventListener(mockLogger)
+
+	e.BackgroundError(errors.New("an example error"))
+	require.Equal(t, 0, *infoCount)
+	require.Equal(t, 1, *errorCount)
+
+	testAllCallbacksSetInEventListener(t, e)
+}
+
 func TestTeeEventListenerSetsAllCallbacks(t *testing.T) {
 	e := TeeEventListener(EventListener{}, EventListener{})
 	testAllCallbacksSetInEventListener(t, e)
@@ -481,3 +538,76 @@ type mockDiskUsageFS struct {
 func (fs *mockDiskUsageFS) GetDiskUsage(path string) (vfs.DiskUsage, error) {
 	return fs.usage.Load().(vfs.DiskUsage), nil
 }
+
+func TestSSTCorruptionEvent(t *testing.T) {
+	for _, test := range []string{"missing-file", "missing-before-open", "meta-block-corruption", "data-block-corruption"} {
+		t.Run(test, func(t *testing.T) {
+			var mu sync.Mutex
+			var events []DataCorruptionInfo
+			fs := vfs.NewMem()
+			opts := &Options{
+				FS: fs,
+				// We use panicLogger to avoid errors being printed and make sure Fatalf
+				// is not called.
+				Logger: panicLogger{},
+				EventListener: &EventListener{
+					DataCorruption: func(info DataCorruptionInfo) {
+						mu.Lock()
+						defer mu.Unlock()
+						events = append(events, info)
+					},
+				},
+				DisableAutomaticCompactions: true,
+			}
+			d, err := Open("", opts)
+			require.NoError(t, err)
+			key := func(k int) []byte {
+				return []byte(fmt.Sprintf("key-%05d", k))
+			}
+
+			for i := 0; i < 100; i++ {
+				d.Set(key(i), []byte(fmt.Sprintf("value-%05d", i)), nil)
+			}
+			require.NoError(t, d.Flush())
+			require.NoError(t, d.Compact([]byte("a"), []byte("z"), false /* parallelize */))
+
+			// We expect a single sst file.
+			files := testutils.CheckErr(fs.List(""))
+			files = slices.DeleteFunc(files, func(name string) bool {
+				return !strings.HasSuffix(name, ".sst")
+			})
+			require.Lenf(t, files, 1, "expected a single sst file, got %v", files)
+			sstFileName := files[0]
+
+			switch test {
+			case "missing-file":
+				require.NoError(t, fs.Remove(sstFileName))
+			case "missing-before-open":
+				require.NoError(t, d.Close())
+				require.NoError(t, fs.Remove(sstFileName))
+				opts.DisableConsistencyCheck = true
+				d, err = Open("", opts)
+				require.NoError(t, err)
+			case "meta-block-corruption":
+				buf, err := fs.UnsafeGetFileDataBuffer(sstFileName)
+				require.NoError(t, err)
+				buf[len(buf)-100]++
+			case "data-block-corruption":
+				buf, err := fs.UnsafeGetFileDataBuffer(sstFileName)
+				require.NoError(t, err)
+				buf[20]++
+			default:
+				t.Fatalf("invalid test")
+			}
+			_, _, err = d.Get(key(5))
+			require.Error(t, err)
+			require.Greater(t, len(events), 0)
+			info := events[0]
+			require.Equal(t, info.Path, sstFileName)
+			require.False(t, info.IsRemote)
+			require.Equal(t, base.UserKeyBoundsInclusive(key(0), key(99)), info.Bounds)
+
+			d.Close()
+		})
+	}
+}
diff --git a/file_cache_test.go b/file_cache_test.go
index 4391745c..0901b2b4 100644
--- a/file_cache_test.go
+++ b/file_cache_test.go
@@ -178,6 +178,8 @@ func (t *fileCacheTest) cleanup() {
 	t.blockCache.Unref()
 }
 
+func noopSSTableCorruptionFn(*manifest.TableMetadata, error) {}
+
 // newTestHandle creates a filesystem with a set of test tables and an
 // associated file cache handle. The caller must close the handle.
 func (t *fileCacheTest) newTestHandle() (*fileCacheHandle, *fileCacheTestFS) {
@@ -219,7 +221,7 @@ func (t *fileCacheTest) newTestHandle() (*fileCacheHandle, *fileCacheTestFS) {
 		FileCache: t.fileCache,
 	}
 	opts.EnsureDefaults()
-	h := t.fileCache.newHandle(t.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions())
+	h := t.fileCache.newHandle(t.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions(), noopSSTableCorruptionFn)
 	return h, fs
 }
 
@@ -1044,7 +1046,7 @@ func TestFileCacheErrorBadMagicNumber(t *testing.T) {
 		FileCache: fcs.fileCache,
 	}
 	opts.EnsureDefaults()
-	c := opts.FileCache.newHandle(fcs.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions())
+	c := opts.FileCache.newHandle(fcs.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions(), noopSSTableCorruptionFn)
 	require.NoError(t, err)
 	defer c.Close()
 
@@ -1118,7 +1120,7 @@ func TestFileCacheClockPro(t *testing.T) {
 		LoggerAndTracer: &base.LoggerWithNoopTracer{Logger: base.DefaultLogger},
 	}
 	opts.EnsureDefaults()
-	h := fcs.fileCache.newHandle(fcs.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions())
+	h := fcs.fileCache.newHandle(fcs.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions(), noopSSTableCorruptionFn)
 	defer h.Close()
 
 	scanner := bufio.NewScanner(f)
@@ -1142,7 +1144,7 @@ func TestFileCacheClockPro(t *testing.T) {
 		m := &tableMetadata{FileNum: base.FileNum(key)}
 		m.InitPhysicalBacking()
 		m.FileBacking.Ref()
-		v, err := h.findOrCreate(context.Background(), m.FileBacking.DiskFileNum)
+		v, err := h.findOrCreate(context.Background(), m)
 		require.NoError(t, err)
 		v.Unref()
 
@@ -1254,7 +1256,7 @@ func BenchmarkFileCacheHotPath(b *testing.B) {
 		LoggerAndTracer: &base.LoggerWithNoopTracer{Logger: base.DefaultLogger},
 	}
 	opts.EnsureDefaults()
-	h := fcs.fileCache.newHandle(fcs.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions())
+	h := fcs.fileCache.newHandle(fcs.blockCacheHandle, objProvider, opts.LoggerAndTracer, opts.MakeReaderOptions(), noopSSTableCorruptionFn)
 	defer h.Close()
 
 	makeTable(1)
@@ -1265,7 +1267,7 @@ func BenchmarkFileCacheHotPath(b *testing.B) {
 
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
-		v, _ := h.findOrCreate(context.Background(), m.FileBacking.DiskFileNum)
+		v, _ := h.findOrCreate(context.Background(), m)
 		v.Unref()
 	}
 }
diff --git a/format_major_version_test.go b/format_major_version_test.go
index 78c10244..5da29017 100644
--- a/format_major_version_test.go
+++ b/format_major_version_test.go
@@ -25,11 +25,12 @@ func TestFormatMajorVersionStableValues(t *testing.T) {
 	require.Equal(t, FormatVirtualSSTables, FormatMajorVersion(16))
 	require.Equal(t, FormatSyntheticPrefixSuffix, FormatMajorVersion(17))
 	require.Equal(t, FormatFlushableIngestExcises, FormatMajorVersion(18))
+	require.Equal(t, FormatColumnarBlocks, FormatMajorVersion(19))
 
 	// When we add a new version, we should add a check for the new version in
 	// addition to updating these expected values.
-	require.Equal(t, FormatNewest, FormatMajorVersion(19))
-	require.Equal(t, internalFormatNewest, FormatMajorVersion(19))
+	require.Equal(t, FormatNewest, FormatMajorVersion(20))
+	require.Equal(t, internalFormatNewest, FormatMajorVersion(20))
 }
 
 func TestFormatMajorVersion_MigrationDefined(t *testing.T) {
@@ -60,6 +61,8 @@ func TestRatchetFormat(t *testing.T) {
 	require.Equal(t, FormatFlushableIngestExcises, d.FormatMajorVersion())
 	require.NoError(t, d.RatchetFormatMajorVersion(FormatColumnarBlocks))
 	require.Equal(t, FormatColumnarBlocks, d.FormatMajorVersion())
+	require.NoError(t, d.RatchetFormatMajorVersion(FormatWALSyncChunks))
+	require.Equal(t, FormatWALSyncChunks, d.FormatMajorVersion())
 
 	require.NoError(t, d.Close())
 
@@ -217,6 +220,7 @@ func TestFormatMajorVersions_TableFormat(t *testing.T) {
 		FormatSyntheticPrefixSuffix:      {sstable.TableFormatPebblev1, sstable.TableFormatPebblev4},
 		FormatFlushableIngestExcises:     {sstable.TableFormatPebblev1, sstable.TableFormatPebblev4},
 		FormatColumnarBlocks:             {sstable.TableFormatPebblev1, sstable.TableFormatPebblev5},
+		FormatWALSyncChunks:              {sstable.TableFormatPebblev1, sstable.TableFormatPebblev5},
 	}
 
 	// Valid versions.
diff --git a/ingest_test.go b/ingest_test.go
index c12e1797..a977a8c8 100644
--- a/ingest_test.go
+++ b/ingest_test.go
@@ -721,25 +721,21 @@ func TestExcise(t *testing.T) {
 				return err.Error()
 			}
 			return ""
-
 		case "flush":
 			if err := d.Flush(); err != nil {
 				return err.Error()
 			}
 			return ""
-
 		case "block-flush":
 			d.mu.Lock()
 			d.mu.compact.flushing = true
 			d.mu.Unlock()
 			return ""
-
 		case "allow-flush":
 			d.mu.Lock()
 			d.mu.compact.flushing = false
 			d.mu.Unlock()
 			return ""
-
 		case "ingest":
 			noWait := td.HasArg("no-wait")
 			if !noWait {
@@ -761,7 +757,6 @@ func TestExcise(t *testing.T) {
 				return "memtable flushed"
 			}
 			return ""
-
 		case "ingest-and-excise":
 			var prevFlushableIngests uint64
 			noWait := td.HasArg("no-wait")
@@ -772,7 +767,7 @@ func TestExcise(t *testing.T) {
 				d.mu.Unlock()
 			}
 
-			if err := runIngestAndExciseCmd(td, d, mem); err != nil {
+			if err := runIngestAndExciseCmd(td, d); err != nil {
 				return err.Error()
 			}
 			if noWait {
@@ -792,7 +787,6 @@ func TestExcise(t *testing.T) {
 				return "memtable flushed"
 			}
 			return ""
-
 		case "file-only-snapshot":
 			if len(td.CmdArgs) != 1 {
 				panic("insufficient args for file-only-snapshot command")
@@ -811,10 +805,8 @@ func TestExcise(t *testing.T) {
 			s := d.NewEventuallyFileOnlySnapshot(keyRanges)
 			efos[name] = s
 			return "ok"
-
 		case "get":
 			return runGetCmd(t, td, d)
-
 		case "iter":
 			opts := &IterOptions{
 				KeyTypes: IterKeyTypePointsAndRanges,
@@ -849,10 +841,8 @@ func TestExcise(t *testing.T) {
 			}
 			iter, _ := reader.NewIter(opts)
 			return runIterCmd(td, iter, true)
-
 		case "lsm":
 			return runLSMCmd(td, d)
-
 		case "metrics":
 			// The asynchronous loading of table stats can change metrics, so
 			// wait for all the tables' stats to be loaded.
@@ -861,11 +851,14 @@ func TestExcise(t *testing.T) {
 			d.mu.Unlock()
 
 			return d.Metrics().StringForTests()
-
 		case "wait-pending-table-stats":
 			return runTableStatsCmd(td, d)
-
 		case "excise":
+			if err := runExciseCmd(td, d); err != nil {
+				return err.Error()
+			}
+			return ""
+		case "excise-dryrun":
 			ve := &versionEdit{
 				DeletedTables: map[deletedFileEntry]*tableMetadata{},
 			}
@@ -894,7 +887,6 @@ func TestExcise(t *testing.T) {
 			d.mu.versions.logUnlock()
 			d.mu.Unlock()
 			return fmt.Sprintf("would excise %d files, use ingest-and-excise to excise.\n%s", len(ve.DeletedTables), ve.DebugString(base.DefaultFormatter))
-
 		case "confirm-backing":
 			// Confirms that the files have the same FileBacking.
 			fileNums := make(map[base.FileNum]struct{})
@@ -1097,7 +1089,7 @@ func testIngestSharedImpl(
 			return ""
 
 		case "ingest-and-excise":
-			if err := runIngestAndExciseCmd(td, d, d.opts.FS); err != nil {
+			if err := runIngestAndExciseCmd(td, d); err != nil {
 				return err.Error()
 			}
 			// Wait for a possible flush.
@@ -1595,7 +1587,7 @@ func TestConcurrentExcise(t *testing.T) {
 			d.mu.Lock()
 			prevFlushableIngests := d.mu.versions.metrics.Flush.AsIngestCount
 			d.mu.Unlock()
-			if err := runIngestAndExciseCmd(td, d, d.opts.FS); err != nil {
+			if err := runIngestAndExciseCmd(td, d); err != nil {
 				return err.Error()
 			}
 			// Wait for a possible flush.
diff --git a/internal/base/filenames_test.go b/internal/base/filenames_test.go
index 15ead6f6..e352237d 100644
--- a/internal/base/filenames_test.go
+++ b/internal/base/filenames_test.go
@@ -110,9 +110,12 @@ func TestMustExist(t *testing.T) {
 	filename := fs.PathJoin("..", "..", "testdata", "db-stage-4", "000000.sst")
 
 	MustExist(fs, filename, &buf, err)
-	require.Equal(t, `000000.sst:
-file does not exist
-directory contains 9 files, 3 unknown, 1 tables, 1 logs, 2 manifests`, buf.buf.String())
+
+	expected := fmt.Sprintf(`file does not exist
+(1) filename: %s; directory contains 9 files, 3 unknown, 1 tables, 1 logs, 2 manifests
+Wraps: (2) file does not exist
+Error types: (1) *hintdetail.withDetail (2) *errors.errorString`, filename)
+	require.Equal(t, expected, buf.buf.String())
 }
 
 func TestRedactFileNum(t *testing.T) {
diff --git a/internal/base/lazy_value_test.go b/internal/base/lazy_value_test.go
index de2c7412..9bad4c2e 100644
--- a/internal/base/lazy_value_test.go
+++ b/internal/base/lazy_value_test.go
@@ -14,12 +14,12 @@ import (
 )
 
 type valueFetcherFunc func(
-	handle []byte, valLen int32, buf []byte) (val []byte, callerOwned bool, err error)
+	handle []byte, blobFileNum DiskFileNum, valLen uint32, buf []byte) (val []byte, callerOwned bool, err error)
 
 func (v valueFetcherFunc) Fetch(
-	ctx context.Context, handle []byte, valLen int32, buf []byte,
+	ctx context.Context, handle []byte, blobFileNum DiskFileNum, valLen uint32, buf []byte,
 ) (val []byte, callerOwned bool, err error) {
-	return v(handle, valLen, buf)
+	return v(handle, blobFileNum, valLen, buf)
 }
 
 func TestLazyValue(t *testing.T) {
@@ -54,10 +54,10 @@ func TestLazyValue(t *testing.T) {
 			ValueOrHandle: []byte("foo-handle"),
 			Fetcher: &LazyFetcher{
 				Fetcher: valueFetcherFunc(
-					func(handle []byte, valLen int32, buf []byte) ([]byte, bool, error) {
+					func(handle []byte, blobFileNum DiskFileNum, valLen uint32, buf []byte) ([]byte, bool, error) {
 						numCalls++
 						require.Equal(t, []byte("foo-handle"), handle)
-						require.Equal(t, int32(3), valLen)
+						require.Equal(t, uint32(3), valLen)
 						return fooBytes1, callerOwned, nil
 					}),
 				Attribute: AttributeAndLen{ValueLen: 3, ShortAttribute: 7},
diff --git a/internal/manifest/testdata/version_edit_apply b/internal/manifest/testdata/version_edit_apply
index ab239549..f5b0bd2f 100644
--- a/internal/manifest/testdata/version_edit_apply
+++ b/internal/manifest/testdata/version_edit_apply
@@ -231,7 +231,7 @@ L2:
 # that is unknown.
 
 apply v8
-  add-table:     L0 000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935)]
+  add-table:     L0 000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935); depth:1]
 ----
 error during Accumulate: blob file 000003 referenced by L0.000004 not found
 
@@ -240,10 +240,10 @@ error during Accumulate: blob file 000003 referenced by L0.000004 not found
 
 apply v8 name=v9
   add-blob-file: 000003 size:[20535 (20KB)] vals:[25935 (25KB)]
-  add-table:     L0 000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935)]
+  add-table:     L0 000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935); depth:1]
 ----
 L0.0:
-  000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935)]
+  000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935); depth:1]
 L1:
   000001:[a#2,SET-e#2,SET] seqnums:[0-0] points:[a#2,SET-e#2,SET]
 L2:
@@ -254,7 +254,7 @@ L2:
 # same version edit, accumulate will error.
 
 apply v8
-  add-table:     L0 000005:[a#10,SET-a#10,SET] seqnums:[10-10] points:[a#10,SET-a#10,SET] blobrefs:[(000003: 205)]
+  add-table:     L0 000005:[a#10,SET-a#10,SET] seqnums:[10-10] points:[a#10,SET-a#10,SET] blobrefs:[(000003: 205); depth:1]
 ----
 error during Accumulate: blob file 000003 referenced by L0.000005 not found
 
@@ -264,12 +264,12 @@ error during Accumulate: blob file 000003 referenced by L0.000005 not found
 apply v9
   del-table:     L0 000004
   del-table:     L1 000001
-  add-table:     L1 000005:[a#9,SET-d#9,SET] seqnums:[0-9] points:[a#9,SET-d#9,SET] blobrefs:[(000003: 12205)]
-  add-table:     L1 000006:[e#2,SET-z#9,SET] seqnums:[0-9] points:[e#2,SET-z#9,SET] blobrefs:[(000003: 14192)]
+  add-table:     L1 000005:[a#9,SET-d#9,SET] seqnums:[0-9] points:[a#9,SET-d#9,SET] blobrefs:[(000003: 12205); depth:1]
+  add-table:     L1 000006:[e#2,SET-z#9,SET] seqnums:[0-9] points:[e#2,SET-z#9,SET] blobrefs:[(000003: 14192); depth:1]
 ----
 L1:
-  000005:[a#9,SET-d#9,SET] seqnums:[0-9] points:[a#9,SET-d#9,SET] blobrefs:[(000003: 12205)]
-  000006:[e#2,SET-z#9,SET] seqnums:[0-9] points:[e#2,SET-z#9,SET] blobrefs:[(000003: 14192)]
+  000005:[a#9,SET-d#9,SET] seqnums:[0-9] points:[a#9,SET-d#9,SET] blobrefs:[(000003: 12205); depth:1]
+  000006:[e#2,SET-z#9,SET] seqnums:[0-9] points:[e#2,SET-z#9,SET] blobrefs:[(000003: 14192); depth:1]
 L2:
   000002:[c#1,SET-f#1,SET] seqnums:[0-0] points:[c#1,SET-f#1,SET]
 
@@ -289,12 +289,12 @@ L2:
 
 apply v10
   add-blob-file: 000003 size:[20535 (20KB)] vals:[25935 (25KB)]
-  add-table:     L0 000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935)]
+  add-table:     L0 000004:[a#9,SET-z#9,DEL] seqnums:[9-9] points:[a#9,SET-z#9,DEL] blobrefs:[(000003: 25935); depth:1]
 new version edit
   del-table:     L0 000004
   del-table:     L1 000001
-  add-table:     L1 000005:[a#9,SET-d#9,SET] seqnums:[0-9] points:[a#9,SET-d#9,SET] blobrefs:[(000003: 12205)]
-  add-table:     L1 000006:[e#2,SET-z#9,SET] seqnums:[0-9] points:[e#2,SET-z#9,SET] blobrefs:[(000003: 14192)]
+  add-table:     L1 000005:[a#9,SET-d#9,SET] seqnums:[0-9] points:[a#9,SET-d#9,SET] blobrefs:[(000003: 12205); depth:1]
+  add-table:     L1 000006:[e#2,SET-z#9,SET] seqnums:[0-9] points:[e#2,SET-z#9,SET] blobrefs:[(000003: 14192); depth:1]
 new version edit
   del-table:     L1 000005
   del-table:     L1 000006
diff --git a/internal/manifest/testdata/version_edit_decode b/internal/manifest/testdata/version_edit_decode
index dff340d7..e83857a3 100644
--- a/internal/manifest/testdata/version_edit_decode
+++ b/internal/manifest/testdata/version_edit_decode
@@ -69,22 +69,22 @@ f9a006                       #   . ValueSize    = 102521
   del-blob-file: 000033
 
 encode
-  add-table:     L6 000029:[bar#14,DEL-foo#13,SET] blobrefs:[(000041: 20952)]
+  add-table:     L6 000029:[bar#14,DEL-foo#13,SET] blobrefs:[(000041: 20952); depth:1]
   add-blob-file: 000041 size:[20952 (20KB)] vals:[102521 (100KB)]
   del-blob-file: 000033
 ----
 67061d000b626172000e0000000000000b666f6f010d0000000000000000
-450129d8a301016b29d8a301f9a006006c21
-  add-table:     L6 000029:[bar#14,DEL-foo#13,SET] seqnums:[0-0] points:[bar#14,DEL-foo#13,SET] blobrefs:[(000041: 20952)]
+45010129d8a301016b29d8a301f9a006006c21
+  add-table:     L6 000029:[bar#14,DEL-foo#13,SET] seqnums:[0-0] points:[bar#14,DEL-foo#13,SET] blobrefs:[(000041: 20952); depth:1]
   add-blob-file: 000041 size:[20952 (20KB)] vals:[102521 (100KB)]
   del-blob-file: 000033
 
 decode
 67061d000b626172000e0000000000000b666f6f010d0000000000000000
-450129d8a301016b29d8a301f9a006006c21
+45010129d8a301016b29d8a301f9a006006c21
 ----
 67061d000b626172000e0000000000000b666f6f010d0000000000000000
-450129d8a301016b29d8a301f9a006006c21
+45010129d8a301016b29d8a301f9a006006c21
   add-table:     L6 000029:[bar#14,DEL-foo#13,SET]
   add-blob-file: 000041 size:[20952 (20KB)] vals:[102521 (100KB)]
   del-blob-file: 000033
diff --git a/internal/manifest/testutils.go b/internal/manifest/testutils.go
index d6d72164..44f99a70 100644
--- a/internal/manifest/testutils.go
+++ b/internal/manifest/testutils.go
@@ -30,7 +30,7 @@ type debugParser struct {
 	lastToken string
 }
 
-const debugParserSeparators = ":-[]()"
+const debugParserSeparators = ":-[]();"
 
 func makeDebugParser(s string) debugParser {
 	p := debugParser{
diff --git a/internal/manifest/version_edit_test.go b/internal/manifest/version_edit_test.go
index 66534482..81095030 100644
--- a/internal/manifest/version_edit_test.go
+++ b/internal/manifest/version_edit_test.go
@@ -507,7 +507,7 @@ func TestParseVersionEditDebugRoundTrip(t *testing.T) {
 		{
 			input: strings.Join([]string{
 				`  add-table:     L1 000001:[a#0,SET-z#0,DEL] seqnums:[0-0] points:[a#0,SET-z#0,DEL] size:1`,
-				`  add-table:     L2 000002:[a#0,SET-z#0,DEL] seqnums:[0-0] points:[a#0,SET-z#0,DEL] size:2 blobrefs:[(002431: 3008533), (002432: 10534)]`,
+				`  add-table:     L2 000002:[a#0,SET-z#0,DEL] seqnums:[0-0] points:[a#0,SET-z#0,DEL] size:2 blobrefs:[(002431: 3008533), (002432: 10534); depth:2]`,
 			}, "\n"),
 		},
 	}
diff --git a/internal/manifest/version_test.go b/internal/manifest/version_test.go
index 809cc188..42659d08 100644
--- a/internal/manifest/version_test.go
+++ b/internal/manifest/version_test.go
@@ -416,7 +416,7 @@ func TestTableMetadata_ParseRoundTrip(t *testing.T) {
 		},
 		{
 			name:  "blobrefs",
-			input: "000196:[bar#0,SET-foo#0,SET] seqnums:[0-0] points:[bar#0,SET-foo#0,SET] blobrefs:[(000191: 2952), (000075: 108520)]",
+			input: "000196:[bar#0,SET-foo#0,SET] seqnums:[0-0] points:[bar#0,SET-foo#0,SET] blobrefs:[(000191: 2952), (000075: 108520); depth:2]",
 		},
 	}
 	for _, tc := range testCases {
diff --git a/metrics_test.go b/metrics_test.go
index de7cc4ee..c3b77610 100644
--- a/metrics_test.go
+++ b/metrics_test.go
@@ -279,7 +279,7 @@ func TestMetrics(t *testing.T) {
 			return s
 
 		case "ingest-and-excise":
-			if err := runIngestAndExciseCmd(td, d, d.opts.FS); err != nil {
+			if err := runIngestAndExciseCmd(td, d); err != nil {
 				return err.Error()
 			}
 			return ""
diff --git a/open_test.go b/open_test.go
index 59acc669..8b9653ac 100644
--- a/open_test.go
+++ b/open_test.go
@@ -332,7 +332,7 @@ func TestNewDBFilenames(t *testing.T) {
 			"LOCK",
 			"MANIFEST-000001",
 			"OPTIONS-000003",
-			"marker.format-version.000006.019",
+			"marker.format-version.000007.020",
 			"marker.manifest.000001.MANIFEST-000001",
 		},
 	}
diff --git a/record/log_writer_test.go b/record/log_writer_test.go
index 5cf740b4..b376951c 100644
--- a/record/log_writer_test.go
+++ b/record/log_writer_test.go
@@ -6,10 +6,13 @@ package record
 
 import (
 	"bytes"
+	"encoding/binary"
 	"fmt"
+	"io"
 	"math"
 	"math/rand/v2"
 	"sort"
+	"strings"
 	"sync"
 	"sync/atomic"
 	"testing"
@@ -171,11 +174,13 @@ func TestSyncError(t *testing.T) {
 type syncFile struct {
 	writePos atomic.Int64
 	syncPos  atomic.Int64
+	buffer   bytes.Buffer
 }
 
 func (f *syncFile) Write(buf []byte) (int, error) {
 	n := len(buf)
 	f.writePos.Add(int64(n))
+	f.buffer.Write(buf)
 	return n, nil
 }
 
@@ -733,6 +738,95 @@ func TestSyncRecordGeneralizedWithCloseError(t *testing.T) {
 	require.Less(t, f.syncPos.Load(), f.writePos.Load())
 }
 
+func writeWALSyncRecords(t *testing.T, numRecords int, recordSizes []int) *syncFile {
+	f := &syncFile{}
+	w := NewLogWriter(f, 1, LogWriterConfig{WALFsyncLatency: prometheus.NewHistogram(prometheus.HistogramOpts{}), WriteWALSyncOffsets: true})
+	var syncErr error
+	for i := 0; i < numRecords; i++ {
+		var syncWG sync.WaitGroup
+		syncWG.Add(1)
+		data := []byte(strings.Repeat(fmt.Sprintf("%d", i%10), recordSizes[i]))
+		offset, err := w.SyncRecord(data, &syncWG, &syncErr)
+		require.NoError(t, err)
+		syncWG.Wait()
+		require.NoError(t, syncErr)
+		if v := f.writePos.Load(); offset != v {
+			t.Fatalf("expected write pos %d, but found %d", offset, v)
+		}
+		if v := f.syncPos.Load(); offset != v {
+			t.Fatalf("expected sync pos %d, but found %d", offset, v)
+		}
+	}
+	return f
+}
+
+func validateWALSyncRecords(t *testing.T, buf *bytes.Buffer) {
+	var largestOffset uint64 = 0
+	i := 0
+	bufBytes := (*buf).Bytes()
+	for i < len(bufBytes) {
+		if blockSize-(i%blockSize) < walSyncHeaderSize {
+			i += blockSize - (i % blockSize)
+			continue
+		}
+
+		checksum := binary.LittleEndian.Uint32(bufBytes[i+0 : i+4])
+		length := binary.LittleEndian.Uint16(bufBytes[i+4 : i+6])
+		chunkEncoding := bufBytes[i+6]
+		logNum := binary.LittleEndian.Uint32(bufBytes[i+7 : i+11])
+
+		// Reader and Writer have a logNum of 1, so a logNum of 2 is EOF.
+		if logNum == 2 {
+			// reached EOF trailer
+			if checksum != 0 && length != 0 {
+				t.Fatal("Mismatched logNum but not EOF trailer")
+			}
+			break
+		}
+		offset := binary.LittleEndian.Uint64(bufBytes[i+11 : i+19])
+		if offset < largestOffset {
+			t.Fatal("Expected monotonitcally increasing offsets.")
+		}
+		largestOffset = offset
+		headerFormat := headerFormatMappings[chunkEncoding]
+		headerSize := headerFormat.headerSize
+		i += headerSize + int(length)
+	}
+
+	r := NewReader(bytes.NewBuffer(bufBytes), 1)
+	for {
+		rr, err := r.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			t.Fatalf("Error Next(): %v", err)
+		}
+		_, err = io.ReadAll(rr)
+		if err != nil {
+			t.Fatalf("Error Next(): %v", err)
+		}
+	}
+}
+
+func TestWALSyncOffsetSimple(t *testing.T) {
+	recordSizes := []int{}
+	for i := 0; i < 1000; i++ {
+		recordSizes = append(recordSizes, blockSize-walSyncHeaderSize)
+	}
+	f := writeWALSyncRecords(t, len(recordSizes), recordSizes)
+	validateWALSyncRecords(t, &f.buffer)
+}
+
+func TestWALSyncOffsetRandom(t *testing.T) {
+	recordSizes := []int{}
+	for i := 0; i < 1000; i++ {
+		recordSizes = append(recordSizes, 1+rand.IntN(blockSize-walSyncHeaderSize))
+	}
+	f := writeWALSyncRecords(t, len(recordSizes), recordSizes)
+	validateWALSyncRecords(t, &f.buffer)
+}
+
 // BenchmarkQueueWALBlocks exercises queueing within the LogWriter. It can be
 // useful to measure allocations involved when flushing is slow enough to
 // accumulate a large backlog fo queued blocks.
diff --git a/record/record_test.go b/record/record_test.go
index e2342e57..87fe33e7 100644
--- a/record/record_test.go
+++ b/record/record_test.go
@@ -11,11 +11,17 @@ import (
 	"io"
 	"math"
 	"math/rand/v2"
+	"slices"
 	"strings"
 	"testing"
 	"time"
 
+	"github.com/cockroachdb/datadriven"
+	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble/internal/base"
+	"github.com/cockroachdb/pebble/internal/binfmt"
+	"github.com/cockroachdb/pebble/internal/crc"
+	"github.com/cockroachdb/pebble/internal/treeprinter"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/stretchr/testify/require"
 )
@@ -431,7 +437,7 @@ func TestCorruptBlock(t *testing.T) {
 	if err == nil {
 		t.Fatal("Expected a checksum mismatch error, got nil")
 	}
-	if err != ErrInvalidChunk {
+	if err != io.ErrUnexpectedEOF {
 		t.Fatalf("Unexpected error returned: %v", err)
 	}
 }
@@ -663,8 +669,8 @@ func TestInvalidLogNum(t *testing.T) {
 
 	{
 		r := NewReader(bytes.NewReader(buf.Bytes()), 2)
-		if _, err := r.Next(); err != ErrInvalidChunk {
-			t.Fatalf("expected %s, but found %s\n", ErrInvalidChunk, err)
+		if _, err := r.Next(); err != io.ErrUnexpectedEOF {
+			t.Fatalf("expected %s, but found %s\n", io.ErrUnexpectedEOF, err)
 		}
 	}
 }
@@ -747,7 +753,7 @@ func TestRecycleLog(t *testing.T) {
 			rr, err := r.Next()
 			if err != nil {
 				// If we limited output then an EOF, zeroed, or invalid chunk is expected.
-				if limitedBuf.limit < 0 && (err == io.EOF || err == ErrZeroedChunk || err == ErrInvalidChunk) {
+				if limitedBuf.limit < 0 && (err == io.EOF || err == io.ErrUnexpectedEOF || err == ErrZeroedChunk || err == ErrInvalidChunk) {
 					break
 				}
 				t.Fatalf("%d/%d: %v", i, j, err)
@@ -755,7 +761,7 @@ func TestRecycleLog(t *testing.T) {
 			x, err := io.ReadAll(rr)
 			if err != nil {
 				// If we limited output then an EOF, zeroed, or invalid chunk is expected.
-				if limitedBuf.limit < 0 && (err == io.EOF || err == ErrZeroedChunk || err == ErrInvalidChunk) {
+				if limitedBuf.limit < 0 && (err == io.EOF || err == io.ErrUnexpectedEOF || err == ErrZeroedChunk || err == ErrInvalidChunk) {
 					break
 				}
 				t.Fatalf("%d/%d: %v", i, j, err)
@@ -764,7 +770,7 @@ func TestRecycleLog(t *testing.T) {
 				t.Fatalf("%d/%d: expected record %d, but found %d", i, j, sizes[j], len(x))
 			}
 		}
-		if _, err := r.Next(); err != io.EOF && err != ErrZeroedChunk && err != ErrInvalidChunk {
+		if _, err := r.Next(); err != io.EOF && err != io.ErrUnexpectedEOF && err != ErrZeroedChunk && err != ErrInvalidChunk {
 			t.Fatalf("%d: expected EOF, but found %v", i, err)
 		}
 	}
@@ -875,7 +881,186 @@ func TestRecycleLogWithPartialRecord(t *testing.T) {
 	require.NoError(t, err)
 
 	_, err = io.ReadAll(rr)
-	require.Equal(t, err, ErrInvalidChunk)
+	require.Equal(t, err, io.ErrUnexpectedEOF)
+}
+
+func TestWALSync(t *testing.T) {
+	var buffer bytes.Buffer
+	result := make([]byte, 0)
+	corruptChunkNumbers := make([]int, 0)
+
+	datadriven.RunTest(t, "testdata/walSync", func(t *testing.T, d *datadriven.TestData) string {
+		switch d.Cmd {
+		case "init":
+			buffer.Reset()
+			result = result[:0]
+
+			for chunkNumber, line := range strings.Split(d.Input, "\n") {
+				switch {
+				case strings.HasPrefix(line, "writeChunk"):
+					var encodedLength, chunkLength uint16
+					var logNum uint32
+					var offset uint64
+					var encoding uint8
+					var corrupt bool
+
+					_, err := fmt.Sscanf(line, "writeChunk encodedLength=%d chunkLength=%d encoding=%d logNum=%d offset=%d corrupt=%t",
+						&encodedLength, &chunkLength, &encoding, &logNum, &offset, &corrupt)
+					if err != nil {
+						return fmt.Sprintf("error parsing length line: %v", err)
+					}
+
+					chunk := make([]byte, walSyncHeaderSize+chunkLength)
+
+					binary.LittleEndian.PutUint16(chunk[4:6], encodedLength)
+					chunk[6] = encoding
+					binary.LittleEndian.PutUint32(chunk[7:11], logNum)
+					binary.LittleEndian.PutUint64(chunk[11:19], offset)
+
+					checksum := uint32(crc.New(chunk[6:]).Value())
+					binary.LittleEndian.PutUint32(chunk[0:4], checksum)
+
+					if corrupt {
+						for i := 0; i < 4; i++ {
+							chunk[i] ^= 1
+						}
+						corruptChunkNumbers = append(corruptChunkNumbers, chunkNumber)
+					}
+
+					buffer.Write(chunk)
+
+				case strings.HasPrefix(line, "endblock"):
+					remainingBytes := buffer.Len() % blockSize
+					buffer.Write(make([]byte, remainingBytes))
+
+				case strings.HasPrefix(line, "EOF"):
+					var logNum uint32
+					_, err := fmt.Sscanf(line, "EOF logNum=%d", &logNum)
+					if err != nil {
+						return fmt.Sprintf("error parsing EOF line: %v", err)
+					}
+
+					eofChunk := make([]byte, 19)
+					eofChunk[6] = walSyncFullChunkEncoding
+					binary.LittleEndian.PutUint32(eofChunk[7:11], logNum)
+					buffer.Write(eofChunk)
+
+				case strings.HasPrefix(line, "raw"):
+					rawChunk, err := parseRawChunk(line)
+					if err != nil {
+						return fmt.Sprintf("error parsing raw line: %v", err)
+					}
+					buffer.Write(rawChunk)
+				}
+			}
+
+		case "read":
+			r := NewReader(bytes.NewBuffer(buffer.Bytes()), 1)
+
+			for {
+				reader, err := r.Next()
+				if err != nil {
+					return fmt.Sprintf("error reading next: %v\nfinal blockNum: %d\nbytes read: %d", err, r.blockNum, len(result))
+				}
+
+				data, err := io.ReadAll(reader)
+				if err != nil {
+					return fmt.Sprintf("error reading all: %v\nfinal blockNum: %d\nbytes read: %d", err, r.blockNum, len(result))
+				}
+				result = append(result, data...)
+			}
+
+		case "describe":
+			formatter := binfmt.New(buffer.Bytes()).LineWidth(20)
+			tree := treeprinter.New()
+			describeWALSyncBlocks(formatter, &tree, buffer.Bytes(), corruptChunkNumbers)
+			return tree.String()
+		}
+
+		return ""
+	})
+}
+
+func parseRawChunk(input string) ([]byte, error) {
+	words := strings.Fields(input)
+	var result []byte
+	for _, word := range words {
+		var value byte
+		if strings.HasPrefix(word, "0x") {
+			_, err := fmt.Sscanf(word, "0x%02X", &value)
+			if err != nil {
+				return nil, errors.Errorf("failed to parse hex: %s", word)
+			}
+			result = append(result, value)
+		} else if word == "#" {
+			break
+		}
+	}
+	return result, nil
+}
+
+// describeWALSyncBlocks is used to visualize blocks and chunks with the assumption
+// that they are generally well formed. Having one of the corruption conditions
+// may cause the visualization to be incorrect due to the assumption of well-formedness.
+func describeWALSyncBlocks(
+	f *binfmt.Formatter, tp *treeprinter.Node, data []byte, corruptChunks []int,
+) {
+	i := 0
+	n := tp.Child("Blocks")
+	globalChunkNumber := 0
+
+	for i < len(data) {
+		if i%blockSize == 0 {
+			blockNum := i / blockSize
+
+			blockNode := n.Childf("Block #%d", blockNum)
+
+			chunkNumber := 0
+			for i < len(data) {
+				var chunkNode treeprinter.Node
+				if slices.Contains(corruptChunks, globalChunkNumber) {
+					chunkNode = blockNode.Childf("Chunk #%d (offset %d, corrupt)", chunkNumber, i)
+				} else {
+					chunkNode = blockNode.Childf("Chunk #%d (offset %d)", chunkNumber, i)
+				}
+
+				checksum := binary.LittleEndian.Uint32(data[i+0 : i+4])
+				length := binary.LittleEndian.Uint16(data[i+4 : i+6])
+
+				if int(length) == 0 {
+					chunkNode.Child("EOF")
+					f.SetAnchorOffset()
+					f.ToTreePrinter(n)
+					return
+				}
+
+				chunkEncoding := data[i+6]
+				headerFormat := headerFormatMappings[chunkEncoding]
+				chunkType, wireFormat, headerSize := headerFormat.chunkPosition, headerFormat.wireFormat, headerFormat.headerSize
+
+				logNum := binary.LittleEndian.Uint32(data[i+7 : i+11])
+				offset := binary.LittleEndian.Uint64(data[i+11 : i+19])
+
+				chunkNode.Childf("Checksum: %d", checksum)
+				chunkNode.Childf("Encoded Length: %d", length)
+				chunkNode.Childf("Chunk encoding: %d (chunkType: %d, wireFormat: %d)", chunkEncoding, chunkType, wireFormat)
+				chunkNode.Childf("Log Num: %d", logNum)
+				chunkNode.Childf("Synced Offset: %d", offset)
+
+				i += headerSize + int(length)
+				chunkNumber++
+				globalChunkNumber++
+
+				if i%blockSize == 0 {
+					break
+				}
+
+			}
+		}
+	}
+
+	f.SetAnchorOffset()
+	f.ToTreePrinter(n)
 }
 
 func BenchmarkRecordWrite(b *testing.B) {
@@ -897,10 +1082,3 @@ func BenchmarkRecordWrite(b *testing.B) {
 		})
 	}
 }
-
-// TODO(edward) Suppresses linting warning.
-// Delete after readAheadForCorruption() is called in follow-up PRs.
-func TestReadAheadForCorruption(t *testing.T) {
-	r := NewReader(new(bytes.Buffer), 0)
-	r.readAheadForCorruption()
-}
diff --git a/record/testdata/walSync b/record/testdata/walSync
new file mode 100644
index 00000000..ab61c87c
--- /dev/null
+++ b/record/testdata/walSync
@@ -0,0 +1,1191 @@
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+describe
+----
+Blocks
+ ├── Block #0
+ │    ├── Chunk #0 (offset 0)
+ │    │    ├── Checksum: 3699662224
+ │    │    ├── Encoded Length: 1
+ │    │    ├── Chunk encoding: 9 (chunkType: 1, wireFormat: 3)
+ │    │    ├── Log Num: 1
+ │    │    └── Synced Offset: 0
+ │    └── Chunk #1 (offset 20)
+ │         └── EOF
+ └── 
+
+read
+----
+error reading next: EOF
+final blockNum: 0
+bytes read: 1
+
+
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+EOF logNum=2
+----
+
+describe
+----
+Blocks
+ ├── Block #0
+ │    ├── Chunk #0 (offset 0, corrupt)
+ │    │    ├── Checksum: 3716504721
+ │    │    ├── Encoded Length: 1
+ │    │    ├── Chunk encoding: 9 (chunkType: 1, wireFormat: 3)
+ │    │    ├── Log Num: 1
+ │    │    └── Synced Offset: 0
+ │    └── Chunk #1 (offset 20)
+ │         └── EOF
+ └── 
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 1
+bytes read: 0
+
+###########################
+##### CRC Error Cases #####
+###########################
+
+# Simple corruption in single block with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 1
+bytes read: 0
+
+# Simple corruption spanning the entire block with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=true
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 2
+bytes read: 0
+
+# Multiple corruption with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=true
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Multiple corruption but large offset is also corrupt, no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100000 corrupt=true
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Simple corruption with confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=30000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Corrupt the first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a confirmation chunk. Observe that final blockNum == 1
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Corrupt the first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a corrupt chunk, leading to EOF
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=true
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Complex multiple corruption with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=32000 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=32000 corrupt=true
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=64000 corrupt=true
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 0
+
+# Complex multiple corruption with confirmation in blockNum 3
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=true
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 3
+bytes read: 0
+
+# Complex multiple corruption with confirmation offset too small
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=true
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=true
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=100 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 32749
+
+###########################
+###### log num cases ######
+###########################
+# NOTE: reader logNum == 1
+###########################
+
+
+# Simple logNum issues in single block with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 1
+bytes read: 0
+
+# Simple logNum issues spanning the entire block with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 2
+bytes read: 0
+
+# Multiple logNum issues with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Multiple logNum issues but large offset is also has issues, no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=100 offset=100000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Simple logNum issues with confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=30000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a confirmation chunk. Observe that final blockNum == 1
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a logNum issue chunk, leading to EOF
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Complex multiple logNum issues with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=32000 corrupt=false
+
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=32000 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=32000 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=64000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 0
+
+
+# Complex multiple logNum issues with confirmation in blockNum 3
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=32000 corrupt=false
+
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 3
+bytes read: 0
+
+
+# Complex multiple logNum issue with confirmation offset too small
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=0 corrupt=false
+
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=100 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 32749
+
+#######################################
+###### invalid wire format cases ######
+#######################################
+# Encoding == 0 leads to a wireFormat error
+
+
+# Simple wire issues in single block with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 1
+bytes read: 0
+
+# Simple wire issues spanning the entire block with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 2
+bytes read: 0
+
+# Multiple wire issues with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=0 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=0 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Multiple wire issues but large offset is also has issues, no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=0 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=100000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Simple wire issues with confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=0 corrupt=false
+
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=30000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a confirmation chunk. Observe that final blockNum == 1
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a wire issue chunk, leading to EOF
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=32000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Complex multiple wire issues with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=32000 corrupt=false
+
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=32000 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=64000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 0
+
+
+# Complex multiple wire issues with confirmation in blockNum 3
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=32000 corrupt=false
+
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 3
+bytes read: 0
+
+
+# Complex multiple wire issue with confirmation offset too small
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=0 logNum=1 offset=0 corrupt=false
+
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=0 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=100 corrupt=false
+
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 32749
+
+###########################
+###### zeroed chunk  ######
+###########################
+# Zeroed chunk has checksum, encodedLength, and chunkEncoding == 0; minimum 7 zeroes
+
+# Simple zeroed issues in single block with no confirmation
+init
+writeChunk encodedLength=32730 chunkLength=32730 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 19 0x00's
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 2
+bytes read: 32730
+
+# Multiple zeroed issues with no confirmation
+init
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 19 0x00's
+writeChunk encodedLength=32730 chunkLength=32730 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=32730 chunkLength=32730 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 19 0x00's
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Multiple zeroed issues but large offset is also has issues, no confirmation
+init
+writeChunk encodedLength=32730 chunkLength=32730 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 19 0x00's
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 19 0x00's
+writeChunk encodedLength=32730 chunkLength=32730 encoding=9 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 32730
+
+# Simple zeroed issues with confirmation
+init
+writeChunk encodedLength=32730 chunkLength=32730 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 19 0x00's
+
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=100000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: zeroed chunk
+final blockNum: 1
+bytes read: 32730
+
+init
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 11 0x00's
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=100000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: zeroed chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a confirmation chunk. Observe that final blockNum == 1
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 11 0x00's
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=1000000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: zeroed chunk
+final blockNum: 1
+bytes read: 1
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a zeroed chunk, leading to EOF
+init
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 11 0x00's
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 11 0x00's
+writeChunk encodedLength=32738 chunkLength=32738 encoding=0 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Multiple zeroed chunk issues with confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=1 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=64000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: zeroed chunk
+final blockNum: 2
+bytes read: 1
+
+# Complex multiple zeroed chunks with confirmation offset too small
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 11 0x00's
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=1 corrupt=false
+raw 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 11 0x00's
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 32750
+
+#####################################
+###### encodedLength too long  ######
+#####################################
+# if r.end > r.n {
+
+init
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Simple encodedLength issues in single block with no confirmation
+init
+writeChunk encodedLength=64000 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 1
+bytes read: 0
+
+# Simple encodedLength issues spanning the entire block with no confirmation
+init
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 2
+bytes read: 0
+
+# Multiple encodedLength issues with no confirmation
+init
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Multiple encodedLength issues but large offset is also has issues, no confirmation
+init
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=1000 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Simple encodedLength issues with confirmation
+init
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=30000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a confirmation chunk. Observe that final blockNum == 1
+init
+writeChunk encodedLength=200 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=1000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a encodedLength issue chunk, leading to EOF
+init
+writeChunk encodedLength=200 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Complex multiple encodedLength issues with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=0 offset=32000 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=0 offset=64000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 0
+
+# Complex multiple encodedLength issues with confirmation in blockNum 3
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=200 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=200 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 3
+bytes read: 1
+
+# Complex multiple encodedLength issues with confirmation offset too small
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=200 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=64000 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=200 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 6
+bytes read: 32750
+
+###########################
+#### Encoding too Large ###
+###########################
+# if int(chunkEncoding) >= len(headerFormatMappings) {
+
+# Simple encoding issues in single block with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 1
+bytes read: 0
+
+# Simple encoding issues spanning the entire block with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 2
+bytes read: 0
+
+# Multiple encoding issues with no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=0 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Multiple encoding issues but large offset is also has issues, no confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=100000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Simple encoding issues with confirmation
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=30000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a confirmation chunk. Observe that final blockNum == 1
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 0
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a encoding issue chunk, leading to EOF
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 0
+
+# Complex multiple encoding issues with no confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=64000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 0
+
+# Complex multiple encoding issues with confirmation in blockNum 3
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=32000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 3
+bytes read: 0
+
+# Complex multiple encoding issue with confirmation offset too small
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=100 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32709 chunkLength=32709 encoding=9 logNum=1 offset=100 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 32749
+
+###########################
+## header format too big ##
+###########################
+# if r.end+headerSize > r.n {
+
+# large header chunk
+init
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=60000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 32738
+
+# Simple large header issues in single block with no confirmation
+init
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 2
+bytes read: 32738
+
+# Multiple large header issues with no confirmation
+init
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 32738
+
+# Multiple large header issues but large offset is also has issues, no confirmation
+init
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=1000000 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 32738
+
+# Simple large header issues with confirmation
+init
+writeChunk encodedLength=32738 chunkLength=32738 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=100000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 32738
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a confirmation chunk. Observe that final blockNum == 1
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=1000000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 1
+bytes read: 1
+
+# Issues in first block with confirming chunks after. However, these next chunks
+# in the same block cannot be used to confirm because reading ahead jumps to the next
+# block which has a zeroed chunk, leading to EOF
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=60 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=32738 chunkLength=32738 encoding=0 logNum=1 offset=32000 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 3
+bytes read: 1
+
+# Multiple large header issues with confirmation
+init
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=1 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=64000 corrupt=false
+EOF logNum=2
+----
+
+read
+----
+error reading next: pebble/record: invalid chunk
+final blockNum: 2
+bytes read: 1
+
+# Complex multiple large header chunks with confirmation offset too small
+init
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=0 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=0 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=60 corrupt=false
+writeChunk encodedLength=1 chunkLength=1 encoding=9 logNum=1 offset=1 corrupt=false
+raw 0x01 0x01 0x01 0x01 0x01 0x01 0x09 0x00 0x00 0x00 0x00 # length 11, 0x09 encodes a wal sync type, which is 19 bytes header
+writeChunk encodedLength=32718 chunkLength=32718 encoding=9 logNum=1 offset=32000 corrupt=false
+writeChunk encodedLength=32749 chunkLength=32749 encoding=9 logNum=1 offset=100 corrupt=false
+
+EOF logNum=2
+----
+
+read
+----
+error reading next: unexpected EOF
+final blockNum: 5
+bytes read: 32750
\ No newline at end of file
diff --git a/sstable/blob/blob_test.go b/sstable/blob/blob_test.go
index 3003ae47..0d882206 100644
--- a/sstable/blob/blob_test.go
+++ b/sstable/blob/blob_test.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"testing"
 
 	"github.com/cockroachdb/crlib/crstrings"
@@ -25,23 +26,9 @@ func TestBlobWriter(t *testing.T) {
 		buf.Reset()
 		switch td.Cmd {
 		case "build":
-			var (
-				targetBlockSize    int = 128
-				blockSizeThreshold int = 90
-				compression            = block.NoCompression
-			)
-			td.MaybeScanArgs(t, "target-block-size", &targetBlockSize)
-			td.MaybeScanArgs(t, "block-size-threshold", &blockSizeThreshold)
-			if cmdArg, ok := td.Arg("compression"); ok {
-				compression = block.CompressionFromString(cmdArg.SingleVal(t))
-			}
-
+			opts := scanFileWriterOptions(t, td)
 			obj = &objstorage.MemObj{}
-			w := NewFileWriter(000001, obj, FileWriterOptions{
-				Compression:   compression,
-				ChecksumType:  block.ChecksumTypeCRC32c,
-				FlushGovernor: block.MakeFlushGovernor(targetBlockSize, blockSizeThreshold, 0, nil),
-			})
+			w := NewFileWriter(000001, obj, opts)
 			for _, l := range crstrings.Lines(td.Input) {
 				h := w.AddValue([]byte(l))
 				fmt.Fprintln(&buf, h)
@@ -50,12 +37,7 @@ func TestBlobWriter(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
-			fmt.Fprintf(&buf, "Stats:\n")
-			fmt.Fprintf(&buf, "  BlockCount: %d\n", stats.BlockCount)
-			fmt.Fprintf(&buf, "  ValueCount: %d\n", stats.ValueCount)
-			fmt.Fprintf(&buf, "  BlockLenLongest: %d\n", stats.BlockLenLongest)
-			fmt.Fprintf(&buf, "  UncompressedValueBytes: %d\n", stats.UncompressedValueBytes)
-			fmt.Fprintf(&buf, "  FileLen: %d\n", stats.FileLen)
+			printFileWriterStats(&buf, stats)
 			return buf.String()
 		case "open":
 			r, err := NewFileReader(context.Background(), obj, FileReaderOptions{})
@@ -72,3 +54,30 @@ func TestBlobWriter(t *testing.T) {
 		}
 	})
 }
+
+func scanFileWriterOptions(t *testing.T, td *datadriven.TestData) FileWriterOptions {
+	var (
+		targetBlockSize    int = 128
+		blockSizeThreshold int = 90
+		compression            = block.NoCompression
+	)
+	td.MaybeScanArgs(t, "target-block-size", &targetBlockSize)
+	td.MaybeScanArgs(t, "block-size-threshold", &blockSizeThreshold)
+	if cmdArg, ok := td.Arg("compression"); ok {
+		compression = block.CompressionFromString(cmdArg.SingleVal(t))
+	}
+	return FileWriterOptions{
+		Compression:   compression,
+		ChecksumType:  block.ChecksumTypeCRC32c,
+		FlushGovernor: block.MakeFlushGovernor(targetBlockSize, blockSizeThreshold, 0, nil),
+	}
+}
+
+func printFileWriterStats(w io.Writer, stats FileWriterStats) {
+	fmt.Fprintf(w, "Stats:\n")
+	fmt.Fprintf(w, "  BlockCount: %d\n", stats.BlockCount)
+	fmt.Fprintf(w, "  ValueCount: %d\n", stats.ValueCount)
+	fmt.Fprintf(w, "  BlockLenLongest: %d\n", stats.BlockLenLongest)
+	fmt.Fprintf(w, "  UncompressedValueBytes: %d\n", stats.UncompressedValueBytes)
+	fmt.Fprintf(w, "  FileLen: %d\n", stats.FileLen)
+}
diff --git a/sstable/blob/fetcher_test.go b/sstable/blob/fetcher_test.go
new file mode 100644
index 00000000..e573e7a5
--- /dev/null
+++ b/sstable/blob/fetcher_test.go
@@ -0,0 +1,145 @@
+// Copyright 2025 The LevelDB-Go and Pebble Authors. All rights reserved. Use
+// of this source code is governed by a BSD-style license that can be found in
+// the LICENSE file.
+
+package blob
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/cockroachdb/crlib/crstrings"
+	"github.com/cockroachdb/datadriven"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/pebble/internal/base"
+	"github.com/cockroachdb/pebble/internal/sstableinternal"
+	"github.com/cockroachdb/pebble/objstorage"
+	"github.com/cockroachdb/pebble/sstable/block"
+	"github.com/cockroachdb/pebble/sstable/valblk"
+	"github.com/stretchr/testify/require"
+)
+
+type mockReaderProvider struct {
+	w       *bytes.Buffer
+	readers map[base.DiskFileNum]*FileReader
+}
+
+func (rp *mockReaderProvider) GetValueReader(
+	ctx context.Context, fileNum base.DiskFileNum,
+) (r ValueReader, closeFunc func(), err error) {
+	fmt.Fprintf(rp.w, "# GetValueReader(%s)\n", fileNum)
+
+	vr, ok := rp.readers[fileNum]
+	if !ok {
+		return nil, nil, errors.Newf("no reader for file %s", fileNum)
+	}
+	return vr, func() {}, nil
+}
+
+func TestValueFetcher(t *testing.T) {
+	ctx := context.Background()
+	var buf bytes.Buffer
+	rp := &mockReaderProvider{
+		w:       &buf,
+		readers: map[base.DiskFileNum]*FileReader{},
+	}
+	fetchers := map[string]*ValueFetcher{}
+	defer func() {
+		for _, f := range fetchers {
+			require.NoError(t, f.Close())
+		}
+		for _, r := range rp.readers {
+			require.NoError(t, r.Close())
+		}
+	}()
+
+	var handleBuf [valblk.HandleMaxLen]byte
+	datadriven.RunTest(t, "testdata/value_fetcher", func(t *testing.T, td *datadriven.TestData) string {
+		buf.Reset()
+		switch td.Cmd {
+		case "define":
+			var fileNum uint64
+			td.ScanArgs(t, "filenum", &fileNum)
+			opts := scanFileWriterOptions(t, td)
+			obj := &objstorage.MemObj{}
+			w := NewFileWriter(base.DiskFileNum(fileNum), obj, opts)
+			for _, l := range crstrings.Lines(td.Input) {
+				h := w.AddValue([]byte(l))
+				fmt.Fprintln(&buf, h)
+			}
+			stats, err := w.Close()
+			if err != nil {
+				t.Fatal(err)
+			}
+			printFileWriterStats(&buf, stats)
+
+			r, err := NewFileReader(ctx, obj, FileReaderOptions{
+				ReaderOptions: block.ReaderOptions{
+					CacheOpts: sstableinternal.CacheOptions{
+						FileNum: base.DiskFileNum(fileNum),
+					},
+				},
+			})
+			if err != nil {
+				t.Fatal(err)
+			}
+			rp.readers[base.DiskFileNum(fileNum)] = r
+			return buf.String()
+		case "new-fetcher":
+			var name string
+			td.ScanArgs(t, "name", &name)
+			fetchers[name] = &ValueFetcher{}
+			fetchers[name].Init(rp, block.ReadEnv{})
+			return ""
+		case "fetch":
+			var (
+				name                            string
+				blobFileNum                     uint64
+				valLen, blockNum, offsetInBlock uint32
+			)
+			td.ScanArgs(t, "name", &name)
+			td.ScanArgs(t, "filenum", &blobFileNum)
+			td.ScanArgs(t, "valLen", &valLen)
+			td.ScanArgs(t, "blknum", &blockNum)
+			td.ScanArgs(t, "off", &offsetInBlock)
+			fetcher := fetchers[name]
+			if fetcher == nil {
+				t.Fatalf("fetcher %s not found", name)
+			}
+			handle := encodeRemainingHandle(handleBuf[:], blockNum, offsetInBlock)
+
+			val, _, err := fetcher.Fetch(ctx, handle, base.DiskFileNum(blobFileNum), valLen, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			writeValueFetcherState(&buf, fetcher)
+			fmt.Fprintf(&buf, "%s\n", val)
+			return buf.String()
+		default:
+			panic(fmt.Sprintf("unknown command: %s", td.Cmd))
+		}
+	})
+}
+
+func encodeRemainingHandle(dst []byte, blockNum uint32, offsetInBlock uint32) []byte {
+	n := valblk.EncodeHandle(dst, valblk.Handle{
+		ValueLen:      0,
+		BlockNum:      blockNum,
+		OffsetInBlock: offsetInBlock,
+	})
+	return dst[1:n]
+}
+
+func writeValueFetcherState(w *bytes.Buffer, f *ValueFetcher) {
+	fmt.Fprintf(w, "ValueFetcher{\n")
+	for _, cr := range f.readers {
+		if cr.r == nil {
+			fmt.Fprintln(w, "  empty")
+			continue
+		}
+		fmt.Fprintf(w, "  %s (blk%d)\n", cr.fileNum, cr.currentBlockNum)
+	}
+	fmt.Fprintf(w, "}\n")
+}
diff --git a/sstable/blob/testdata/value_fetcher b/sstable/blob/testdata/value_fetcher
new file mode 100644
index 00000000..7438ae51
--- /dev/null
+++ b/sstable/blob/testdata/value_fetcher
@@ -0,0 +1,325 @@
+define filenum=000001 target-block-size=64 block-size-threshold=90
+canteloupe
+apple
+orange
+blueberry
+kiwi
+tangerine
+pear
+pomegranate
+guava
+watermelon
+fig
+plum
+raspberry
+strawberry
+durian
+honeydew
+starfruit
+mango
+grape
+papaya
+lychee
+persimmon
+mandarin
+peach
+apricot
+nectarine
+----
+(000001,blk0[0:10])
+(000001,blk0[10:15])
+(000001,blk0[15:21])
+(000001,blk0[21:30])
+(000001,blk0[30:34])
+(000001,blk0[34:43])
+(000001,blk0[43:47])
+(000001,blk0[47:58])
+(000001,blk0[58:63])
+(000001,blk1[0:10])
+(000001,blk1[10:13])
+(000001,blk1[13:17])
+(000001,blk1[17:26])
+(000001,blk1[26:36])
+(000001,blk1[36:42])
+(000001,blk1[42:50])
+(000001,blk1[50:59])
+(000001,blk1[59:64])
+(000001,blk2[0:5])
+(000001,blk2[5:11])
+(000001,blk2[11:17])
+(000001,blk2[17:26])
+(000001,blk2[26:34])
+(000001,blk2[34:39])
+(000001,blk2[39:46])
+(000001,blk2[46:55])
+Stats:
+  BlockCount: 3
+  ValueCount: 26
+  BlockLenLongest: 64
+  UncompressedValueBytes: 182
+  FileLen: 240
+
+define filenum=000002 target-block-size=64 block-size-threshold=90
+kale
+spinach
+watercress
+argula
+lettuce
+cabbage
+chard
+endive
+bok choy
+sorrel
+mustard greens
+collard greens
+microgreens
+----
+(000002,blk0[0:4])
+(000002,blk0[4:11])
+(000002,blk0[11:21])
+(000002,blk0[21:27])
+(000002,blk0[27:34])
+(000002,blk0[34:41])
+(000002,blk0[41:46])
+(000002,blk0[46:52])
+(000002,blk0[52:60])
+(000002,blk1[0:6])
+(000002,blk1[6:20])
+(000002,blk1[20:34])
+(000002,blk1[34:45])
+Stats:
+  BlockCount: 2
+  ValueCount: 13
+  BlockLenLongest: 60
+  UncompressedValueBytes: 105
+  FileLen: 155
+
+define filenum=000003 target-block-size=64 block-size-threshold=90
+beet
+carrot
+turnip
+rutabaga
+parsnip
+celery
+sweet potato
+potato
+----
+(000003,blk0[0:4])
+(000003,blk0[4:10])
+(000003,blk0[10:16])
+(000003,blk0[16:24])
+(000003,blk0[24:31])
+(000003,blk0[31:37])
+(000003,blk0[37:49])
+(000003,blk0[49:55])
+Stats:
+  BlockCount: 1
+  ValueCount: 8
+  BlockLenLongest: 55
+  UncompressedValueBytes: 55
+  FileLen: 97
+
+define filenum=000004 target-block-size=64 block-size-threshold=90
+onion
+garlic
+leek
+scallion
+shallots
+ramps
+chives
+----
+(000004,blk0[0:5])
+(000004,blk0[5:11])
+(000004,blk0[11:15])
+(000004,blk0[15:23])
+(000004,blk0[23:31])
+(000004,blk0[31:36])
+(000004,blk0[36:42])
+Stats:
+  BlockCount: 1
+  ValueCount: 7
+  BlockLenLongest: 42
+  UncompressedValueBytes: 42
+  FileLen: 84
+
+define filenum=000005 target-block-size=64 block-size-threshold=90
+shitake
+oyster
+enoki
+portobello
+porcini
+morel
+chanterelle
+cremini
+maitake
+----
+(000005,blk0[0:7])
+(000005,blk0[7:13])
+(000005,blk0[13:18])
+(000005,blk0[18:28])
+(000005,blk0[28:35])
+(000005,blk0[35:40])
+(000005,blk0[40:51])
+(000005,blk0[51:58])
+(000005,blk1[0:7])
+Stats:
+  BlockCount: 2
+  ValueCount: 9
+  BlockLenLongest: 58
+  UncompressedValueBytes: 65
+  FileLen: 115
+
+define filenum=000006 target-block-size=64 block-size-threshold=90
+squash
+pumpkin
+cucumber
+----
+(000006,blk0[0:6])
+(000006,blk0[6:13])
+(000006,blk0[13:21])
+Stats:
+  BlockCount: 1
+  ValueCount: 3
+  BlockLenLongest: 21
+  UncompressedValueBytes: 21
+  FileLen: 63
+
+new-fetcher name=iter1
+----
+
+fetch name=iter1 filenum=000001 valLen=10 blknum=0 off=0
+----
+# GetValueReader(000001)
+ValueFetcher{
+  000001 (blk0)
+  empty
+  empty
+  empty
+  empty
+}
+canteloupe
+
+fetch name=iter1 filenum=000001 valLen=5 blknum=0 off=10
+----
+ValueFetcher{
+  000001 (blk0)
+  empty
+  empty
+  empty
+  empty
+}
+apple
+
+new-fetcher name=iter2
+----
+
+fetch name=iter2 filenum=000001 blknum=2 valLen=5 off=34
+----
+# GetValueReader(000001)
+ValueFetcher{
+  000001 (blk2)
+  empty
+  empty
+  empty
+  empty
+}
+peach
+
+fetch name=iter1 filenum=000001 valLen=4 blknum=1 off=13
+----
+ValueFetcher{
+  000001 (blk1)
+  empty
+  empty
+  empty
+  empty
+}
+plum
+
+fetch name=iter2 filenum=000006 blknum=0 valLen=7 off=6
+----
+# GetValueReader(000006)
+ValueFetcher{
+  000001 (blk2)
+  000006 (blk0)
+  empty
+  empty
+  empty
+}
+pumpkin
+
+fetch name=iter2 filenum=000005 blknum=1 valLen=7 off=0
+----
+# GetValueReader(000005)
+ValueFetcher{
+  000001 (blk2)
+  000006 (blk0)
+  000005 (blk1)
+  empty
+  empty
+}
+maitake
+
+fetch name=iter2 filenum=000003 blknum=0 valLen=12 off=37
+----
+# GetValueReader(000003)
+ValueFetcher{
+  000001 (blk2)
+  000006 (blk0)
+  000005 (blk1)
+  000003 (blk0)
+  empty
+}
+sweet potato
+
+fetch name=iter2 filenum=000002 blknum=1 valLen=14 off=6
+----
+# GetValueReader(000002)
+ValueFetcher{
+  000001 (blk2)
+  000006 (blk0)
+  000005 (blk1)
+  000003 (blk0)
+  000002 (blk1)
+}
+mustard greens
+
+fetch name=iter2 filenum=000004 blknum=0 valLen=5 off=31
+----
+# GetValueReader(000004)
+ValueFetcher{
+  000004 (blk0)
+  000006 (blk0)
+  000005 (blk1)
+  000003 (blk0)
+  000002 (blk1)
+}
+ramps
+
+fetch name=iter2 filenum=000004 blknum=0 valLen=6 off=36
+----
+ValueFetcher{
+  000004 (blk0)
+  000006 (blk0)
+  000005 (blk1)
+  000003 (blk0)
+  000002 (blk1)
+}
+chives
+
+# Although iter2 has previously fetched from file 000001, we've fetched from 5
+# distinct other files since then, so 0000001 will have been evicted. The
+# following fetch from filenum 000001 should log a GetValueReader(000001) as we
+# need to reobtain a blob file reader.
+
+fetch name=iter2 filenum=000001 blknum=2 valLen=9 off=17
+----
+# GetValueReader(000001)
+ValueFetcher{
+  000004 (blk0)
+  000001 (blk2)
+  000005 (blk1)
+  000003 (blk0)
+  000002 (blk1)
+}
+persimmon
diff --git a/sstable/blob/testdata/writer b/sstable/blob/testdata/writer
index 19f8483e..fb1cc378 100644
--- a/sstable/blob/testdata/writer
+++ b/sstable/blob/testdata/writer
@@ -55,7 +55,7 @@ nectarine
 Stats:
   BlockCount: 3
   ValueCount: 26
-  BlockLenLongest: 69
+  BlockLenLongest: 64
   UncompressedValueBytes: 182
   FileLen: 240
 
@@ -63,4 +63,4 @@ open
 ----
 TableFormat: blobV1
 ChecksumType: crc32c
-IndexHandle: {Handle: {197,14}, DataLens:(1,1,1)}
+IndexHandle: {Handle: {197,9}, DataLens:(1,1,1)}
diff --git a/sstable/block/kv_test.go b/sstable/block/kv_test.go
index a1693369..9ed9f9b5 100644
--- a/sstable/block/kv_test.go
+++ b/sstable/block/kv_test.go
@@ -14,40 +14,58 @@ import (
 
 func TestValuePrefix(t *testing.T) {
 	testCases := []struct {
-		isHandle         bool
-		setHasSamePrefix bool
-		attr             base.ShortAttribute
+		isValueBlockHandle bool
+		isBlobHandle       bool
+		setHasSamePrefix   bool
+		attr               base.ShortAttribute
 	}{
 		{
-			isHandle:         false,
-			setHasSamePrefix: false,
+			isValueBlockHandle: false,
+			isBlobHandle:       false,
+			setHasSamePrefix:   false,
 		},
 		{
-			isHandle:         false,
-			setHasSamePrefix: true,
+			isValueBlockHandle: false,
+			isBlobHandle:       false,
+			setHasSamePrefix:   true,
 		},
 		{
-			isHandle:         true,
-			setHasSamePrefix: false,
-			attr:             5,
+			isValueBlockHandle: true,
+			isBlobHandle:       false,
+			setHasSamePrefix:   false,
+			attr:               5,
 		},
 		{
-			isHandle:         true,
-			setHasSamePrefix: true,
-			attr:             2,
+			isValueBlockHandle: true,
+			isBlobHandle:       false,
+			setHasSamePrefix:   true,
+			attr:               2,
+		},
+		{
+			isValueBlockHandle: false,
+			isBlobHandle:       true,
+			setHasSamePrefix:   false,
+		},
+		{
+			isValueBlockHandle: false,
+			isBlobHandle:       true,
+			setHasSamePrefix:   true,
 		},
 	}
 	for _, tc := range testCases {
 		t.Run(fmt.Sprintf("%+v", tc), func(t *testing.T) {
 			var prefix ValuePrefix
-			if tc.isHandle {
-				prefix = ValueHandlePrefix(tc.setHasSamePrefix, tc.attr)
+			if tc.isValueBlockHandle {
+				prefix = ValueBlockHandlePrefix(tc.setHasSamePrefix, tc.attr)
+			} else if tc.isBlobHandle {
+				prefix = BlobValueHandlePrefix(tc.setHasSamePrefix)
 			} else {
 				prefix = InPlaceValuePrefix(tc.setHasSamePrefix)
 			}
-			require.Equal(t, tc.isHandle, prefix.IsValueHandle())
+			require.Equal(t, tc.isValueBlockHandle, prefix.IsValueBlockHandle())
+			require.Equal(t, tc.isBlobHandle, prefix.IsBlobValueHandle())
 			require.Equal(t, tc.setHasSamePrefix, prefix.SetHasSamePrefix())
-			if tc.isHandle {
+			if tc.isValueBlockHandle {
 				require.Equal(t, tc.attr, prefix.ShortAttribute())
 			}
 		})
diff --git a/sstable/colblk/data_block_test.go b/sstable/colblk/data_block_test.go
index 2e707686..2a0ee3c2 100644
--- a/sstable/colblk/data_block_test.go
+++ b/sstable/colblk/data_block_test.go
@@ -69,7 +69,7 @@ func TestDataBlock(t *testing.T) {
 					valueString := line[j+1:]
 					vp := block.InPlaceValuePrefix(kcmp.PrefixEqual())
 					if strings.HasPrefix(valueString, "valueHandle") {
-						vp = block.ValueHandlePrefix(kcmp.PrefixEqual(), 0)
+						vp = block.ValueBlockHandlePrefix(kcmp.PrefixEqual(), 0)
 					}
 					if kcmp.UserKeyComparison == 0 && prevKey.Kind() != base.InternalKeyKindMerge {
 						isObsolete = true
diff --git a/sstable/writer_test.go b/sstable/writer_test.go
index fc8e09de..338fbb30 100644
--- a/sstable/writer_test.go
+++ b/sstable/writer_test.go
@@ -443,13 +443,15 @@ func TestWriterWithValueBlocks(t *testing.T) {
 				if kv.K.Kind() == InternalKeyKindSet {
 					prefix := block.ValuePrefix(lv.ValueOrHandle[0])
 					setWithSamePrefix := prefix.SetHasSamePrefix()
-					if prefix.IsValueHandle() {
+					if prefix.IsInPlaceValue() {
+						fmt.Fprintf(&buf, "%s:in-place %s, same-pre %t\n", kv.K, lv.ValueOrHandle[1:], setWithSamePrefix)
+					} else if prefix.IsValueBlockHandle() {
 						attribute := prefix.ShortAttribute()
 						vh := valblk.DecodeHandle(lv.ValueOrHandle[1:])
 						fmt.Fprintf(&buf, "%s:value-handle len %d block %d offset %d, att %d, same-pre %t\n",
 							kv.K, vh.ValueLen, vh.BlockNum, vh.OffsetInBlock, attribute, setWithSamePrefix)
 					} else {
-						fmt.Fprintf(&buf, "%s:in-place %s, same-pre %t\n", kv.K, lv.ValueOrHandle[1:], setWithSamePrefix)
+						panic(fmt.Sprintf("unknown value prefix: %d", lv.ValueOrHandle[0]))
 					}
 				} else {
 					fmt.Fprintf(&buf, "%s:%s\n", kv.K, lv.ValueOrHandle)
diff --git a/table_stats_test.go b/table_stats_test.go
index 8409a884..7a320b6e 100644
--- a/table_stats_test.go
+++ b/table_stats_test.go
@@ -142,7 +142,7 @@ func TestTableStats(t *testing.T) {
 			return ""
 
 		case "ingest-and-excise":
-			if err := runIngestAndExciseCmd(td, d, d.opts.FS); err != nil {
+			if err := runIngestAndExciseCmd(td, d); err != nil {
 				return err.Error()
 			}
 			// Wait for a possible flush.
diff --git a/testdata/checkpoint b/testdata/checkpoint
index 9140cec4..7a5a409f 100644
--- a/testdata/checkpoint
+++ b/testdata/checkpoint
@@ -43,6 +43,10 @@ create: db/marker.format-version.000006.019
 close: db/marker.format-version.000006.019
 remove: db/marker.format-version.000005.018
 sync: db
+create: db/marker.format-version.000007.020
+close: db/marker.format-version.000007.020
+remove: db/marker.format-version.000006.019
+sync: db
 create: db/temporary.000003.dbtmp
 sync: db/temporary.000003.dbtmp
 close: db/temporary.000003.dbtmp
@@ -109,9 +113,9 @@ sync-data: checkpoints/checkpoint1/OPTIONS-000003
 close: checkpoints/checkpoint1/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint1
-create: checkpoints/checkpoint1/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint1/marker.format-version.000001.019
-close: checkpoints/checkpoint1/marker.format-version.000001.019
+create: checkpoints/checkpoint1/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint1/marker.format-version.000001.020
+close: checkpoints/checkpoint1/marker.format-version.000001.020
 sync: checkpoints/checkpoint1
 close: checkpoints/checkpoint1
 link: db/000005.sst -> checkpoints/checkpoint1/000005.sst
@@ -153,9 +157,9 @@ sync-data: checkpoints/checkpoint2/OPTIONS-000003
 close: checkpoints/checkpoint2/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint2
-create: checkpoints/checkpoint2/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint2/marker.format-version.000001.019
-close: checkpoints/checkpoint2/marker.format-version.000001.019
+create: checkpoints/checkpoint2/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint2/marker.format-version.000001.020
+close: checkpoints/checkpoint2/marker.format-version.000001.020
 sync: checkpoints/checkpoint2
 close: checkpoints/checkpoint2
 link: db/000007.sst -> checkpoints/checkpoint2/000007.sst
@@ -192,9 +196,9 @@ sync-data: checkpoints/checkpoint3/OPTIONS-000003
 close: checkpoints/checkpoint3/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint3
-create: checkpoints/checkpoint3/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint3/marker.format-version.000001.019
-close: checkpoints/checkpoint3/marker.format-version.000001.019
+create: checkpoints/checkpoint3/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint3/marker.format-version.000001.020
+close: checkpoints/checkpoint3/marker.format-version.000001.020
 sync: checkpoints/checkpoint3
 close: checkpoints/checkpoint3
 link: db/000005.sst -> checkpoints/checkpoint3/000005.sst
@@ -278,7 +282,7 @@ list db
 LOCK
 MANIFEST-000001
 OPTIONS-000003
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 list checkpoints/checkpoint1
@@ -288,7 +292,7 @@ list checkpoints/checkpoint1
 000007.sst
 MANIFEST-000001
 OPTIONS-000003
-marker.format-version.000001.019
+marker.format-version.000001.020
 marker.manifest.000001.MANIFEST-000001
 
 open checkpoints/checkpoint1 readonly
@@ -355,7 +359,7 @@ list checkpoints/checkpoint2
 000007.sst
 MANIFEST-000001
 OPTIONS-000003
-marker.format-version.000001.019
+marker.format-version.000001.020
 marker.manifest.000001.MANIFEST-000001
 
 open checkpoints/checkpoint2 readonly
@@ -397,7 +401,7 @@ list checkpoints/checkpoint3
 000007.sst
 MANIFEST-000001
 OPTIONS-000003
-marker.format-version.000001.019
+marker.format-version.000001.020
 marker.manifest.000001.MANIFEST-000001
 
 open checkpoints/checkpoint3 readonly
@@ -516,9 +520,9 @@ sync-data: checkpoints/checkpoint4/OPTIONS-000003
 close: checkpoints/checkpoint4/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint4
-create: checkpoints/checkpoint4/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint4/marker.format-version.000001.019
-close: checkpoints/checkpoint4/marker.format-version.000001.019
+create: checkpoints/checkpoint4/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint4/marker.format-version.000001.020
+close: checkpoints/checkpoint4/marker.format-version.000001.020
 sync: checkpoints/checkpoint4
 close: checkpoints/checkpoint4
 link: db/000010.sst -> checkpoints/checkpoint4/000010.sst
@@ -606,7 +610,7 @@ list db
 LOCK
 MANIFEST-000001
 OPTIONS-000003
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 
@@ -625,9 +629,9 @@ sync-data: checkpoints/checkpoint5/OPTIONS-000003
 close: checkpoints/checkpoint5/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint5
-create: checkpoints/checkpoint5/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint5/marker.format-version.000001.019
-close: checkpoints/checkpoint5/marker.format-version.000001.019
+create: checkpoints/checkpoint5/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint5/marker.format-version.000001.020
+close: checkpoints/checkpoint5/marker.format-version.000001.020
 sync: checkpoints/checkpoint5
 close: checkpoints/checkpoint5
 link: db/000010.sst -> checkpoints/checkpoint5/000010.sst
@@ -727,9 +731,9 @@ sync-data: checkpoints/checkpoint6/OPTIONS-000003
 close: checkpoints/checkpoint6/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint6
-create: checkpoints/checkpoint6/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint6/marker.format-version.000001.019
-close: checkpoints/checkpoint6/marker.format-version.000001.019
+create: checkpoints/checkpoint6/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint6/marker.format-version.000001.020
+close: checkpoints/checkpoint6/marker.format-version.000001.020
 sync: checkpoints/checkpoint6
 close: checkpoints/checkpoint6
 link: db/000011.sst -> checkpoints/checkpoint6/000011.sst
diff --git a/testdata/checkpoint_shared b/testdata/checkpoint_shared
index 98361379..a5bdb82c 100644
--- a/testdata/checkpoint_shared
+++ b/testdata/checkpoint_shared
@@ -31,6 +31,10 @@ create: db/marker.format-version.000003.019
 close: db/marker.format-version.000003.019
 remove: db/marker.format-version.000002.018
 sync: db
+create: db/marker.format-version.000004.020
+close: db/marker.format-version.000004.020
+remove: db/marker.format-version.000003.019
+sync: db
 create: db/temporary.000003.dbtmp
 sync: db/temporary.000003.dbtmp
 close: db/temporary.000003.dbtmp
@@ -97,9 +101,9 @@ sync-data: checkpoints/checkpoint1/OPTIONS-000003
 close: checkpoints/checkpoint1/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint1
-create: checkpoints/checkpoint1/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint1/marker.format-version.000001.019
-close: checkpoints/checkpoint1/marker.format-version.000001.019
+create: checkpoints/checkpoint1/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint1/marker.format-version.000001.020
+close: checkpoints/checkpoint1/marker.format-version.000001.020
 sync: checkpoints/checkpoint1
 close: checkpoints/checkpoint1
 open: db/MANIFEST-000001 (options: *vfs.sequentialReadsOption)
@@ -150,9 +154,9 @@ sync-data: checkpoints/checkpoint2/OPTIONS-000003
 close: checkpoints/checkpoint2/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint2
-create: checkpoints/checkpoint2/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint2/marker.format-version.000001.019
-close: checkpoints/checkpoint2/marker.format-version.000001.019
+create: checkpoints/checkpoint2/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint2/marker.format-version.000001.020
+close: checkpoints/checkpoint2/marker.format-version.000001.020
 sync: checkpoints/checkpoint2
 close: checkpoints/checkpoint2
 open: db/MANIFEST-000001 (options: *vfs.sequentialReadsOption)
@@ -199,9 +203,9 @@ sync-data: checkpoints/checkpoint3/OPTIONS-000003
 close: checkpoints/checkpoint3/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoints/checkpoint3
-create: checkpoints/checkpoint3/marker.format-version.000001.019
-sync-data: checkpoints/checkpoint3/marker.format-version.000001.019
-close: checkpoints/checkpoint3/marker.format-version.000001.019
+create: checkpoints/checkpoint3/marker.format-version.000001.020
+sync-data: checkpoints/checkpoint3/marker.format-version.000001.020
+close: checkpoints/checkpoint3/marker.format-version.000001.020
 sync: checkpoints/checkpoint3
 close: checkpoints/checkpoint3
 open: db/MANIFEST-000001 (options: *vfs.sequentialReadsOption)
@@ -258,7 +262,7 @@ LOCK
 MANIFEST-000001
 OPTIONS-000003
 REMOTE-OBJ-CATALOG-000001
-marker.format-version.000003.019
+marker.format-version.000004.020
 marker.manifest.000001.MANIFEST-000001
 marker.remote-obj-catalog.000001.REMOTE-OBJ-CATALOG-000001
 
@@ -268,7 +272,7 @@ list checkpoints/checkpoint1
 MANIFEST-000001
 OPTIONS-000003
 REMOTE-OBJ-CATALOG-000001
-marker.format-version.000001.019
+marker.format-version.000001.020
 marker.manifest.000001.MANIFEST-000001
 marker.remote-obj-catalog.000001.REMOTE-OBJ-CATALOG-000001
 
@@ -320,7 +324,7 @@ list checkpoints/checkpoint2
 MANIFEST-000001
 OPTIONS-000003
 REMOTE-OBJ-CATALOG-000001
-marker.format-version.000001.019
+marker.format-version.000001.020
 marker.manifest.000001.MANIFEST-000001
 marker.remote-obj-catalog.000001.REMOTE-OBJ-CATALOG-000001
 
diff --git a/testdata/determinism b/testdata/determinism
index e1026ba0..e998d00f 100644
--- a/testdata/determinism
+++ b/testdata/determinism
@@ -134,3 +134,12 @@ run count=100
 sequential( 0:define 1:memtable-info 2:batch 3:batch 4:memtable-info parallel( 5:batch 6:batch ) )
 ----
 ok
+
+excise a z
+----
+7:excise
+
+run count=100
+sequential( 0:define 1:memtable-info 2:batch 3:batch 4:memtable-info parallel( 5:batch 6:batch ) 7:excise )
+----
+ok
diff --git a/testdata/event_listener b/testdata/event_listener
index 6e99c722..928e07fc 100644
--- a/testdata/event_listener
+++ b/testdata/event_listener
@@ -56,6 +56,11 @@ close: db/marker.format-version.000006.019
 remove: db/marker.format-version.000005.018
 sync: db
 upgraded to format version: 019
+create: db/marker.format-version.000007.020
+close: db/marker.format-version.000007.020
+remove: db/marker.format-version.000006.019
+sync: db
+upgraded to format version: 020
 create: db/temporary.000003.dbtmp
 sync: db/temporary.000003.dbtmp
 close: db/temporary.000003.dbtmp
@@ -365,9 +370,9 @@ sync-data: checkpoint/OPTIONS-000003
 close: checkpoint/OPTIONS-000003
 close: db/OPTIONS-000003
 open-dir: checkpoint
-create: checkpoint/marker.format-version.000001.019
-sync-data: checkpoint/marker.format-version.000001.019
-close: checkpoint/marker.format-version.000001.019
+create: checkpoint/marker.format-version.000001.020
+sync-data: checkpoint/marker.format-version.000001.020
+close: checkpoint/marker.format-version.000001.020
 sync: checkpoint
 close: checkpoint
 link: db/000013.sst -> checkpoint/000013.sst
diff --git a/testdata/excise b/testdata/excise
index fa614a91..7a42c881 100644
--- a/testdata/excise
+++ b/testdata/excise
@@ -28,7 +28,7 @@ L0.0:
 L6:
   000004:[a#10,SET-l#10,SET]
 
-excise c k
+excise-dryrun c k
 ----
 would excise 2 files, use ingest-and-excise to excise.
   del-table:     L0 000006
@@ -38,7 +38,7 @@ would excise 2 files, use ingest-and-excise to excise.
   add-backing:   000004
 
 
-excise a e
+excise-dryrun a e
 ----
 would excise 2 files, use ingest-and-excise to excise.
   del-table:     L0 000006
@@ -48,7 +48,7 @@ would excise 2 files, use ingest-and-excise to excise.
   add-backing:   000006
   add-backing:   000004
 
-excise e z
+excise-dryrun e z
 ----
 would excise 2 files, use ingest-and-excise to excise.
   del-table:     L0 000006
@@ -58,7 +58,7 @@ would excise 2 files, use ingest-and-excise to excise.
   add-backing:   000006
   add-backing:   000004
 
-excise f l
+excise-dryrun f l
 ----
 would excise 2 files, use ingest-and-excise to excise.
   del-table:     L0 000006
@@ -69,7 +69,7 @@ would excise 2 files, use ingest-and-excise to excise.
   add-backing:   000006
   add-backing:   000004
 
-excise f ll
+excise-dryrun f ll
 ----
 would excise 2 files, use ingest-and-excise to excise.
   del-table:     L0 000006
@@ -79,7 +79,7 @@ would excise 2 files, use ingest-and-excise to excise.
   add-backing:   000006
   add-backing:   000004
 
-excise p q
+excise-dryrun p q
 ----
 would excise 0 files, use ingest-and-excise to excise.
 
@@ -159,7 +159,7 @@ L0.0:
 L6:
   000004:[c#10,RANGEKEYSET-f#inf,RANGEKEYSET]
 
-excise f g
+excise-dryrun f g
 ----
 would excise 1 files, use ingest-and-excise to excise.
   del-table:     L0 000005
@@ -167,21 +167,21 @@ would excise 1 files, use ingest-and-excise to excise.
   add-table:     L0 000007(000005):[g#11,RANGEDEL-i#inf,RANGEDEL] seqnums:[11-11] points:[g#11,RANGEDEL-i#inf,RANGEDEL] size:1
   add-backing:   000005
 
-excise b c
+excise-dryrun b c
 ----
 would excise 1 files, use ingest-and-excise to excise.
   del-table:     L0 000005
   add-table:     L0 000008(000005):[g#11,RANGEDEL-i#inf,RANGEDEL] seqnums:[11-11] points:[g#11,RANGEDEL-i#inf,RANGEDEL] size:1
   add-backing:   000005
 
-excise i j
+excise-dryrun i j
 ----
 would excise 0 files, use ingest-and-excise to excise.
 
 # Excise mid range key. This will not happen in practice, but excise()
 # supports it.
 
-excise c d
+excise-dryrun c d
 ----
 would excise 2 files, use ingest-and-excise to excise.
   del-table:     L0 000005
@@ -709,6 +709,37 @@ c: (somethingElse, .)
 .
 .
 
+excise aa ab
+----
+
+lsm
+----
+L0.0:
+  000019:[a#22,SET-a#22,SET]
+  000020:[b#23,SET-f#23,SET]
+L6:
+  000023(000018):[a#0,SET-a#0,SET]
+  000022(000018):[f#0,SET-x#0,SET]
+
+excise g h
+----
+
+lsm
+----
+L0.0:
+  000019:[a#22,SET-a#22,SET]
+  000020:[b#23,SET-f#23,SET]
+L6:
+  000023(000018):[a#0,SET-a#0,SET]
+  000024(000018):[f#0,SET-f#0,SET]
+  000025(000018):[x#0,SET-x#0,SET]
+
+excise a z
+----
+
+lsm
+----
+
 # Two overlapping ingestions wait on one another even if
 # the overlap is only on the excise span.
 
diff --git a/testdata/flushable_ingest b/testdata/flushable_ingest
index fa78d294..36112ec7 100644
--- a/testdata/flushable_ingest
+++ b/testdata/flushable_ingest
@@ -60,7 +60,7 @@ LOCK
 MANIFEST-000001
 OPTIONS-000003
 ext
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 # Test basic WAL replay
@@ -81,7 +81,7 @@ LOCK
 MANIFEST-000001
 OPTIONS-000003
 ext
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 open
@@ -390,7 +390,7 @@ LOCK
 MANIFEST-000001
 OPTIONS-000003
 ext
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 close
@@ -410,7 +410,7 @@ LOCK
 MANIFEST-000001
 OPTIONS-000003
 ext
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 open
@@ -443,7 +443,7 @@ MANIFEST-000001
 MANIFEST-000011
 OPTIONS-000014
 ext
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000002.MANIFEST-000011
 
 # Make sure that the new mutable memtable can accept writes.
@@ -586,7 +586,7 @@ LOCK
 MANIFEST-000001
 OPTIONS-000003
 ext
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 close
@@ -605,7 +605,7 @@ MANIFEST-000001
 OPTIONS-000003
 ext
 ext1
-marker.format-version.000006.019
+marker.format-version.000007.020
 marker.manifest.000001.MANIFEST-000001
 
 open
diff --git a/tool/testdata/db_upgrade b/tool/testdata/db_upgrade
index 7c27d73c..5a94e6ce 100644
--- a/tool/testdata/db_upgrade
+++ b/tool/testdata/db_upgrade
@@ -27,7 +27,7 @@ db get foo yellow
 db upgrade foo
 ----
 ----
-Upgrading DB from internal version 16 to 19.
+Upgrading DB from internal version 16 to 20.
 WARNING!!!
 This DB will not be usable with older versions of Pebble!
 
@@ -43,7 +43,7 @@ Continue? [Y/N] Error: EOF
 
 db upgrade foo --yes
 ----
-Upgrading DB from internal version 16 to 19.
+Upgrading DB from internal version 16 to 20.
 Upgrade complete.
 
 db get foo blue
@@ -56,4 +56,4 @@ db get foo yellow
 
 db upgrade foo
 ----
-DB is already at internal version 19.
+DB is already at internal version 20.
diff --git a/wal/testdata/reader b/wal/testdata/reader
index 257bf8c0..3256c6ac 100644
--- a/wal/testdata/reader
+++ b/wal/testdata/reader
@@ -90,7 +90,7 @@ r.NextRecord() = (rr, (000002.log: 111), <nil>)
 r.NextRecord() = (rr, (000002.log: 272), <nil>)
   io.ReadAll(rr) = ("2a0000000000000001000000ec8367c42ebf0ffad5c57ece37b18559ba95ad78... <64000-byte record>", <nil>)
   BatchHeader: [seqNum=42,count=1]
-r.NextRecord() = (rr, (000002.log: 64294), pebble/record: invalid chunk)
+r.NextRecord() = (rr, (000002.log: 64294), unexpected EOF)
 
 # Test a typical failure scenario. Start off with a recycled log file (000003)
 # that would be on the primary device. It closes "unclean" because we're unable
@@ -253,7 +253,7 @@ r.NextRecord() = (rr, (000005-001.log: 55), 6022 from previous files, <nil>)
 r.NextRecord() = (rr, (000005-001.log: 482), 6022 from previous files, <nil>)
   io.ReadAll(rr) = ("12750100000000001d0000007575c6296b096226e5e78b9760aa7c2ecfa913b6... <199-byte record>", <nil>)
   BatchHeader: [seqNum=95506,count=29]
-r.NextRecord() = (rr, (000005-001.log: 692), 6022 from previous files, pebble/record: invalid chunk)
+r.NextRecord() = (rr, (000005-001.log: 692), 6022 from previous files, unexpected EOF)
 
 # Read again, this time pretending we found a third segment with the
 # logNameIndex=002. This helps exercise error conditions switching to a new