Repository: cockroachdb/pebble (go)

Issue:
TestCrashOpenCrashAfterWALCreation flaky under test
```
go test -tags invariants --exec 'stress -p 16' -timeout 0 -v -race . -run TestCrashOpenCrashAfterWALCreation

980 runs so far, 0 failures, over 1m5s

=== RUN   TestCrashOpenCrashAfterWALCreation
    options_test.go:37: Running TestCrashOpenCrashAfterWALCreation with format major version 016
    ingest_test.go:3019: [JOB 1] WAL 000002: {(,000)} stopped reading at offset: (000002.log: 29); replayed 1 keys in 1 batches
    open_test.go:1036: 
                Error Trace:    /Users/radu/go/src/github.com/cockroachdb/pebble/open_test.go:1036
                Error:          "2" is not greater than "2"
                Test:           TestCrashOpenCrashAfterWALCreation
--- FAIL: TestCrashOpenCrashAfterWALCreation (0.00s)
FAIL
```

Pull request:
db: fix TestCrashOpenCrashAfterWALCreation flake
The errorfs-based instrumentation had a race between the wal creation actually happening on the underlying FS and a dir sync (and/or between the dir sync actually happening on the underlying FS and a subsequent operation).

We use a custom instrumented FS that acts *after* the operation happens on the MemFS.

Fixes #4306

Patch:
diff --git a/cockroachkvs/blockproperties.go b/cockroachkvs/blockproperties.go
index 22ebd0b9..d5bc61b1 100644
--- a/cockroachkvs/blockproperties.go
+++ b/cockroachkvs/blockproperties.go
@@ -45,6 +45,14 @@ type MVCCWallTimeIntervalRangeKeyMask struct {
 	sstable.BlockIntervalFilter
 }
 
+// Init initializees the mask and its block interval filter.
+func (m *MVCCWallTimeIntervalRangeKeyMask) Init() {
+	m.BlockIntervalFilter.Init(
+		mvccWallTimeIntervalCollector,
+		0, math.MaxUint64,
+		MVCCBlockIntervalSuffixReplacer{})
+}
+
 // SetSuffix implements the pebble.BlockPropertyFilterMask interface.
 func (m *MVCCWallTimeIntervalRangeKeyMask) SetSuffix(suffix []byte) error {
 	if len(suffix) == 0 {
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
new file mode 100644
index 00000000..e80ca117
--- /dev/null
+++ b/internal/cache/cache.go
@@ -0,0 +1,314 @@
+// 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 cache
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"runtime"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/cockroachdb/pebble/internal/base"
+	"github.com/cockroachdb/pebble/internal/invariants"
+)
+
+// Metrics holds metrics for the cache.
+type Metrics struct {
+	// The number of bytes inuse by the cache.
+	Size int64
+	// The count of objects (blocks or tables) in the cache.
+	Count int64
+	// The number of cache hits.
+	Hits int64
+	// The number of cache misses.
+	Misses int64
+}
+
+// Cache implements Pebble's sharded block cache. The Clock-PRO algorithm is
+// used for page replacement
+// (http://static.usenix.org/event/usenix05/tech/general/full_papers/jiang/jiang_html/html.html). In
+// order to provide better concurrency, 4 x NumCPUs shards are created, with
+// each shard being given 1/n of the target cache size. The Clock-PRO algorithm
+// is run independently on each shard.
+//
+// Blocks are keyed by an (id, fileNum, offset) triple. The ID is a namespace
+// for file numbers and allows a single Cache to be shared between multiple
+// Pebble instances. The fileNum and offset refer to an sstable file number and
+// the offset of the block within the file. Because sstables are immutable and
+// file numbers are never reused, (fileNum,offset) are unique for the lifetime
+// of a Pebble instance.
+//
+// In addition to maintaining a map from (fileNum,offset) to data, each shard
+// maintains a map of the cached blocks for a particular fileNum. This allows
+// efficient eviction of all of the blocks for a file which is used when an
+// sstable is deleted from disk.
+//
+// # Memory Management
+//
+// A normal implementation of the block cache would result in GC having to read
+// through all the structures and keep track of the liveness of many objects.
+// This was found to cause significant overhead in CRDB when compared to the
+// earlier use of RocksDB.
+//
+// In order to reduce pressure on the Go GC, manual memory management is
+// performed for the data stored in the cache. Manual memory management is
+// performed by calling into C.{malloc,free} to allocate memory; this memory is
+// outside the purview of the GC. Cache.Values are reference counted and the
+// memory backing a manual value is freed when the reference count drops to 0.
+//
+// Manual memory management brings the possibility of memory leaks. It is
+// imperative that every Handle returned by Cache.{Get,Set} is eventually
+// released. The "invariants" build tag enables a leak detection facility that
+// places a GC finalizer on cache.Value. When the cache.Value finalizer is run,
+// if the underlying buffer is still present a leak has occurred. The "tracing"
+// build tag enables tracing of cache.Value reference count manipulation and
+// eases finding where a leak has occurred. These two facilities are usually
+// used in combination by specifying `-tags invariants,tracing`. Note that
+// "tracing" produces a significant slowdown, while "invariants" does not.
+type Cache struct {
+	refs    atomic.Int64
+	maxSize int64
+	idAlloc atomic.Uint64
+	shards  []shard
+
+	// Traces recorded by Cache.trace. Used for debugging.
+	tr struct {
+		sync.Mutex
+		msgs []string
+	}
+}
+
+// ID is a namespace for file numbers. It allows a single Cache to be shared
+// among multiple Pebble instances. NewID can be used to generate a new ID that
+// is unique in the context of this cache.
+type ID uint64
+
+// New creates a new cache of the specified size. Memory for the cache is
+// allocated on demand, not during initialization. The cache is created with a
+// reference count of 1. Each DB it is associated with adds a reference, so the
+// creator of the cache should usually release their reference after the DB is
+// created.
+//
+//	c := cache.New(...)
+//	defer c.Unref()
+//	d, err := pebble.Open(pebble.Options{Cache: c})
+func New(size int64) *Cache {
+	// How many cache shards should we create?
+	//
+	// Note that the probability two processors will try to access the same
+	// shard at the same time increases superlinearly with the number of
+	// processors (consider the birthday problem where each CPU is a person,
+	// and each shard is a possible birthday).
+	//
+	// We could consider growing the number of shards superlinearly, but
+	// increasing the shard count may reduce the effectiveness of the caching
+	// algorithm if frequently-accessed blocks are insufficiently distributed
+	// across shards.
+	//
+	// Experimentally, we've observed contention contributing to tail latencies
+	// at 2 shards per processor. For now we use 4 shards per processor,
+	// recognizing this may not be final word.
+	m := 4 * runtime.GOMAXPROCS(0)
+
+	// In tests we can use large CPU machines with small cache sizes and have
+	// many caches in existence at a time. If sharding into m shards would
+	// produce too small shards, constrain the number of shards to 4.
+	const minimumShardSize = 4 << 20 // 4 MiB
+	if m > 4 && int(size)/m < minimumShardSize {
+		m = 4
+	}
+	return newShards(size, m)
+}
+
+func newShards(size int64, shards int) *Cache {
+	c := &Cache{
+		maxSize: size,
+		shards:  make([]shard, shards),
+	}
+	c.refs.Store(1)
+	c.idAlloc.Store(1)
+	c.trace("alloc", c.refs.Load())
+	for i := range c.shards {
+		c.shards[i].init(size / int64(len(c.shards)))
+	}
+
+	// Note: this is a no-op if invariants are disabled or race is enabled.
+	invariants.SetFinalizer(c, func(c *Cache) {
+		if v := c.refs.Load(); v != 0 {
+			c.tr.Lock()
+			fmt.Fprintf(os.Stderr,
+				"pebble: cache (%p) has non-zero reference count: %d\n", c, v)
+			if len(c.tr.msgs) > 0 {
+				fmt.Fprintf(os.Stderr, "%s\n", strings.Join(c.tr.msgs, "\n"))
+			}
+			c.tr.Unlock()
+			os.Exit(1)
+		}
+	})
+	return c
+}
+
+func (c *Cache) getShard(k key) *shard {
+	idx := k.shardIdx(len(c.shards))
+	return &c.shards[idx]
+}
+
+// Ref adds a reference to the cache. The cache only remains valid as long a
+// reference is maintained to it.
+func (c *Cache) Ref() {
+	v := c.refs.Add(1)
+	if v <= 1 {
+		panic(fmt.Sprintf("pebble: inconsistent reference count: %d", v))
+	}
+	c.trace("ref", v)
+}
+
+// Unref releases a reference on the cache.
+func (c *Cache) Unref() {
+	v := c.refs.Add(-1)
+	c.trace("unref", v)
+	switch {
+	case v < 0:
+		panic(fmt.Sprintf("pebble: inconsistent reference count: %d", v))
+	case v == 0:
+		for i := range c.shards {
+			c.shards[i].Free()
+		}
+	}
+}
+
+// Get retrieves the cache value for the specified file and offset, returning
+// nil if no value is present.
+func (c *Cache) Get(id ID, fileNum base.DiskFileNum, offset uint64) *Value {
+	k := makeKey(id, fileNum, offset)
+	cv, re := c.getShard(k).getWithMaybeReadEntry(k, false /* desireReadEntry */)
+	if invariants.Enabled && re != nil {
+		panic("readEntry should be nil")
+	}
+	return cv
+}
+
+// GetWithReadHandle retrieves the cache value for the specified ID, fileNum
+// and offset. If found, a valid Handle is returned (with cacheHit set to
+// true), else a valid ReadHandle is returned.
+//
+// See the ReadHandle declaration for the contract the caller must satisfy
+// when getting a valid ReadHandle.
+//
+// This method can block before returning since multiple concurrent gets for
+// the same cache value will take turns getting a ReadHandle, which represents
+// permission to do the read. This blocking respects context cancellation, in
+// which case an error is returned (and not a valid ReadHandle).
+//
+// When blocking, the errorDuration return value can be non-zero and is
+// populated with the total duration that other readers that observed an error
+// (see ReadHandle.SetReadError) spent in doing the read. This duration can be
+// greater than the time spent blocked in this method, since some of these
+// errors could have occurred prior to this call. But it serves as a rough
+// indicator of whether turn taking could have caused higher latency due to
+// context cancellation of other readers.
+//
+// While waiting, someone else may successfully read the value, which results
+// in a valid Handle being returned. This is a case where cacheHit=false.
+func (c *Cache) GetWithReadHandle(
+	ctx context.Context, id ID, fileNum base.DiskFileNum, offset uint64,
+) (cv *Value, rh ReadHandle, errorDuration time.Duration, cacheHit bool, err error) {
+	k := makeKey(id, fileNum, offset)
+	cv, re := c.getShard(k).getWithMaybeReadEntry(k, true /* desireReadEntry */)
+	if cv != nil {
+		return cv, ReadHandle{}, 0, true, nil
+	}
+	cv, errorDuration, err = re.waitForReadPermissionOrHandle(ctx)
+	if err != nil || cv != nil {
+		re.unrefAndTryRemoveFromMap()
+		return cv, ReadHandle{}, errorDuration, false, err
+	}
+	return nil, ReadHandle{entry: re}, errorDuration, false, nil
+}
+
+// Set sets the cache value for the specified file and offset, overwriting an
+// existing value if present. The value must have been allocated by Cache.Alloc.
+//
+// The cache takes a reference on the Value and holds it until it gets evicted.
+func (c *Cache) Set(id ID, fileNum base.DiskFileNum, offset uint64, value *Value) {
+	k := makeKey(id, fileNum, offset)
+	c.getShard(k).set(k, value)
+}
+
+// Delete deletes the cached value for the specified file and offset.
+func (c *Cache) Delete(id ID, fileNum base.DiskFileNum, offset uint64) {
+	k := makeKey(id, fileNum, offset)
+	c.getShard(k).delete(k)
+}
+
+// EvictFile evicts all of the cache values for the specified file.
+func (c *Cache) EvictFile(id ID, fileNum base.DiskFileNum) {
+	if id == 0 {
+		panic("pebble: 0 cache ID is invalid")
+	}
+	for i := range c.shards {
+		c.shards[i].evictFile(id, fileNum)
+	}
+}
+
+// MaxSize returns the max size of the cache.
+func (c *Cache) MaxSize() int64 {
+	return c.maxSize
+}
+
+// Size returns the current space used by the cache.
+func (c *Cache) Size() int64 {
+	var size int64
+	for i := range c.shards {
+		size += c.shards[i].Size()
+	}
+	return size
+}
+
+// Reserve N bytes in the cache. This effectively shrinks the size of the cache
+// by N bytes, without actually consuming any memory. The returned closure
+// should be invoked to release the reservation.
+func (c *Cache) Reserve(n int) func() {
+	// Round-up the per-shard reservation. Most reservations should be large, so
+	// this probably doesn't matter in practice.
+	shardN := (n + len(c.shards) - 1) / len(c.shards)
+	for i := range c.shards {
+		c.shards[i].Reserve(shardN)
+	}
+	return func() {
+		if shardN == -1 {
+			panic("pebble: cache reservation already released")
+		}
+		for i := range c.shards {
+			c.shards[i].Reserve(-shardN)
+		}
+		shardN = -1
+	}
+}
+
+// Metrics returns the metrics for the cache.
+func (c *Cache) Metrics() Metrics {
+	var m Metrics
+	for i := range c.shards {
+		s := &c.shards[i]
+		s.mu.RLock()
+		m.Count += int64(s.blocks.Len())
+		m.Size += s.sizeHot + s.sizeCold
+		s.mu.RUnlock()
+		m.Hits += s.hits.Load()
+		m.Misses += s.misses.Load()
+	}
+	return m
+}
+
+// NewID returns a new ID to be used as a namespace for cached file
+// blocks.
+func (c *Cache) NewID() ID {
+	return ID(c.idAlloc.Add(1))
+}
diff --git a/internal/cache/clockpro.go b/internal/cache/clockpro.go
index c1c6d5a2..99f56adb 100644
--- a/internal/cache/clockpro.go
+++ b/internal/cache/clockpro.go
@@ -18,15 +18,12 @@
 package cache // import "github.com/cockroachdb/pebble/internal/cache"
 
 import (
-	"context"
 	"fmt"
 	"os"
 	"runtime"
 	"runtime/debug"
-	"strings"
 	"sync"
 	"sync/atomic"
-	"time"
 
 	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/cockroachdb/pebble/internal/invariants"
@@ -644,317 +641,3 @@ func (c *shard) runHandTest() {
 
 	c.handTest = c.handTest.next()
 }
-
-// Metrics holds metrics for the cache.
-type Metrics struct {
-	// The number of bytes inuse by the cache.
-	Size int64
-	// The count of objects (blocks or tables) in the cache.
-	Count int64
-	// The number of cache hits.
-	Hits int64
-	// The number of cache misses.
-	Misses int64
-}
-
-// Cache implements Pebble's sharded block cache. The Clock-PRO algorithm is
-// used for page replacement
-// (http://static.usenix.org/event/usenix05/tech/general/full_papers/jiang/jiang_html/html.html). In
-// order to provide better concurrency, 4 x NumCPUs shards are created, with
-// each shard being given 1/n of the target cache size. The Clock-PRO algorithm
-// is run independently on each shard.
-//
-// Blocks are keyed by an (id, fileNum, offset) triple. The ID is a namespace
-// for file numbers and allows a single Cache to be shared between multiple
-// Pebble instances. The fileNum and offset refer to an sstable file number and
-// the offset of the block within the file. Because sstables are immutable and
-// file numbers are never reused, (fileNum,offset) are unique for the lifetime
-// of a Pebble instance.
-//
-// In addition to maintaining a map from (fileNum,offset) to data, each shard
-// maintains a map of the cached blocks for a particular fileNum. This allows
-// efficient eviction of all of the blocks for a file which is used when an
-// sstable is deleted from disk.
-//
-// # Memory Management
-//
-// A normal implementation of the block cache would result in GC having to read
-// through all the structures and keep track of the liveness of many objects.
-// This was found to cause significant overhead in CRDB when compared to the
-// earlier use of RocksDB.
-//
-// In order to reduce pressure on the Go GC, manual memory management is
-// performed for the data stored in the cache. Manual memory management is
-// performed by calling into C.{malloc,free} to allocate memory; this memory is
-// outside the purview of the GC. Cache.Values are reference counted and the
-// memory backing a manual value is freed when the reference count drops to 0.
-//
-// Manual memory management brings the possibility of memory leaks. It is
-// imperative that every Handle returned by Cache.{Get,Set} is eventually
-// released. The "invariants" build tag enables a leak detection facility that
-// places a GC finalizer on cache.Value. When the cache.Value finalizer is run,
-// if the underlying buffer is still present a leak has occurred. The "tracing"
-// build tag enables tracing of cache.Value reference count manipulation and
-// eases finding where a leak has occurred. These two facilities are usually
-// used in combination by specifying `-tags invariants,tracing`. Note that
-// "tracing" produces a significant slowdown, while "invariants" does not.
-type Cache struct {
-	refs    atomic.Int64
-	maxSize int64
-	idAlloc atomic.Uint64
-	shards  []shard
-
-	// Traces recorded by Cache.trace. Used for debugging.
-	tr struct {
-		sync.Mutex
-		msgs []string
-	}
-}
-
-// ID is a namespace for file numbers. It allows a single Cache to be shared
-// among multiple Pebble instances. NewID can be used to generate a new ID that
-// is unique in the context of this cache.
-type ID uint64
-
-// New creates a new cache of the specified size. Memory for the cache is
-// allocated on demand, not during initialization. The cache is created with a
-// reference count of 1. Each DB it is associated with adds a reference, so the
-// creator of the cache should usually release their reference after the DB is
-// created.
-//
-//	c := cache.New(...)
-//	defer c.Unref()
-//	d, err := pebble.Open(pebble.Options{Cache: c})
-func New(size int64) *Cache {
-	// How many cache shards should we create?
-	//
-	// Note that the probability two processors will try to access the same
-	// shard at the same time increases superlinearly with the number of
-	// processors (consider the birthday problem where each CPU is a person,
-	// and each shard is a possible birthday).
-	//
-	// We could consider growing the number of shards superlinearly, but
-	// increasing the shard count may reduce the effectiveness of the caching
-	// algorithm if frequently-accessed blocks are insufficiently distributed
-	// across shards.
-	//
-	// Experimentally, we've observed contention contributing to tail latencies
-	// at 2 shards per processor. For now we use 4 shards per processor,
-	// recognizing this may not be final word.
-	m := 4 * runtime.GOMAXPROCS(0)
-
-	// In tests we can use large CPU machines with small cache sizes and have
-	// many caches in existence at a time. If sharding into m shards would
-	// produce too small shards, constrain the number of shards to 4.
-	const minimumShardSize = 4 << 20 // 4 MiB
-	if m > 4 && int(size)/m < minimumShardSize {
-		m = 4
-	}
-	return newShards(size, m)
-}
-
-func newShards(size int64, shards int) *Cache {
-	c := &Cache{
-		maxSize: size,
-		shards:  make([]shard, shards),
-	}
-	c.refs.Store(1)
-	c.idAlloc.Store(1)
-	c.trace("alloc", c.refs.Load())
-	for i := range c.shards {
-		c.shards[i].init(size / int64(len(c.shards)))
-	}
-
-	// Note: this is a no-op if invariants are disabled or race is enabled.
-	invariants.SetFinalizer(c, func(c *Cache) {
-		if v := c.refs.Load(); v != 0 {
-			c.tr.Lock()
-			fmt.Fprintf(os.Stderr,
-				"pebble: cache (%p) has non-zero reference count: %d\n", c, v)
-			if len(c.tr.msgs) > 0 {
-				fmt.Fprintf(os.Stderr, "%s\n", strings.Join(c.tr.msgs, "\n"))
-			}
-			c.tr.Unlock()
-			os.Exit(1)
-		}
-	})
-	return c
-}
-
-func (c *Cache) getShard(k key) *shard {
-	idx := k.shardIdx(len(c.shards))
-	return &c.shards[idx]
-}
-
-// Ref adds a reference to the cache. The cache only remains valid as long a
-// reference is maintained to it.
-func (c *Cache) Ref() {
-	v := c.refs.Add(1)
-	if v <= 1 {
-		panic(fmt.Sprintf("pebble: inconsistent reference count: %d", v))
-	}
-	c.trace("ref", v)
-}
-
-// Unref releases a reference on the cache.
-func (c *Cache) Unref() {
-	v := c.refs.Add(-1)
-	c.trace("unref", v)
-	switch {
-	case v < 0:
-		panic(fmt.Sprintf("pebble: inconsistent reference count: %d", v))
-	case v == 0:
-		for i := range c.shards {
-			c.shards[i].Free()
-		}
-	}
-}
-
-// Get retrieves the cache value for the specified file and offset, returning
-// nil if no value is present.
-func (c *Cache) Get(id ID, fileNum base.DiskFileNum, offset uint64) *Value {
-	k := makeKey(id, fileNum, offset)
-	cv, re := c.getShard(k).getWithMaybeReadEntry(k, false /* desireReadEntry */)
-	if invariants.Enabled && re != nil {
-		panic("readEntry should be nil")
-	}
-	return cv
-}
-
-// GetWithReadHandle retrieves the cache value for the specified ID, fileNum
-// and offset. If found, a valid Handle is returned (with cacheHit set to
-// true), else a valid ReadHandle is returned.
-//
-// See the ReadHandle declaration for the contract the caller must satisfy
-// when getting a valid ReadHandle.
-//
-// This method can block before returning since multiple concurrent gets for
-// the same cache value will take turns getting a ReadHandle, which represents
-// permission to do the read. This blocking respects context cancellation, in
-// which case an error is returned (and not a valid ReadHandle).
-//
-// When blocking, the errorDuration return value can be non-zero and is
-// populated with the total duration that other readers that observed an error
-// (see ReadHandle.SetReadError) spent in doing the read. This duration can be
-// greater than the time spent blocked in this method, since some of these
-// errors could have occurred prior to this call. But it serves as a rough
-// indicator of whether turn taking could have caused higher latency due to
-// context cancellation of other readers.
-//
-// While waiting, someone else may successfully read the value, which results
-// in a valid Handle being returned. This is a case where cacheHit=false.
-func (c *Cache) GetWithReadHandle(
-	ctx context.Context, id ID, fileNum base.DiskFileNum, offset uint64,
-) (cv *Value, rh ReadHandle, errorDuration time.Duration, cacheHit bool, err error) {
-	k := makeKey(id, fileNum, offset)
-	cv, re := c.getShard(k).getWithMaybeReadEntry(k, true /* desireReadEntry */)
-	if cv != nil {
-		return cv, ReadHandle{}, 0, true, nil
-	}
-	cv, errorDuration, err = re.waitForReadPermissionOrHandle(ctx)
-	if err != nil || cv != nil {
-		re.unrefAndTryRemoveFromMap()
-		return cv, ReadHandle{}, errorDuration, false, err
-	}
-	return nil, ReadHandle{entry: re}, errorDuration, false, nil
-}
-
-// Set sets the cache value for the specified file and offset, overwriting an
-// existing value if present. The value must have been allocated by Cache.Alloc.
-//
-// The cache takes a reference on the Value and holds it until it gets evicted.
-func (c *Cache) Set(id ID, fileNum base.DiskFileNum, offset uint64, value *Value) {
-	k := makeKey(id, fileNum, offset)
-	c.getShard(k).set(k, value)
-}
-
-// Delete deletes the cached value for the specified file and offset.
-func (c *Cache) Delete(id ID, fileNum base.DiskFileNum, offset uint64) {
-	k := makeKey(id, fileNum, offset)
-	c.getShard(k).delete(k)
-}
-
-// EvictFile evicts all of the cache values for the specified file.
-func (c *Cache) EvictFile(id ID, fileNum base.DiskFileNum) {
-	if id == 0 {
-		panic("pebble: 0 cache ID is invalid")
-	}
-	for i := range c.shards {
-		c.shards[i].evictFile(id, fileNum)
-	}
-}
-
-// MaxSize returns the max size of the cache.
-func (c *Cache) MaxSize() int64 {
-	return c.maxSize
-}
-
-// Size returns the current space used by the cache.
-func (c *Cache) Size() int64 {
-	var size int64
-	for i := range c.shards {
-		size += c.shards[i].Size()
-	}
-	return size
-}
-
-// Alloc allocates a byte slice of the specified size, possibly reusing
-// previously allocated but unused memory. The memory backing the value is
-// manually managed. The caller MUST either add the value to the cache (via
-// Cache.Set), or release the value (via Cache.Free). Failure to do so will
-// result in a memory leak.
-func Alloc(n int) *Value {
-	return newValue(n)
-}
-
-// Free frees the specified value. The buffer associated with the value will
-// possibly be reused, making it invalid to use the buffer after calling
-// Free. Do not call Free on a value that has been added to the cache.
-func Free(v *Value) {
-	if n := v.refs(); n > 1 {
-		panic(fmt.Sprintf("pebble: Value has been added to the cache: refs=%d", n))
-	}
-	v.Release()
-}
-
-// Reserve N bytes in the cache. This effectively shrinks the size of the cache
-// by N bytes, without actually consuming any memory. The returned closure
-// should be invoked to release the reservation.
-func (c *Cache) Reserve(n int) func() {
-	// Round-up the per-shard reservation. Most reservations should be large, so
-	// this probably doesn't matter in practice.
-	shardN := (n + len(c.shards) - 1) / len(c.shards)
-	for i := range c.shards {
-		c.shards[i].Reserve(shardN)
-	}
-	return func() {
-		if shardN == -1 {
-			panic("pebble: cache reservation already released")
-		}
-		for i := range c.shards {
-			c.shards[i].Reserve(-shardN)
-		}
-		shardN = -1
-	}
-}
-
-// Metrics returns the metrics for the cache.
-func (c *Cache) Metrics() Metrics {
-	var m Metrics
-	for i := range c.shards {
-		s := &c.shards[i]
-		s.mu.RLock()
-		m.Count += int64(s.blocks.Len())
-		m.Size += s.sizeHot + s.sizeCold
-		s.mu.RUnlock()
-		m.Hits += s.hits.Load()
-		m.Misses += s.misses.Load()
-	}
-	return m
-}
-
-// NewID returns a new ID to be used as a namespace for cached file
-// blocks.
-func (c *Cache) NewID() ID {
-	return ID(c.idAlloc.Add(1))
-}
diff --git a/internal/cache/value.go b/internal/cache/value.go
index 82655bc0..d025d2a9 100644
--- a/internal/cache/value.go
+++ b/internal/cache/value.go
@@ -41,7 +41,12 @@ type Value struct {
 // want to add finalizer assertions.
 const valueEntryGoAllocated = !buildtags.Cgo || invariants.UseFinalizers
 
-func newValue(n int) *Value {
+// Alloc allocates a byte slice of the specified size, possibly reusing
+// previously allocated but unused memory. The memory backing the value is
+// manually managed. The caller MUST either add the value to the cache (via
+// Cache.Set), or release the value (via Cache.Free). Failure to do so will
+// result in a memory leak.
+func Alloc(n int) *Value {
 	if n == 0 {
 		return nil
 	}
@@ -126,3 +131,13 @@ func (v *Value) Release() {
 		v.free()
 	}
 }
+
+// Free frees the specified value. The buffer associated with the value will
+// possibly be reused, making it invalid to use the buffer after calling
+// Free. Do not call Free on a value that has been added to the cache.
+func Free(v *Value) {
+	if n := v.refs(); n > 1 {
+		panic(fmt.Sprintf("pebble: Value has been added to the cache: refs=%d", n))
+	}
+	v.Release()
+}


Test patch:
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
new file mode 100644
index 00000000..562d4ce9
--- /dev/null
+++ b/internal/cache/cache_test.go
@@ -0,0 +1,252 @@
+// 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 cache
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"math/rand/v2"
+	"os"
+	"runtime"
+	"strconv"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/cockroachdb/pebble/internal/base"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCache(t *testing.T) {
+	// Test data was generated from the python code
+	f, err := os.Open("testdata/cache")
+	require.NoError(t, err)
+
+	cache := newShards(200, 1)
+	defer cache.Unref()
+
+	scanner := bufio.NewScanner(f)
+	line := 1
+
+	for scanner.Scan() {
+		fields := bytes.Fields(scanner.Bytes())
+
+		key, err := strconv.Atoi(string(fields[0]))
+		require.NoError(t, err)
+
+		wantHit := fields[1][0] == 'h'
+
+		var hit bool
+		cv := cache.Get(1, base.DiskFileNum(key), 0)
+		if cv == nil {
+			cv = Alloc(1)
+			cv.RawBuffer()[0] = fields[0][0]
+			cache.Set(1, base.DiskFileNum(key), 0, cv)
+		} else {
+			hit = true
+			if v := cv.RawBuffer(); !bytes.Equal(v, fields[0][:1]) {
+				t.Errorf("%d: cache returned bad data: got %s , want %s\n", line, v, fields[0][:1])
+			}
+		}
+		cv.Release()
+		if hit != wantHit {
+			t.Errorf("%d: cache hit mismatch: got %v, want %v\n", line, hit, wantHit)
+		}
+		line++
+	}
+}
+
+func setTestValue(
+	cache *Cache, id ID, fileNum base.DiskFileNum, offset uint64, s string, repeat int,
+) {
+	b := bytes.Repeat([]byte(s), repeat)
+	v := Alloc(len(b))
+	copy(v.RawBuffer(), b)
+	cache.Set(id, fileNum, offset, v)
+	v.Release()
+}
+
+func TestCacheDelete(t *testing.T) {
+	cache := newShards(100, 1)
+	defer cache.Unref()
+
+	setTestValue(cache, 1, 0, 0, "a", 5)
+	setTestValue(cache, 1, 1, 0, "a", 5)
+	setTestValue(cache, 1, 2, 0, "a", 5)
+	if expected, size := int64(15), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+	cache.Delete(1, base.DiskFileNum(1), 0)
+	if expected, size := int64(10), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+	if v := cache.Get(1, base.DiskFileNum(0), 0); v == nil {
+		t.Fatalf("expected to find block 0/0")
+	} else {
+		v.Release()
+	}
+	if v := cache.Get(1, base.DiskFileNum(1), 0); v != nil {
+		t.Fatalf("expected to not find block 1/0")
+	}
+	// Deleting a non-existing block does nothing.
+	cache.Delete(1, base.DiskFileNum(1), 0)
+	if expected, size := int64(10), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+}
+
+func TestEvictFile(t *testing.T) {
+	cache := newShards(100, 1)
+	defer cache.Unref()
+
+	setTestValue(cache, 1, 0, 0, "a", 5)
+	setTestValue(cache, 1, 1, 0, "a", 5)
+	setTestValue(cache, 1, 2, 0, "a", 5)
+	setTestValue(cache, 1, 2, 1, "a", 5)
+	setTestValue(cache, 1, 2, 2, "a", 5)
+	if expected, size := int64(25), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+	cache.EvictFile(1, base.DiskFileNum(0))
+	if expected, size := int64(20), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+	cache.EvictFile(1, base.DiskFileNum(1))
+	if expected, size := int64(15), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+	cache.EvictFile(1, base.DiskFileNum(2))
+	if expected, size := int64(0), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+}
+
+func TestEvictAll(t *testing.T) {
+	// Verify that it is okay to evict all of the data from a cache. Previously
+	// this would trigger a nil-pointer dereference.
+	cache := newShards(100, 1)
+	defer cache.Unref()
+
+	setTestValue(cache, 1, 0, 0, "a", 101)
+	setTestValue(cache, 1, 1, 0, "a", 101)
+}
+
+func TestMultipleDBs(t *testing.T) {
+	cache := newShards(100, 1)
+	defer cache.Unref()
+
+	setTestValue(cache, 1, 0, 0, "a", 5)
+	setTestValue(cache, 2, 0, 0, "b", 5)
+	if expected, size := int64(10), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+	cache.EvictFile(1, base.DiskFileNum(0))
+	if expected, size := int64(5), cache.Size(); expected != size {
+		t.Fatalf("expected cache size %d, but found %d", expected, size)
+	}
+	v := cache.Get(1, base.DiskFileNum(0), 0)
+	if v != nil {
+		t.Fatalf("expected not present, but found %#v", v)
+	}
+	v = cache.Get(2, base.DiskFileNum(0), 0)
+	if v := v.RawBuffer(); string(v) != "bbbbb" {
+		t.Fatalf("expected bbbbb, but found %s", v)
+	}
+	v.Release()
+}
+
+func TestZeroSize(t *testing.T) {
+	cache := newShards(0, 1)
+	defer cache.Unref()
+
+	setTestValue(cache, 1, 0, 0, "a", 5)
+}
+
+func TestReserve(t *testing.T) {
+	cache := newShards(4, 2)
+	defer cache.Unref()
+
+	setTestValue(cache, 1, 0, 0, "a", 1)
+	setTestValue(cache, 2, 0, 0, "a", 1)
+	require.EqualValues(t, 2, cache.Size())
+	r := cache.Reserve(1)
+	require.EqualValues(t, 0, cache.Size())
+
+	setTestValue(cache, 1, 0, 0, "a", 1)
+	setTestValue(cache, 2, 0, 0, "a", 1)
+	setTestValue(cache, 3, 0, 0, "a", 1)
+	setTestValue(cache, 4, 0, 0, "a", 1)
+	require.EqualValues(t, 2, cache.Size())
+	r()
+	require.EqualValues(t, 2, cache.Size())
+	setTestValue(cache, 1, 0, 0, "a", 1)
+	setTestValue(cache, 2, 0, 0, "a", 1)
+	require.EqualValues(t, 4, cache.Size())
+}
+
+func TestReserveDoubleRelease(t *testing.T) {
+	cache := newShards(100, 1)
+	defer cache.Unref()
+
+	r := cache.Reserve(10)
+	r()
+
+	result := func() (result string) {
+		defer func() {
+			if v := recover(); v != nil {
+				result = fmt.Sprint(v)
+			}
+		}()
+		r()
+		return ""
+	}()
+	const expected = "pebble: cache reservation already released"
+	if expected != result {
+		t.Fatalf("expected %q, but found %q", expected, result)
+	}
+}
+
+func TestCacheStressSetExisting(t *testing.T) {
+	cache := newShards(1, 1)
+	defer cache.Unref()
+
+	var wg sync.WaitGroup
+	for i := 0; i < 10; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			for j := 0; j < 10000; j++ {
+				setTestValue(cache, 1, 0, uint64(i), "a", 1)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+	wg.Wait()
+}
+
+func BenchmarkCacheGet(b *testing.B) {
+	const size = 100000
+
+	cache := newShards(size, 1)
+	defer cache.Unref()
+
+	for i := 0; i < size; i++ {
+		setTestValue(cache, 1, 0, 0, "a", 1)
+	}
+
+	b.ResetTimer()
+	b.RunParallel(func(pb *testing.PB) {
+		rng := rand.New(rand.NewPCG(0, uint64(time.Now().UnixNano())))
+
+		for pb.Next() {
+			v := cache.Get(1, base.DiskFileNum(0), uint64(rng.IntN(size)))
+			if v == nil {
+				b.Fatal("failed to lookup value")
+			}
+			v.Release()
+		}
+	})
+}
diff --git a/internal/cache/clockpro_test.go b/internal/cache/clockpro_test.go
index 9f5c2165..fad263a2 100644
--- a/internal/cache/clockpro_test.go
+++ b/internal/cache/clockpro_test.go
@@ -1,255 +1,15 @@
-// Copyright 2018. All rights reserved. Use of this source code is governed by
-// an MIT-style license that can be found in the LICENSE file.
+// Copyright 2018 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 cache
 
 import (
-	"bufio"
-	"bytes"
-	"fmt"
-	"math/rand/v2"
-	"os"
-	"runtime"
-	"strconv"
-	"sync"
 	"testing"
-	"time"
 
-	"github.com/cockroachdb/pebble/internal/base"
 	"github.com/stretchr/testify/require"
 )
 
-func TestCache(t *testing.T) {
-	// Test data was generated from the python code
-	f, err := os.Open("testdata/cache")
-	require.NoError(t, err)
-
-	cache := newShards(200, 1)
-	defer cache.Unref()
-
-	scanner := bufio.NewScanner(f)
-	line := 1
-
-	for scanner.Scan() {
-		fields := bytes.Fields(scanner.Bytes())
-
-		key, err := strconv.Atoi(string(fields[0]))
-		require.NoError(t, err)
-
-		wantHit := fields[1][0] == 'h'
-
-		var hit bool
-		cv := cache.Get(1, base.DiskFileNum(key), 0)
-		if cv == nil {
-			cv = Alloc(1)
-			cv.RawBuffer()[0] = fields[0][0]
-			cache.Set(1, base.DiskFileNum(key), 0, cv)
-		} else {
-			hit = true
-			if v := cv.RawBuffer(); !bytes.Equal(v, fields[0][:1]) {
-				t.Errorf("%d: cache returned bad data: got %s , want %s\n", line, v, fields[0][:1])
-			}
-		}
-		cv.Release()
-		if hit != wantHit {
-			t.Errorf("%d: cache hit mismatch: got %v, want %v\n", line, hit, wantHit)
-		}
-		line++
-	}
-}
-
-func setTestValue(
-	cache *Cache, id ID, fileNum base.DiskFileNum, offset uint64, s string, repeat int,
-) {
-	b := bytes.Repeat([]byte(s), repeat)
-	v := Alloc(len(b))
-	copy(v.RawBuffer(), b)
-	cache.Set(id, fileNum, offset, v)
-	v.Release()
-}
-
-func TestCacheDelete(t *testing.T) {
-	cache := newShards(100, 1)
-	defer cache.Unref()
-
-	setTestValue(cache, 1, 0, 0, "a", 5)
-	setTestValue(cache, 1, 1, 0, "a", 5)
-	setTestValue(cache, 1, 2, 0, "a", 5)
-	if expected, size := int64(15), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-	cache.Delete(1, base.DiskFileNum(1), 0)
-	if expected, size := int64(10), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-	if v := cache.Get(1, base.DiskFileNum(0), 0); v == nil {
-		t.Fatalf("expected to find block 0/0")
-	} else {
-		v.Release()
-	}
-	if v := cache.Get(1, base.DiskFileNum(1), 0); v != nil {
-		t.Fatalf("expected to not find block 1/0")
-	}
-	// Deleting a non-existing block does nothing.
-	cache.Delete(1, base.DiskFileNum(1), 0)
-	if expected, size := int64(10), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-}
-
-func TestEvictFile(t *testing.T) {
-	cache := newShards(100, 1)
-	defer cache.Unref()
-
-	setTestValue(cache, 1, 0, 0, "a", 5)
-	setTestValue(cache, 1, 1, 0, "a", 5)
-	setTestValue(cache, 1, 2, 0, "a", 5)
-	setTestValue(cache, 1, 2, 1, "a", 5)
-	setTestValue(cache, 1, 2, 2, "a", 5)
-	if expected, size := int64(25), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-	cache.EvictFile(1, base.DiskFileNum(0))
-	if expected, size := int64(20), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-	cache.EvictFile(1, base.DiskFileNum(1))
-	if expected, size := int64(15), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-	cache.EvictFile(1, base.DiskFileNum(2))
-	if expected, size := int64(0), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-}
-
-func TestEvictAll(t *testing.T) {
-	// Verify that it is okay to evict all of the data from a cache. Previously
-	// this would trigger a nil-pointer dereference.
-	cache := newShards(100, 1)
-	defer cache.Unref()
-
-	setTestValue(cache, 1, 0, 0, "a", 101)
-	setTestValue(cache, 1, 1, 0, "a", 101)
-}
-
-func TestMultipleDBs(t *testing.T) {
-	cache := newShards(100, 1)
-	defer cache.Unref()
-
-	setTestValue(cache, 1, 0, 0, "a", 5)
-	setTestValue(cache, 2, 0, 0, "b", 5)
-	if expected, size := int64(10), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-	cache.EvictFile(1, base.DiskFileNum(0))
-	if expected, size := int64(5), cache.Size(); expected != size {
-		t.Fatalf("expected cache size %d, but found %d", expected, size)
-	}
-	v := cache.Get(1, base.DiskFileNum(0), 0)
-	if v != nil {
-		t.Fatalf("expected not present, but found %#v", v)
-	}
-	v = cache.Get(2, base.DiskFileNum(0), 0)
-	if v := v.RawBuffer(); string(v) != "bbbbb" {
-		t.Fatalf("expected bbbbb, but found %s", v)
-	}
-	v.Release()
-}
-
-func TestZeroSize(t *testing.T) {
-	cache := newShards(0, 1)
-	defer cache.Unref()
-
-	setTestValue(cache, 1, 0, 0, "a", 5)
-}
-
-func TestReserve(t *testing.T) {
-	cache := newShards(4, 2)
-	defer cache.Unref()
-
-	setTestValue(cache, 1, 0, 0, "a", 1)
-	setTestValue(cache, 2, 0, 0, "a", 1)
-	require.EqualValues(t, 2, cache.Size())
-	r := cache.Reserve(1)
-	require.EqualValues(t, 0, cache.Size())
-
-	setTestValue(cache, 1, 0, 0, "a", 1)
-	setTestValue(cache, 2, 0, 0, "a", 1)
-	setTestValue(cache, 3, 0, 0, "a", 1)
-	setTestValue(cache, 4, 0, 0, "a", 1)
-	require.EqualValues(t, 2, cache.Size())
-	r()
-	require.EqualValues(t, 2, cache.Size())
-	setTestValue(cache, 1, 0, 0, "a", 1)
-	setTestValue(cache, 2, 0, 0, "a", 1)
-	require.EqualValues(t, 4, cache.Size())
-}
-
-func TestReserveDoubleRelease(t *testing.T) {
-	cache := newShards(100, 1)
-	defer cache.Unref()
-
-	r := cache.Reserve(10)
-	r()
-
-	result := func() (result string) {
-		defer func() {
-			if v := recover(); v != nil {
-				result = fmt.Sprint(v)
-			}
-		}()
-		r()
-		return ""
-	}()
-	const expected = "pebble: cache reservation already released"
-	if expected != result {
-		t.Fatalf("expected %q, but found %q", expected, result)
-	}
-}
-
-func TestCacheStressSetExisting(t *testing.T) {
-	cache := newShards(1, 1)
-	defer cache.Unref()
-
-	var wg sync.WaitGroup
-	for i := 0; i < 10; i++ {
-		wg.Add(1)
-		go func(i int) {
-			defer wg.Done()
-			for j := 0; j < 10000; j++ {
-				setTestValue(cache, 1, 0, uint64(i), "a", 1)
-				runtime.Gosched()
-			}
-		}(i)
-	}
-	wg.Wait()
-}
-
-func BenchmarkCacheGet(b *testing.B) {
-	const size = 100000
-
-	cache := newShards(size, 1)
-	defer cache.Unref()
-
-	for i := 0; i < size; i++ {
-		setTestValue(cache, 1, 0, 0, "a", 1)
-	}
-
-	b.ResetTimer()
-	b.RunParallel(func(pb *testing.PB) {
-		rng := rand.New(rand.NewPCG(0, uint64(time.Now().UnixNano())))
-
-		for pb.Next() {
-			v := cache.Get(1, base.DiskFileNum(0), uint64(rng.IntN(size)))
-			if v == nil {
-				b.Fatal("failed to lookup value")
-			}
-			v.Release()
-		}
-	})
-}
-
 func TestReserveColdTarget(t *testing.T) {
 	// If coldTarget isn't updated when we call shard.Reserve,
 	// then we unnecessarily remove nodes from the
diff --git a/open_test.go b/open_test.go
index 0e9dddec..3e42fd8f 100644
--- a/open_test.go
+++ b/open_test.go
@@ -994,33 +994,18 @@ func TestCrashOpenCrashAfterWALCreation(t *testing.T) {
 	// file is created and after a subsequent directory sync occurs. This
 	// simulates a crash after the new log file is created and synced.
 	crashFS = nil
-	var crashFSAtomic atomic.Pointer[vfs.MemFS]
+	instrumentedFS := &crashAfterLogCreationFS{MemFS: fs}
 	{
-		var walCreated, dirSynced atomic.Bool
 		d, err := Open("", &Options{
-			FS: errorfs.Wrap(fs, errorfs.InjectorFunc(func(op errorfs.Op) error {
-				if dirSynced.Load() {
-					crashFSAtomic.CompareAndSwap(nil, fs.CrashClone(vfs.CrashCloneCfg{UnsyncedDataPercent: 0}))
-				}
-				if op.Kind == errorfs.OpCreate && filepath.Ext(op.Path) == ".log" {
-					walCreated.Store(true)
-				}
-				// Record when there's a sync of the data directory after the
-				// WAL was created. The data directory will have an empty
-				// path because that's what we passed into Open.
-				if op.Kind == errorfs.OpFileSync && op.Path == "" && walCreated.Load() {
-					dirSynced.Store(true)
-				}
-				return nil
-			})),
+			FS:     instrumentedFS,
 			Logger: testLogger{t},
 		})
 		require.NoError(t, err)
 		require.NoError(t, d.Close())
 	}
 
-	require.NotNil(t, crashFSAtomic.Load())
-	fs = crashFSAtomic.Load()
+	fs = instrumentedFS.saved
+	require.NotNil(t, fs)
 
 	newLogs := getLogs()
 	if n := len(newLogs); n > 2 || n < 1 {
@@ -1033,7 +1018,10 @@ func TestCrashOpenCrashAfterWALCreation(t *testing.T) {
 		require.NoError(t, err)
 		curLogNum, err := strconv.Atoi(strings.Split(newLogs[0], ".")[0])
 		require.NoError(t, err)
-		require.Greater(t, curLogNum, origLogNum)
+		if curLogNum == origLogNum {
+			t.Logf("logs: %v newLogs: %v", logs, newLogs)
+		}
+		require.Greater(t, curLogNum, origLogNum, "%v %v", logs, newLogs)
 	}
 
 	// Finally, open the database with syncs enabled.
@@ -1042,6 +1030,59 @@ func TestCrashOpenCrashAfterWALCreation(t *testing.T) {
 	require.NoError(t, d.Close())
 }
 
+// crashAfterLogCreationFS wraps a MemFS and extracts a crash clone after a WAL
+// is created and the directory is subsequently synced.
+// TODO(radu): replace this with errorfs predicates; see
+// https://github.com/cockroachdb/pebble/pull/4308#pullrequestreview-2584817912
+type crashAfterLogCreationFS struct {
+	*vfs.MemFS
+	// state is 0 before the first log is created, 1 after the first log is
+	// created, 2 after the dir is synced.
+	state atomic.Int32
+	saved *vfs.MemFS
+}
+
+// crashAfterLogCreationDir wraps the File for the store directory to instrument
+// its Sync method.
+type crashAfterLogCreationDir struct {
+	vfs.File
+
+	fs *crashAfterLogCreationFS
+}
+
+func (fs *crashAfterLogCreationFS) Create(
+	name string, category vfs.DiskWriteCategory,
+) (vfs.File, error) {
+	f, err := fs.MemFS.Create(name, category)
+	if err == nil && filepath.Ext(name) == ".log" {
+		fs.state.CompareAndSwap(0, 1)
+	}
+	return f, err
+}
+
+func (fs *crashAfterLogCreationFS) OpenDir(fullname string) (vfs.File, error) {
+	f, err := fs.MemFS.OpenDir(fullname)
+	// The store directory will have an empty name (this is what we pass to Open).
+	if err != nil || fullname != "" {
+		return f, err
+	}
+	// Wrap the store directory File.
+	return &crashAfterLogCreationDir{File: f, fs: fs}, nil
+}
+
+func (d *crashAfterLogCreationDir) Sync() error {
+	if err := d.File.Sync(); err != nil {
+		return err
+	}
+	// Record the first time there's a sync of the data directory after the WAL
+	// was created.
+	if d.fs.state.CompareAndSwap(1, 2) {
+		// The atomic 1->2 transition can only happen once, so there is no race.
+		d.fs.saved = d.fs.MemFS.CrashClone(vfs.CrashCloneCfg{UnsyncedDataPercent: 0})
+	}
+	return nil
+}
+
 // TestOpenWALReplayReadOnlySeqNums tests opening a database:
 //   - in read-only mode
 //   - with multiple unflushed log files that must replayed