Repository: cockroachdb/pebble
Pull request #4316 (2025-02-03 22:25:48)

internal/manifest: convert TestVersionEditDecode to datadriven
Convert the TestVersionEditDecode unit test to use a datadriven test file.

cockroach-teamcity: 

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


Patch:
diff --git a/cmd/pebble/db.go b/cmd/pebble/db.go
index 939c81be..6cae7eda 100644
--- a/cmd/pebble/db.go
+++ b/cmd/pebble/db.go
@@ -55,10 +55,8 @@ type pebbleDB struct {
 }
 
 func newPebbleDB(dir string) DB {
-	cache := pebble.NewCache(cacheSize)
-	defer cache.Unref()
 	opts := &pebble.Options{
-		Cache:                       cache,
+		CacheSize:                   cacheSize,
 		Comparer:                    &cockroachkvs.Comparer,
 		DisableWAL:                  disableWAL,
 		FormatMajorVersion:          pebble.FormatNewest,
diff --git a/cmd/pebble/replay.go b/cmd/pebble/replay.go
index f927a0d7..5b6b8bab 100644
--- a/cmd/pebble/replay.go
+++ b/cmd/pebble/replay.go
@@ -24,7 +24,6 @@ import (
 	"github.com/cockroachdb/pebble/bloom"
 	"github.com/cockroachdb/pebble/cockroachkvs"
 	"github.com/cockroachdb/pebble/internal/base"
-	"github.com/cockroachdb/pebble/internal/cache"
 	"github.com/cockroachdb/pebble/replay"
 	"github.com/cockroachdb/pebble/vfs"
 	"github.com/spf13/cobra"
@@ -297,12 +296,6 @@ func (c *replayConfig) getCheckpointDir(r *replay.Runner) string {
 
 func (c *replayConfig) parseHooks() *pebble.ParseHooks {
 	return &pebble.ParseHooks{
-		NewCache: func(size int64) *cache.Cache {
-			if c.maxCacheSize != 0 && size > c.maxCacheSize {
-				size = c.maxCacheSize
-			}
-			return cache.New(size)
-		},
 		NewComparer: makeComparer,
 		NewFilterPolicy: func(name string) (pebble.FilterPolicy, error) {
 			switch name {
@@ -355,7 +348,13 @@ func (c *replayConfig) parseCustomOptions(optsStr string, opts *pebble.Options)
 			value = strings.TrimSpace(value[i+1:])
 		}
 	}
-	return opts.Parse(buf.String(), c.parseHooks())
+	if err := opts.Parse(buf.String(), c.parseHooks()); err != nil {
+		return err
+	}
+	if c.maxCacheSize != 0 && opts.CacheSize > c.maxCacheSize {
+		opts.CacheSize = c.maxCacheSize
+	}
+	return nil
 }
 
 func (c *replayConfig) cleanUp() error {
diff --git a/db.go b/db.go
index 03368cc7..a86c25d5 100644
--- a/db.go
+++ b/db.go
@@ -305,7 +305,7 @@ type DB struct {
 	fileLock *Lock
 	dataDir  vfs.File
 
-	fileCache            *fileCacheContainer
+	fileCache            *fileCacheHandle
 	newIters             tableNewIters
 	tableNewRangeKeyIter keyspanimpl.TableNewSpanIter
 
@@ -1670,7 +1670,6 @@ func (d *DB) Close() error {
 		err = errors.Errorf("pebble: %d unexpected in-progress compactions", errors.Safe(n))
 	}
 	err = firstError(err, d.mu.formatVers.marker.Close())
-	err = firstError(err, d.fileCache.close())
 	if !d.opts.ReadOnly {
 		if d.mu.log.writer != nil {
 			_, err2 := d.mu.log.writer.Close()
@@ -1760,6 +1759,8 @@ func (d *DB) Close() error {
 		err = firstError(err, errors.Errorf("leaked snapshots: %d open snapshots on DB %p", v, d))
 	}
 
+	err = firstError(err, d.fileCache.Close())
+
 	return err
 }
 
@@ -2048,7 +2049,7 @@ func (d *DB) Metrics() *Metrics {
 
 	metrics.BlockCache = d.opts.Cache.Metrics()
 	metrics.FileCache, metrics.Filter = d.fileCache.metrics()
-	metrics.TableIters = int64(d.fileCache.iterCount())
+	metrics.TableIters = d.fileCache.iterCount()
 	metrics.CategoryStats = d.fileCache.dbOpts.sstStatsCollector.GetStats()
 
 	metrics.SecondaryCacheMetrics = d.objProvider.Metrics()
diff --git a/file_cache.go b/file_cache.go
index e09bda2c..1c0db344 100644
--- a/file_cache.go
+++ b/file_cache.go
@@ -52,7 +52,7 @@ var emptyKeyspanIter = &errorKeyspanIter{err: nil}
 // On error, all iterators are nil.
 //
 // The only (non-test) implementation of tableNewIters is
-// fileCacheContainer.newIters().
+// fileCacheHandle.newIters().
 type tableNewIters func(
 	ctx context.Context,
 	file *manifest.FileMetadata,
@@ -86,7 +86,7 @@ func tableNewRangeKeyIter(newIters tableNewIters) keyspanimpl.TableNewSpanIter {
 }
 
 // fileCacheOpts contains the db specific fields of a file cache. This is stored
-// in the fileCacheContainer along with the file cache.
+// in the fileCacheHandle along with the file cache.
 //
 // NB: It is important to make sure that the fields in this struct are
 // read-only. Since the fields here are shared by every single fileCacheShard,
@@ -105,9 +105,8 @@ type fileCacheOpts struct {
 	sstStatsCollector *block.CategoryStatsCollector
 }
 
-// fileCacheContainer contains the file cache and fields which are unique to the
-// DB.
-type fileCacheContainer struct {
+// fileCacheHandle is used to access the file cache. Each DB has its own handle.
+type fileCacheHandle struct {
 	fileCache *FileCache
 
 	// dbOpts contains fields relevant to the file cache which are unique to
@@ -115,31 +114,26 @@ type fileCacheContainer struct {
 	dbOpts fileCacheOpts
 }
 
-// newFileCacheContainer will panic if the underlying block cache in the file
-// cache doesn't match Options.Cache.
-func newFileCacheContainer(
-	fc *FileCache,
-	cacheID cache.ID,
-	objProvider objstorage.Provider,
-	opts *Options,
-	size int,
-	sstStatsCollector *block.CategoryStatsCollector,
-) *fileCacheContainer {
+// 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.
+//
+// Each handle's cacheID must be unique.
+//
+// newHandle will panic if the underlying block cache in the file cache doesn't
+// match Options.Cache.
+func (c *FileCache) newHandle(
+	cacheID cache.ID, objProvider objstorage.Provider, opts *Options,
+) *fileCacheHandle {
 	// We will release a ref to the file cache acquired here when
-	// fileCacheContainer.close is called.
-	if fc != nil {
-		if fc.cache != opts.Cache {
-			panic("pebble: underlying cache for the file cache and db are different")
-		}
-		fc.Ref()
-	} else {
-		// NewFileCache should create a ref to fc which the container should
-		// drop whenever it is closed.
-		fc = NewFileCache(opts.Cache, opts.Experimental.FileCacheShards, size)
+	// fileCacheHandle.close is called.
+	if c.cache != opts.Cache {
+		panic("pebble: underlying cache for the file cache and db are different")
 	}
+	c.Ref()
 
-	t := &fileCacheContainer{}
-	t.fileCache = fc
+	t := &fileCacheHandle{}
+	t.fileCache = c
 	t.dbOpts.loggerAndTracer = opts.LoggerAndTracer
 	t.dbOpts.cache = opts.Cache
 	t.dbOpts.cacheID = cacheID
@@ -147,13 +141,13 @@ func newFileCacheContainer(
 	t.dbOpts.readerOpts = opts.MakeReaderOptions()
 	t.dbOpts.readerOpts.FilterMetricsTracker = &sstable.FilterMetricsTracker{}
 	t.dbOpts.iterCount = new(atomic.Int32)
-	t.dbOpts.sstStatsCollector = sstStatsCollector
+	t.dbOpts.sstStatsCollector = &block.CategoryStatsCollector{}
 	return t
 }
 
-// Before calling close, make sure that there will be no further need
-// to access any of the files associated with the store.
-func (c *fileCacheContainer) close() error {
+// Close the handle. Before calling, make sure that there will be no further
+// need to access any of the files associated with the store.
+func (c *fileCacheHandle) Close() error {
 	// We want to do some cleanup work here. Check for leaked iterators
 	// by the DB using this container. Note that we'll still perform cleanup
 	// below in the case that there are leaked iterators.
@@ -168,10 +162,12 @@ func (c *fileCacheContainer) close() error {
 			shard.removeDB(&c.dbOpts)
 		}
 	}
-	return firstError(err, c.fileCache.Unref())
+	err = firstError(err, c.fileCache.Unref())
+	*c = fileCacheHandle{}
+	return err
 }
 
-func (c *fileCacheContainer) newIters(
+func (c *fileCacheHandle) newIters(
 	ctx context.Context,
 	file *manifest.FileMetadata,
 	opts *IterOptions,
@@ -183,15 +179,15 @@ func (c *fileCacheContainer) newIters(
 
 // getTableProperties returns the properties associated with the backing physical
 // table if the input metadata belongs to a virtual sstable.
-func (c *fileCacheContainer) getTableProperties(file *fileMetadata) (*sstable.Properties, error) {
+func (c *fileCacheHandle) getTableProperties(file *fileMetadata) (*sstable.Properties, error) {
 	return c.fileCache.getShard(file.FileBacking.DiskFileNum).getTableProperties(file, &c.dbOpts)
 }
 
-func (c *fileCacheContainer) evict(fileNum base.DiskFileNum) {
+func (c *fileCacheHandle) evict(fileNum base.DiskFileNum) {
 	c.fileCache.getShard(fileNum).evict(fileNum, &c.dbOpts, false)
 }
 
-func (c *fileCacheContainer) metrics() (CacheMetrics, FilterMetrics) {
+func (c *fileCacheHandle) metrics() (CacheMetrics, FilterMetrics) {
 	var m CacheMetrics
 	for i := range c.fileCache.shards {
 		s := c.fileCache.shards[i]
@@ -206,7 +202,7 @@ func (c *fileCacheContainer) metrics() (CacheMetrics, FilterMetrics) {
 	return m, f
 }
 
-func (c *fileCacheContainer) estimateSize(
+func (c *fileCacheHandle) estimateSize(
 	meta *fileMetadata, lower, upper []byte,
 ) (size uint64, err error) {
 	c.withCommonReader(meta, func(cr sstable.CommonReader) error {
@@ -230,7 +226,7 @@ func createCommonReader(v *fileCacheValue, file *fileMetadata) sstable.CommonRea
 	return cr
 }
 
-func (c *fileCacheContainer) withCommonReader(
+func (c *fileCacheHandle) withCommonReader(
 	meta *fileMetadata, fn func(sstable.CommonReader) error,
 ) error {
 	s := c.fileCache.getShard(meta.FileBacking.DiskFileNum)
@@ -242,7 +238,7 @@ func (c *fileCacheContainer) withCommonReader(
 	return fn(createCommonReader(v, meta))
 }
 
-func (c *fileCacheContainer) withReader(meta physicalMeta, fn func(*sstable.Reader) error) error {
+func (c *fileCacheHandle) withReader(meta physicalMeta, fn func(*sstable.Reader) error) error {
 	s := c.fileCache.getShard(meta.FileBacking.DiskFileNum)
 	v := s.findNode(context.TODO(), meta.FileBacking, &c.dbOpts)
 	defer s.unrefValue(v)
@@ -253,7 +249,7 @@ func (c *fileCacheContainer) withReader(meta physicalMeta, fn func(*sstable.Read
 }
 
 // withVirtualReader fetches a VirtualReader associated with a virtual sstable.
-func (c *fileCacheContainer) withVirtualReader(
+func (c *fileCacheHandle) withVirtualReader(
 	meta virtualMeta, fn func(sstable.VirtualReader) error,
 ) error {
 	s := c.fileCache.getShard(meta.FileBacking.DiskFileNum)
@@ -270,7 +266,7 @@ func (c *fileCacheContainer) withVirtualReader(
 	return fn(sstable.MakeVirtualReader(v.mustSSTableReader(), meta.VirtualReaderParams(objMeta.IsShared())))
 }
 
-func (c *fileCacheContainer) iterCount() int64 {
+func (c *fileCacheHandle) iterCount() int64 {
 	return int64(c.dbOpts.iterCount.Load())
 }
 
@@ -922,7 +918,9 @@ func (c *fileCacheShard) findNodeInternal(
 		}
 		c.unrefValue(v)
 		c.iterCount.Add(-1)
-		dbOpts.iterCount.Add(-1)
+		if dbOpts.iterCount != nil {
+			dbOpts.iterCount.Add(-1)
+		}
 		return nil
 	}
 	n.value = v
diff --git a/iterator.go b/iterator.go
index 86d82ab9..230fb2c2 100644
--- a/iterator.go
+++ b/iterator.go
@@ -243,7 +243,7 @@ type Iterator struct {
 	// and SetOptions or when re-fragmenting a batch's range keys/range dels.
 	// Non-nil if this Iterator includes a Batch.
 	batch            *Batch
-	fc               *fileCacheContainer
+	fc               *fileCacheHandle
 	newIters         tableNewIters
 	newIterRangeKey  keyspanimpl.TableNewSpanIter
 	lazyCombinedIter lazyCombinedIter
diff --git a/metamorphic/options.go b/metamorphic/options.go
index b10e08a0..3b34aa9e 100644
--- a/metamorphic/options.go
+++ b/metamorphic/options.go
@@ -21,7 +21,6 @@ import (
 	"github.com/cockroachdb/pebble"
 	"github.com/cockroachdb/pebble/bloom"
 	"github.com/cockroachdb/pebble/internal/base"
-	"github.com/cockroachdb/pebble/internal/cache"
 	"github.com/cockroachdb/pebble/objstorage/remote"
 	"github.com/cockroachdb/pebble/sstable"
 	"github.com/cockroachdb/pebble/sstable/block"
@@ -48,7 +47,6 @@ func parseOptions(
 	opts *TestOptions, data string, customOptionParsers map[string]func(string) (CustomOption, bool),
 ) error {
 	hooks := &pebble.ParseHooks{
-		NewCache: pebble.NewCache,
 		NewComparer: func(name string) (*pebble.Comparer, error) {
 			for _, kf := range knownKeyFormats {
 				if kf.Comparer.Name == name {
@@ -671,8 +669,8 @@ func RandomOptions(
 		}
 	}
 
-	opts.BytesPerSync = 1 << uint(rng.IntN(28))     // 1B - 256MB
-	opts.Cache = cache.New(1 << uint(rng.IntN(30))) // 1B - 1GB
+	opts.BytesPerSync = 1 << uint(rng.IntN(28)) // 1B - 256MB
+	opts.CacheSize = 1 << uint(rng.IntN(30))    // 1B - 1GB
 	opts.DisableWAL = rng.IntN(2) == 0
 	opts.FlushDelayDeleteRange = time.Millisecond * time.Duration(5*rng.IntN(245)) // 5-250ms
 	opts.FlushDelayRangeKey = time.Millisecond * time.Duration(5*rng.IntN(245))    // 5-250ms
diff --git a/open.go b/open.go
index 04745917..ac999361 100644
--- a/open.go
+++ b/open.go
@@ -32,7 +32,6 @@ import (
 	"github.com/cockroachdb/pebble/objstorage/objstorageprovider"
 	"github.com/cockroachdb/pebble/objstorage/remote"
 	"github.com/cockroachdb/pebble/record"
-	"github.com/cockroachdb/pebble/sstable/block"
 	"github.com/cockroachdb/pebble/vfs"
 	"github.com/cockroachdb/pebble/wal"
 	"github.com/prometheus/client_golang/prometheus"
@@ -206,7 +205,7 @@ func Open(dirname string, opts *Options) (db *DB, err error) {
 	}
 
 	if opts.Cache == nil {
-		opts.Cache = cache.New(cacheDefaultSize)
+		opts.Cache = cache.New(opts.CacheSize)
 	} else {
 		opts.Cache.Ref()
 	}
@@ -247,7 +246,7 @@ func Open(dirname string, opts *Options) (db *DB, err error) {
 			opts.Cache.Unref()
 
 			if d.fileCache != nil {
-				_ = d.fileCache.close()
+				_ = d.fileCache.Close()
 			}
 
 			for _, mem := range d.mu.mem.queue {
@@ -409,9 +408,11 @@ func Open(dirname string, opts *Options) (db *DB, err error) {
 	}
 
 	fileCacheSize := FileCacheSize(opts.MaxOpenFiles)
-	d.fileCache = newFileCacheContainer(
-		opts.FileCache, d.cacheID, d.objProvider, d.opts, fileCacheSize,
-		&block.CategoryStatsCollector{})
+	if opts.FileCache == nil {
+		opts.FileCache = NewFileCache(opts.Cache, opts.Experimental.FileCacheShards, fileCacheSize)
+		defer opts.FileCache.Unref()
+	}
+	d.fileCache = opts.FileCache.newHandle(d.cacheID, d.objProvider, d.opts)
 	d.newIters = d.fileCache.newIters
 	d.tableNewRangeKeyIter = tableNewRangeKeyIter(d.newIters)
 
diff --git a/options.go b/options.go
index f5c8eb54..a2ca3efd 100644
--- a/options.go
+++ b/options.go
@@ -489,10 +489,11 @@ type Options struct {
 	// The default value is 512KB.
 	BytesPerSync int
 
-	// Cache is used to cache uncompressed blocks from sstables.
-	//
-	// The default cache size is 8 MB.
+	// Cache is used to cache uncompressed blocks from sstables. If it is nil,
+	// a block cache of CacheSize will be created for each DB.
 	Cache *cache.Cache
+	// CacheSize is used when Cache is not set. The default value is 8 MB.
+	CacheSize int64
 
 	// LoadBlockSema, if set, is used to limit the number of blocks that can be
 	// loaded (i.e. read from the filesystem) in parallel. Each load acquires one
@@ -1131,6 +1132,9 @@ func (o *Options) EnsureDefaults() *Options {
 	if o == nil {
 		o = &Options{}
 	}
+	if o.Cache == nil && o.CacheSize == 0 {
+		o.CacheSize = cacheDefaultSize
+	}
 	o.Comparer = o.Comparer.EnsureDefaults()
 
 	if o.BytesPerSync <= 0 {
@@ -1358,7 +1362,7 @@ func filterPolicyName(p FilterPolicy) string {
 func (o *Options) String() string {
 	var buf bytes.Buffer
 
-	cacheSize := int64(cacheDefaultSize)
+	cacheSize := o.CacheSize
 	if o.Cache != nil {
 		cacheSize = o.Cache.MaxSize()
 	}
@@ -1551,7 +1555,6 @@ func parseOptions(s string, fns parseOptionsFuncs) error {
 // ParseHooks contains callbacks to create options fields which can have
 // user-defined implementations.
 type ParseHooks struct {
-	NewCache        func(size int64) *Cache
 	NewCleaner      func(name string) (Cleaner, error)
 	NewComparer     func(name string) (*Comparer, error)
 	NewFilterPolicy func(name string) (FilterPolicy, error)
@@ -1603,16 +1606,7 @@ func (o *Options) Parse(s string, hooks *ParseHooks) error {
 			case "bytes_per_sync":
 				o.BytesPerSync, err = strconv.Atoi(value)
 			case "cache_size":
-				var n int64
-				n, err = strconv.ParseInt(value, 10, 64)
-				if err == nil && hooks != nil && hooks.NewCache != nil {
-					if o.Cache != nil {
-						o.Cache.Unref()
-					}
-					o.Cache = hooks.NewCache(n)
-				}
-				// We avoid calling cache.New in parsing because it makes it
-				// too easy to leak a cache.
+				o.CacheSize, err = strconv.ParseInt(value, 10, 64)
 			case "cleaner":
 				switch value {
 				case "archive":


Test patch:
diff --git a/cmd/pebble/replay_test.go b/cmd/pebble/replay_test.go
index b3f62255..5c4f729a 100644
--- a/cmd/pebble/replay_test.go
+++ b/cmd/pebble/replay_test.go
@@ -9,7 +9,6 @@ import (
 	"testing"
 
 	"github.com/cockroachdb/pebble"
-	"github.com/cockroachdb/pebble/internal/cache"
 	"github.com/stretchr/testify/require"
 )
 
@@ -30,14 +29,14 @@ func TestParseOptionsStr(t *testing.T) {
 		},
 		{
 			c:       replayConfig{optionsString: fmt.Sprintf(`[Options] cache_size=%d`, 16<<20 /* 16MB */)},
-			options: &pebble.Options{Cache: cache.New(16 << 20 /* 16 MB */)},
+			options: &pebble.Options{CacheSize: 16 * 1024 * 1024},
 		},
 		{
 			c: replayConfig{
 				maxCacheSize:  16 << 20, /* 16 MB */
 				optionsString: fmt.Sprintf(`[Options] cache_size=%d`, int64(10<<30 /* 10 GB */)),
 			},
-			options: &pebble.Options{Cache: cache.New(16 << 20 /* 16 MB */)},
+			options: &pebble.Options{CacheSize: 16 * 1024 * 1024},
 		},
 		{
 			c: replayConfig{optionsString: `[Options] [Level "0"] target_file_size=222`},
@@ -59,19 +58,18 @@ func TestParseOptionsStr(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		o := new(pebble.Options)
-		require.NoError(t, tc.c.parseCustomOptions(tc.c.optionsString, o))
-		o.EnsureDefaults()
-		got := o.String()
+		t.Run("", func(t *testing.T) {
+			o := new(pebble.Options)
+			require.NoError(t, tc.c.parseCustomOptions(tc.c.optionsString, o))
+			o.EnsureDefaults()
+			got := o.String()
 
-		tc.options.EnsureDefaults()
-		want := tc.options.String()
-		require.Equal(t, want, got)
-		if o.Cache != nil {
-			o.Cache.Unref()
-		}
-		if tc.options.Cache != nil {
-			tc.options.Cache.Unref()
-		}
+			tc.options.EnsureDefaults()
+			want := tc.options.String()
+			require.Equal(t, want, got)
+			if o.Cache != nil {
+				o.Cache.Unref()
+			}
+		})
 	}
 }
diff --git a/external_test.go b/external_test.go
index 3707ae15..03fcaebb 100644
--- a/external_test.go
+++ b/external_test.go
@@ -49,7 +49,6 @@ func TestIteratorErrors(t *testing.T) {
 		testOpts.Opts.FS = vfs.NewMem()
 	}
 
-	testOpts.Opts.Cache.Ref()
 	{
 		test, err := metamorphic.New(metamorphic.GenerateOps(
 			rng, 10000, kf, metamorphic.WriteOpConfig()),
diff --git a/file_cache_test.go b/file_cache_test.go
index 0e6672b7..9eda5954 100644
--- a/file_cache_test.go
+++ b/file_cache_test.go
@@ -12,6 +12,7 @@ import (
 	"math/rand/v2"
 	"os"
 	"path"
+	"runtime"
 	"strconv"
 	"strings"
 	"sync"
@@ -25,7 +26,6 @@ import (
 	"github.com/cockroachdb/pebble/objstorage"
 	"github.com/cockroachdb/pebble/objstorage/objstorageprovider"
 	"github.com/cockroachdb/pebble/sstable"
-	"github.com/cockroachdb/pebble/sstable/block"
 	"github.com/cockroachdb/pebble/vfs"
 	"github.com/stretchr/testify/require"
 )
@@ -74,14 +74,17 @@ func (fs *fileCacheTestFS) Open(name string, opts ...vfs.OpenOption) (vfs.File,
 	return &fileCacheTestFile{f, fs, name}, nil
 }
 
-func (fs *fileCacheTestFS) validate(
-	t *testing.T, c *fileCacheContainer, f func(i, gotO, gotC int) error,
+func (fs *fileCacheTestFS) validateAndCloseHandle(
+	t *testing.T, h *fileCacheHandle, f func(i, gotO, gotC int) error,
 ) {
 	if err := fs.validateOpenTables(f); err != nil {
 		t.Error(err)
 		return
 	}
-	c.close()
+	if err := h.Close(); err != nil {
+		t.Error(err)
+		return
+	}
 	if err := fs.validateNoneStillOpen(); err != nil {
 		t.Error(err)
 		return
@@ -149,42 +152,61 @@ const (
 	fileCacheTestCacheSize = 100
 )
 
+type fileCacheTest struct {
+	*testing.T
+	blockCache *Cache
+	fileCache  *FileCache
+}
+
 // newFileCacheTest returns a shareable file cache to be used for tests.
 // It is the caller's responsibility to unref the file cache.
-func newFileCacheTest(size int64, fileCacheSize int, numShards int) *FileCache {
-	cache := NewCache(size)
-	defer cache.Unref()
-	return NewFileCache(cache, numShards, fileCacheSize)
+func newFileCacheTest(
+	t *testing.T, blockCacheSize int64, fileCacheSize int, fileCacheNumShards int,
+) *fileCacheTest {
+	blockCache := NewCache(blockCacheSize)
+	fileCache := NewFileCache(blockCache, fileCacheNumShards, fileCacheSize)
+	return &fileCacheTest{
+		T:          t,
+		blockCache: blockCache,
+		fileCache:  fileCache,
+	}
 }
 
-func newFileCacheContainerTest(
-	tc *FileCache, dirname string,
-) (*fileCacheContainer, *fileCacheTestFS, error) {
+func (t *fileCacheTest) cleanup() {
+	if err := t.fileCache.Unref(); err != nil {
+		t.Error(err)
+	}
+	t.blockCache.Unref()
+}
+
+// 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) {
 	xxx := bytes.Repeat([]byte("x"), fileCacheTestNumTables)
 	fs := &fileCacheTestFS{
 		FS: vfs.NewMem(),
 	}
-	objProvider, err := objstorageprovider.Open(objstorageprovider.DefaultSettings(fs, dirname))
+	objProvider, err := objstorageprovider.Open(objstorageprovider.DefaultSettings(fs, ""))
 	if err != nil {
-		return nil, nil, err
+		t.Fatal(err)
 	}
 	defer objProvider.Close()
 
 	for i := 0; i < fileCacheTestNumTables; i++ {
 		w, _, err := objProvider.Create(context.Background(), fileTypeTable, base.DiskFileNum(i), objstorage.CreateOptions{})
 		if err != nil {
-			return nil, nil, errors.Wrap(err, "fs.Create")
+			t.Fatal(err)
 		}
 		tw := sstable.NewWriter(w, sstable.WriterOptions{TableFormat: sstable.TableFormatPebblev2})
 		ik := base.ParseInternalKey(fmt.Sprintf("k.SET.%d", i))
 		if err := tw.Raw().AddWithForceObsolete(ik, xxx[:i], false); err != nil {
-			return nil, nil, errors.Wrap(err, "tw.Set")
+			t.Fatal(err)
 		}
 		if err := tw.RangeKeySet([]byte("k"), []byte("l"), nil, xxx[:i]); err != nil {
-			return nil, nil, errors.Wrap(err, "tw.Set")
+			t.Fatal(err)
 		}
 		if err := tw.Close(); err != nil {
-			return nil, nil, errors.Wrap(err, "tw.Close")
+			t.Fatal(err)
 		}
 	}
 
@@ -193,64 +215,65 @@ func newFileCacheContainerTest(
 	fs.closeCounts = map[string]int{}
 	fs.mu.Unlock()
 
-	opts := &Options{}
-	opts.EnsureDefaults()
-	if tc == nil {
-		opts.Cache = NewCache(8 << 20) // 8 MB
-		defer opts.Cache.Unref()
-	} else {
-		opts.Cache = tc.cache
+	opts := &Options{
+		Cache:     t.blockCache,
+		FileCache: t.fileCache,
 	}
+	opts.EnsureDefaults()
 
-	c := newFileCacheContainer(tc, opts.Cache.NewID(), objProvider, opts, fileCacheTestCacheSize,
-		&block.CategoryStatsCollector{})
-	return c, fs, nil
+	h := t.fileCache.newHandle(t.blockCache.NewID(), objProvider, opts)
+	return h, fs
 }
 
 // Test basic reference counting for the file cache.
 func TestFileCacheRefs(t *testing.T) {
-	c := newFileCacheTest(8<<20, 10, 2)
+	fct := newFileCacheTest(t, 8<<20, 10, 2)
+	// We don't call the full fct.cleanup() method because we will unref the
+	// fileCache in the test.
+	defer fct.blockCache.Unref()
+	fc := fct.fileCache
 
-	v := c.refs.Load()
+	v := fc.refs.Load()
 	if v != 1 {
 		require.Equal(t, 1, v)
 	}
 
-	c.Ref()
-	v = c.refs.Load()
+	fc.Ref()
+	v = fc.refs.Load()
 	if v != 2 {
 		require.Equal(t, 2, v)
 	}
 
-	c.Unref()
-	v = c.refs.Load()
+	fc.Unref()
+	v = fc.refs.Load()
 	if v != 1 {
 		require.Equal(t, 1, v)
 	}
 
-	c.Unref()
-	v = c.refs.Load()
+	fc.Unref()
+	v = fc.refs.Load()
 	if v != 0 {
 		require.Equal(t, 0, v)
 	}
 
-	defer func() {
-		if r := recover(); r != nil {
-			if fmt.Sprint(r) != "pebble: inconsistent reference count: -1" {
-				t.Fatalf("unexpected panic message")
+	func() {
+		defer func() {
+			if r := recover(); r != nil {
+				if fmt.Sprint(r) != "pebble: inconsistent reference count: -1" {
+					t.Fatalf("unexpected panic message")
+				}
+			} else if r == nil {
+				t.Fatalf("expected panic")
 			}
-		} else if r == nil {
-			t.Fatalf("expected panic")
-		}
+		}()
+		fc.Unref()
 	}()
-	c.Unref()
+
 }
 
 // Basic test to determine if reads through the file cache are wired correctly.
 func TestVirtualReadsWiring(t *testing.T) {
-	var d *DB
-	var err error
-	d, err = Open("",
+	d, err := Open("",
 		&Options{
 			FS:                 vfs.NewMem(),
 			FormatMajorVersion: internalFormatNewest,
@@ -406,11 +429,16 @@ func TestVirtualReadsWiring(t *testing.T) {
 
 // The file cache shouldn't be usable after all the dbs close.
 func TestSharedFileCacheUseAfterAllFree(t *testing.T) {
-	fc := newFileCacheTest(8<<20, 10, 1)
+	fct := newFileCacheTest(t, 8<<20, 10, 1)
+	// We don't call the full fct.cleanup() method because we will unref the
+	// fileCache in the test.
+	defer fct.blockCache.Unref()
+	fc := fct.fileCache
+
 	db1, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     fc.cache,
+			Cache:     fct.blockCache,
 			FileCache: fc,
 		})
 	require.NoError(t, err)
@@ -421,7 +449,7 @@ func TestSharedFileCacheUseAfterAllFree(t *testing.T) {
 	db2, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     fc.cache,
+			Cache:     fct.blockCache,
 			FileCache: fc,
 		})
 	require.NoError(t, err)
@@ -435,13 +463,9 @@ func TestSharedFileCacheUseAfterAllFree(t *testing.T) {
 	}
 
 	defer func() {
-		// The cache ref gets incremented before the panic, so we should
-		// decrement it to prevent the finalizer from detecting a leak.
-		fc.cache.Unref()
-
 		if r := recover(); r != nil {
 			if fmt.Sprint(r) != "pebble: inconsistent reference count: 1" {
-				t.Fatalf("unexpected panic message")
+				t.Fatalf("unexpected panic message: %v", r)
 			}
 		} else if r == nil {
 			t.Fatalf("expected panic")
@@ -451,7 +475,7 @@ func TestSharedFileCacheUseAfterAllFree(t *testing.T) {
 	db3, _ := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     fc.cache,
+			Cache:     fct.blockCache,
 			FileCache: fc,
 		})
 	_ = db3
@@ -460,23 +484,26 @@ func TestSharedFileCacheUseAfterAllFree(t *testing.T) {
 // TestSharedFileCacheUseAfterOneFree tests whether a shared file cache is
 // usable by a db, after one of the db's releases its reference.
 func TestSharedFileCacheUseAfterOneFree(t *testing.T) {
-	tc := newFileCacheTest(8<<20, 10, 1)
+	fct := newFileCacheTest(t, 8<<20, 10, 1)
+	defer fct.cleanup()
+
+	if v := fct.fileCache.refs.Load(); v != 1 {
+		t.Fatalf("expected reference count %d, got %d", 1, v)
+	}
+
 	db1, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     tc.cache,
-			FileCache: tc,
+			Cache:     fct.blockCache,
+			FileCache: fct.fileCache,
 		})
 	require.NoError(t, err)
 
-	// Release our reference, now that the db has a reference.
-	tc.Unref()
-
 	db2, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     tc.cache,
-			FileCache: tc,
+			Cache:     fct.blockCache,
+			FileCache: fct.fileCache,
 		})
 	require.NoError(t, err)
 	defer func() {
@@ -486,8 +513,7 @@ func TestSharedFileCacheUseAfterOneFree(t *testing.T) {
 	// Make db1 release a reference to the cache. It should
 	// still be usable by db2.
 	require.NoError(t, db1.Close())
-	v := tc.refs.Load()
-	if v != 1 {
+	if v := fct.fileCache.refs.Load(); v != 2 {
 		t.Fatalf("expected reference count %d, got %d", 1, v)
 	}
 
@@ -503,18 +529,17 @@ func TestSharedFileCacheUseAfterOneFree(t *testing.T) {
 // TestSharedFileCacheUsable ensures that a shared file cache is usable by more
 // than one database at once.
 func TestSharedFileCacheUsable(t *testing.T) {
-	tc := newFileCacheTest(8<<20, 10, 1)
+	fct := newFileCacheTest(t, 8<<20, 10, 1)
+	defer fct.cleanup()
+
 	db1, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     tc.cache,
-			FileCache: tc,
+			Cache:     fct.blockCache,
+			FileCache: fct.fileCache,
 		})
 	require.NoError(t, err)
 
-	// Release our reference, now that the db has a reference.
-	tc.Unref()
-
 	defer func() {
 		require.NoError(t, db1.Close())
 	}()
@@ -522,8 +547,8 @@ func TestSharedFileCacheUsable(t *testing.T) {
 	db2, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     tc.cache,
-			FileCache: tc,
+			Cache:     fct.blockCache,
+			FileCache: fct.fileCache,
 		})
 	require.NoError(t, err)
 	defer func() {
@@ -548,18 +573,17 @@ func TestSharedFileCacheUsable(t *testing.T) {
 }
 
 func TestSharedTableConcurrent(t *testing.T) {
-	tc := newFileCacheTest(8<<20, 10, 1)
+	fct := newFileCacheTest(t, 8<<20, 10, 1)
+	defer fct.cleanup()
+
 	db1, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     tc.cache,
-			FileCache: tc,
+			Cache:     fct.blockCache,
+			FileCache: fct.fileCache,
 		})
 	require.NoError(t, err)
 
-	// Release our reference, now that the db has a reference.
-	tc.Unref()
-
 	defer func() {
 		require.NoError(t, db1.Close())
 	}()
@@ -567,8 +591,8 @@ func TestSharedTableConcurrent(t *testing.T) {
 	db2, err := Open("test",
 		&Options{
 			FS:        vfs.NewMem(),
-			Cache:     tc.cache,
-			FileCache: tc,
+			Cache:     fct.blockCache,
+			FileCache: fct.fileCache,
 		})
 	require.NoError(t, err)
 	defer func() {
@@ -600,8 +624,9 @@ func TestSharedTableConcurrent(t *testing.T) {
 
 func testFileCacheRandomAccess(t *testing.T, concurrent bool) {
 	const N = 2000
-	c, fs, err := newFileCacheContainerTest(nil, "")
-	require.NoError(t, err)
+	fct := newFileCacheTest(t, 8<<20, fileCacheTestCacheSize, runtime.GOMAXPROCS(0))
+	defer fct.cleanup()
+	h, fs := fct.newTestHandle()
 
 	rngMu := sync.Mutex{}
 	rng := rand.New(rand.NewPCG(1, 1))
@@ -616,7 +641,7 @@ func testFileCacheRandomAccess(t *testing.T, concurrent bool) {
 			m.InitPhysicalBacking()
 			m.FileBacking.Ref()
 			defer m.FileBacking.Unref()
-			iters, err := c.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+			iters, err := h.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 			if err != nil {
 				errc <- errors.Errorf("i=%d, fileNum=%d: find: %v", i, fileNum, err)
 				return
@@ -657,7 +682,7 @@ func testFileCacheRandomAccess(t *testing.T, concurrent bool) {
 			require.NoError(t, <-errc)
 		}
 	}
-	fs.validate(t, c, nil)
+	fs.validateAndCloseHandle(t, h, nil)
 }
 
 func TestFileCacheRandomAccessSequential(t *testing.T) { testFileCacheRandomAccess(t, false) }
@@ -669,8 +694,9 @@ func testFileCacheFrequentlyUsedInternal(t *testing.T, rangeIter bool) {
 		pinned0 = 7
 		pinned1 = 11
 	)
-	c, fs, err := newFileCacheContainerTest(nil, "")
-	require.NoError(t, err)
+	fct := newFileCacheTest(t, 8<<20, fileCacheTestCacheSize, runtime.GOMAXPROCS(0))
+	defer fct.cleanup()
+	h, fs := fct.newTestHandle()
 
 	for i := 0; i < N; i++ {
 		for _, j := range [...]int{pinned0, i % fileCacheTestNumTables, pinned1} {
@@ -680,9 +706,9 @@ func testFileCacheFrequentlyUsedInternal(t *testing.T, rangeIter bool) {
 			m.InitPhysicalBacking()
 			m.FileBacking.Ref()
 			if rangeIter {
-				iters, err = c.newIters(context.Background(), m, nil, internalIterOpts{}, iterRangeKeys)
+				iters, err = h.newIters(context.Background(), m, nil, internalIterOpts{}, iterRangeKeys)
 			} else {
-				iters, err = c.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+				iters, err = h.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 			}
 			if err != nil {
 				t.Fatalf("i=%d, j=%d: find: %v", i, j, err)
@@ -693,7 +719,7 @@ func testFileCacheFrequentlyUsedInternal(t *testing.T, rangeIter bool) {
 		}
 	}
 
-	fs.validate(t, c, func(i, gotO, gotC int) error {
+	fs.validateAndCloseHandle(t, h, func(i, gotO, gotC int) error {
 		if i == pinned0 || i == pinned1 {
 			if gotO != 1 || gotC != 0 {
 				return errors.Errorf("i=%d: pinned table: got %d, %d, want %d, %d", i, gotO, gotC, 1, 0)
@@ -717,23 +743,22 @@ func TestSharedFileCacheFrequentlyUsed(t *testing.T) {
 		pinned0 = 7
 		pinned1 = 11
 	)
-	tc := newFileCacheTest(8<<20, 2*fileCacheTestCacheSize, 16)
-	c1, fs1, err := newFileCacheContainerTest(tc, "")
-	require.NoError(t, err)
-	c2, fs2, err := newFileCacheContainerTest(tc, "")
-	require.NoError(t, err)
-	tc.Unref()
+	fct := newFileCacheTest(t, 8<<20, 2*fileCacheTestCacheSize, 16)
+	defer fct.cleanup()
+
+	h1, fs1 := fct.newTestHandle()
+	h2, fs2 := fct.newTestHandle()
 
 	for i := 0; i < N; i++ {
 		for _, j := range [...]int{pinned0, i % fileCacheTestNumTables, pinned1} {
 			m := &fileMetadata{FileNum: FileNum(j)}
 			m.InitPhysicalBacking()
 			m.FileBacking.Ref()
-			iters1, err := c1.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+			iters1, err := h1.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 			if err != nil {
 				t.Fatalf("i=%d, j=%d: find: %v", i, j, err)
 			}
-			iters2, err := c2.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+			iters2, err := h2.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 			if err != nil {
 				t.Fatalf("i=%d, j=%d: find: %v", i, j, err)
 			}
@@ -747,7 +772,7 @@ func TestSharedFileCacheFrequentlyUsed(t *testing.T) {
 		}
 	}
 
-	fs1.validate(t, c1, func(i, gotO, gotC int) error {
+	fs1.validateAndCloseHandle(t, h1, func(i, gotO, gotC int) error {
 		if i == pinned0 || i == pinned1 {
 			if gotO != 1 || gotC != 0 {
 				return errors.Errorf("i=%d: pinned table: got %d, %d, want %d, %d", i, gotO, gotC, 1, 0)
@@ -756,7 +781,7 @@ func TestSharedFileCacheFrequentlyUsed(t *testing.T) {
 		return nil
 	})
 
-	fs2.validate(t, c2, func(i, gotO, gotC int) error {
+	fs2.validateAndCloseHandle(t, h2, func(i, gotO, gotC int) error {
 		if i == pinned0 || i == pinned1 {
 			if gotO != 1 || gotC != 0 {
 				return errors.Errorf("i=%d: pinned table: got %d, %d, want %d, %d", i, gotO, gotC, 1, 0)
@@ -771,8 +796,9 @@ func testFileCacheEvictionsInternal(t *testing.T, rangeIter bool) {
 		N      = 1000
 		lo, hi = 10, 20
 	)
-	c, fs, err := newFileCacheContainerTest(nil, "")
-	require.NoError(t, err)
+	fct := newFileCacheTest(t, 8<<20, fileCacheTestCacheSize, runtime.GOMAXPROCS(0))
+	defer fct.cleanup()
+	h, fs := fct.newTestHandle()
 
 	rng := rand.New(rand.NewPCG(2, 2))
 	for i := 0; i < N; i++ {
@@ -783,9 +809,9 @@ func testFileCacheEvictionsInternal(t *testing.T, rangeIter bool) {
 		m.InitPhysicalBacking()
 		m.FileBacking.Ref()
 		if rangeIter {
-			iters, err = c.newIters(context.Background(), m, nil, internalIterOpts{}, iterRangeKeys)
+			iters, err = h.newIters(context.Background(), m, nil, internalIterOpts{}, iterRangeKeys)
 		} else {
-			iters, err = c.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+			iters, err = h.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 		}
 		if err != nil {
 			t.Fatalf("i=%d, j=%d: find: %v", i, j, err)
@@ -794,12 +820,12 @@ func testFileCacheEvictionsInternal(t *testing.T, rangeIter bool) {
 			t.Fatalf("i=%d, j=%d: close: %v", i, j, err)
 		}
 
-		c.evict(base.DiskFileNum(lo + rng.Uint64N(hi-lo)))
+		h.evict(base.DiskFileNum(lo + rng.Uint64N(hi-lo)))
 	}
 
 	sumEvicted, nEvicted := 0, 0
 	sumSafe, nSafe := 0, 0
-	fs.validate(t, c, func(i, gotO, gotC int) error {
+	fs.validateAndCloseHandle(t, h, func(i, gotO, gotC int) error {
 		if lo <= i && i < hi {
 			sumEvicted += gotO
 			nEvicted++
@@ -834,12 +860,11 @@ func TestSharedFileCacheEvictions(t *testing.T) {
 		N      = 1000
 		lo, hi = 10, 20
 	)
-	tc := newFileCacheTest(8<<20, 2*fileCacheTestCacheSize, 16)
-	c1, fs1, err := newFileCacheContainerTest(tc, "")
-	require.NoError(t, err)
-	c2, fs2, err := newFileCacheContainerTest(tc, "")
-	require.NoError(t, err)
-	tc.Unref()
+	fct := newFileCacheTest(t, 8<<20, 2*fileCacheTestCacheSize, 16)
+	defer fct.cleanup()
+
+	h1, fs1 := fct.newTestHandle()
+	h2, fs2 := fct.newTestHandle()
 
 	// TODO(radu): this test fails on most seeds.
 	rng := rand.New(rand.NewPCG(0, 0))
@@ -848,12 +873,12 @@ func TestSharedFileCacheEvictions(t *testing.T) {
 		m := &fileMetadata{FileNum: FileNum(j)}
 		m.InitPhysicalBacking()
 		m.FileBacking.Ref()
-		iters1, err := c1.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+		iters1, err := h1.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 		if err != nil {
 			t.Fatalf("i=%d, j=%d: find: %v", i, j, err)
 		}
 
-		iters2, err := c2.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+		iters2, err := h2.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 		if err != nil {
 			t.Fatalf("i=%d, j=%d: find: %v", i, j, err)
 		}
@@ -866,14 +891,14 @@ func TestSharedFileCacheEvictions(t *testing.T) {
 			t.Fatalf("i=%d, j=%d: close: %v", i, j, err)
 		}
 
-		c1.evict(base.DiskFileNum(lo + rng.Uint64N(hi-lo)))
-		c2.evict(base.DiskFileNum(lo + rng.Uint64N(hi-lo)))
+		h1.evict(base.DiskFileNum(lo + rng.Uint64N(hi-lo)))
+		h2.evict(base.DiskFileNum(lo + rng.Uint64N(hi-lo)))
 	}
 
-	check := func(fs *fileCacheTestFS, c *fileCacheContainer) (float64, float64, float64) {
+	check := func(fs *fileCacheTestFS, h *fileCacheHandle) (float64, float64, float64) {
 		sumEvicted, nEvicted := 0, 0
 		sumSafe, nSafe := 0, 0
-		fs.validate(t, c, func(i, gotO, gotC int) error {
+		fs.validateAndCloseHandle(t, h, func(i, gotO, gotC int) error {
 			if lo <= i && i < hi {
 				sumEvicted += gotO
 				nEvicted++
@@ -893,14 +918,14 @@ func TestSharedFileCacheEvictions(t *testing.T) {
 	// (lo, hi, fileCacheTestCacheSize, fileCacheTestNumTables) = (10, 20, 100, 300),
 	// the ratio seems to converge on roughly 1.5 for large N, compared to 1.0 if we do
 	// not evict any cache entries.
-	if fEvicted, fSafe, ratio := check(fs1, c1); ratio < 1.25 {
+	if fEvicted, fSafe, ratio := check(fs1, h1); ratio < 1.25 {
 		t.Errorf(
 			"evicted tables were opened %.3f times on average, safe tables %.3f, ratio %.3f < 1.250",
 			fEvicted, fSafe, ratio,
 		)
 	}
 
-	if fEvicted, fSafe, ratio := check(fs2, c2); ratio < 1.25 {
+	if fEvicted, fSafe, ratio := check(fs2, h2); ratio < 1.25 {
 		t.Errorf(
 			"evicted tables were opened %.3f times on average, safe tables %.3f, ratio %.3f < 1.250",
 			fEvicted, fSafe, ratio,
@@ -909,17 +934,18 @@ func TestSharedFileCacheEvictions(t *testing.T) {
 }
 
 func TestFileCacheIterLeak(t *testing.T) {
-	c, _, err := newFileCacheContainerTest(nil, "")
-	require.NoError(t, err)
+	fct := newFileCacheTest(t, 8<<20, fileCacheTestCacheSize, runtime.GOMAXPROCS(0))
+	defer fct.cleanup()
+	h, _ := fct.newTestHandle()
 
 	m := &fileMetadata{FileNum: 0}
 	m.InitPhysicalBacking()
 	m.FileBacking.Ref()
 	defer m.FileBacking.Unref()
-	iters, err := c.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+	iters, err := h.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 	require.NoError(t, err)
 
-	if err := c.close(); err == nil {
+	if err := h.Close(); err == nil {
 		t.Fatalf("expected failure, but found success")
 	} else if !strings.HasPrefix(err.Error(), "leaked iterators:") {
 		t.Fatalf("expected leaked iterators, but found %+v", err)
@@ -930,23 +956,23 @@ func TestFileCacheIterLeak(t *testing.T) {
 }
 
 func TestSharedFileCacheIterLeak(t *testing.T) {
-	tc := newFileCacheTest(8<<20, 2*fileCacheTestCacheSize, 16)
-	c1, _, err := newFileCacheContainerTest(tc, "")
-	require.NoError(t, err)
-	c2, _, err := newFileCacheContainerTest(tc, "")
-	require.NoError(t, err)
-	c3, _, err := newFileCacheContainerTest(tc, "")
-	require.NoError(t, err)
-	tc.Unref()
+	fct := newFileCacheTest(t, 8<<20, 2*fileCacheTestCacheSize, runtime.GOMAXPROCS(0))
+	// We don't call the full fct.cleanup() method because we will unref the
+	// fileCache in the test.
+	defer fct.blockCache.Unref()
+
+	h1, _ := fct.newTestHandle()
+	h2, _ := fct.newTestHandle()
+	h3, _ := fct.newTestHandle()
 
 	m := &fileMetadata{FileNum: 0}
 	m.InitPhysicalBacking()
 	m.FileBacking.Ref()
 	defer m.FileBacking.Unref()
-	iters, err := c1.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+	iters, err := h1.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 	require.NoError(t, err)
 
-	if err := c1.close(); err == nil {
+	if err := h1.Close(); err == nil {
 		t.Fatalf("expected failure, but found success")
 	} else if !strings.HasPrefix(err.Error(), "leaked iterators:") {
 		t.Fatalf("expected leaked iterators, but found %+v", err)
@@ -955,12 +981,14 @@ func TestSharedFileCacheIterLeak(t *testing.T) {
 	}
 
 	// Closing c2 shouldn't error out since c2 isn't leaking any iterators.
-	require.NoError(t, c2.close())
+	require.NoError(t, h2.Close())
+
+	fct.fileCache.Unref()
 
 	// Closing c3 should error out since c3 holds the last reference to the
 	// FileCache, and when the FileCache closes, it will detect that there was a
 	// leaked iterator.
-	if err := c3.close(); err == nil {
+	if err := h3.Close(); err == nil {
 		t.Fatalf("expected failure, but found success")
 	} else if !strings.HasPrefix(err.Error(), "leaked iterators:") {
 		t.Fatalf("expected leaked iterators, but found %+v", err)
@@ -973,24 +1001,26 @@ func TestSharedFileCacheIterLeak(t *testing.T) {
 
 func TestFileCacheRetryAfterFailure(t *testing.T) {
 	// Test a retry can succeed after a failure, i.e., errors are not cached.
-	c, fs, err := newFileCacheContainerTest(nil, "")
-	require.NoError(t, err)
+	fct := newFileCacheTest(t, 8<<20, fileCacheTestCacheSize, runtime.GOMAXPROCS(0))
+	defer fct.cleanup()
+	h, fs := fct.newTestHandle()
 
 	fs.setOpenError(true /* enabled */)
 	m := &fileMetadata{FileNum: 0}
 	m.InitPhysicalBacking()
 	m.FileBacking.Ref()
 	defer m.FileBacking.Unref()
-	if _, err = c.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys); err == nil {
+	_, err := h.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+	if err == nil {
 		t.Fatalf("expected failure, but found success")
 	}
 	require.Equal(t, "pebble: backing file 000000 error: injected error", err.Error())
 	fs.setOpenError(false /* enabled */)
 	var iters iterSet
-	iters, err = c.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
+	iters, err = h.newIters(context.Background(), m, nil, internalIterOpts{}, iterPointKeys)
 	require.NoError(t, err)
 	require.NoError(t, iters.Point().Close())
-	fs.validate(t, c, nil)
+	fs.validateAndCloseHandle(t, h, nil)
 }
 
 func TestFileCacheErrorBadMagicNumber(t *testing.T) {
@@ -1014,10 +1044,11 @@ func TestFileCacheErrorBadMagicNumber(t *testing.T) {
 	opts.EnsureDefaults()
 	opts.Cache = NewCache(8 << 20) // 8 MB
 	defer opts.Cache.Unref()
-	c := newFileCacheContainer(nil, opts.Cache.NewID(), objProvider, opts, fileCacheTestCacheSize,
-		&block.CategoryStatsCollector{})
+	opts.FileCache = NewFileCache(opts.Cache, opts.Experimental.FileCacheShards, fileCacheTestCacheSize)
+	defer opts.FileCache.Unref()
+	c := opts.FileCache.newHandle(opts.Cache.NewID(), objProvider, opts)
 	require.NoError(t, err)
-	defer c.close()
+	defer c.Close()
 
 	m := &fileMetadata{FileNum: testFileNum}
 	m.InitPhysicalBacking()
diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go
index 0ba7ad33..cbca5f80 100644
--- a/internal/manifest/manifest_test.go
+++ b/internal/manifest/manifest_test.go
@@ -32,8 +32,6 @@ func TestInuseKeyRangesRandomized(t *testing.T) {
 
 	// Generate a random database by running the metamorphic test.
 	testOpts := metamorphic.RandomOptions(rng, metamorphic.TestkeysKeyFormat, nil /* custom opt parsers */)
-	testOpts.Opts.Cache.Ref()
-	defer testOpts.Opts.Cache.Unref()
 	{
 		nOps := 10000
 		if buildtags.Race {
diff --git a/internal/manifest/testdata/version_edit_decode b/internal/manifest/testdata/version_edit_decode
new file mode 100644
index 00000000..ecf36ec1
--- /dev/null
+++ b/internal/manifest/testdata/version_edit_decode
@@ -0,0 +1,50 @@
+decode
+01                           # <tagComparator>
+1a                           # len("leveldb.BytewiseComparator") = 26
+"leveldb.BytewiseComparator" # Comparer name
+0302                         # <tagNextFileNumber>, 2
+0400                         # <tagLastSequence>, 0
+0202                         # <tagLogNumber>, 2
+0303                         # <tagNextFileNumber>, 3
+0409                         # <tagLastSequence>, 9
+----
+011a6c6576656c64622e4279746577697365436f6d70617261746f720302
+0400020203030409
+  comparer:     leveldb.BytewiseComparator  log-num:       2
+  next-file-num: 3
+  last-seq-num:  9
+
+decode
+01                           # <tagComparator>
+1a                           # len("leveldb.BytewiseComparator") = 26
+"leveldb.BytewiseComparator" # Comparer name
+0202                         # <tagLogNumber>, 2
+0307                         # <tagNextFileNumber>, 7
+0400                         # <tagLastSequence>, 0
+----
+011a6c6576656c64622e4279746577697365436f6d70617261746f720202
+03070400
+  comparer:     leveldb.BytewiseComparator  log-num:       2
+  next-file-num: 7
+
+decode
+0205                         # <tagLogNumber>, 5
+0306                         # <tagNextFileNumber>, 6
+040e                         # <tagLastSequence>, 14
+67                           # <tagNewFile4>
+00                           #   . Level           = L0
+04                           #   . FileNum         = 000004
+c505                         #   . FileSize        = 709
+0b 626172000e000000000000    #   . Smallest        = "bar"#14,DEL
+0b 666f6f010d000000000000    #   . Largest         = "foo"#13,SET
+0c                           #   . Smallest Seqnum = 12
+0e                           #   . Largest Seqnum  = 14
+06 05 84a6b8ab06             #   . <customTagCreationTime> <len(field)=5> <1701712644>
+01                           #   . <customTagTerminate>
+----
+02050306040e670004c5050b626172000e0000000000000b666f6f010d00
+00000000000c0e060584a6b8ab0601
+  log-num:       5
+  next-file-num: 6
+  last-seq-num:  14
+  add-table:     L0 000004:[bar#14,DEL-foo#13,SET] (2023-12-04T17:57:24Z)
diff --git a/internal/manifest/version_edit_test.go b/internal/manifest/version_edit_test.go
index 615f7a23..0799e96b 100644
--- a/internal/manifest/version_edit_test.go
+++ b/internal/manifest/version_edit_test.go
@@ -6,18 +6,19 @@ package manifest
 
 import (
 	"bytes"
+	"encoding/hex"
 	"fmt"
 	"io"
-	"os"
-	"reflect"
 	"slices"
+	"strconv"
 	"strings"
 	"testing"
+	"unicode"
 
+	"github.com/cockroachdb/crlib/crstrings"
 	"github.com/cockroachdb/datadriven"
 	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble/internal/base"
-	"github.com/cockroachdb/pebble/record"
 	"github.com/cockroachdb/pebble/sstable"
 	"github.com/kr/pretty"
 	"github.com/stretchr/testify/require"
@@ -261,123 +262,59 @@ func TestVersionEditRoundTrip(t *testing.T) {
 }
 
 func TestVersionEditDecode(t *testing.T) {
-	// TODO(radu): these should be datadriven tests that output the encoded and
-	// decoded edits.
-	cmp := base.DefaultComparer.Compare
-	m := (&FileMetadata{
-		FileNum:               4,
-		Size:                  709,
-		SmallestSeqNum:        12,
-		LargestSeqNum:         14,
-		LargestSeqNumAbsolute: 14,
-		CreationTime:          1701712644,
-	}).ExtendPointKeyBounds(
-		cmp,
-		base.MakeInternalKey([]byte("bar"), 14, base.InternalKeyKindDelete),
-		base.MakeInternalKey([]byte("foo"), 13, base.InternalKeyKindSet),
-	)
-	m.InitPhysicalBacking()
-
-	testCases := []struct {
-		filename     string
-		encodedEdits []string
-		edits        []VersionEdit
-	}{
-		// db-stage-1 and db-stage-2 have the same manifest.
-		{
-			filename: "db-stage-1/MANIFEST-000001",
-			encodedEdits: []string{
-				"\x01\x1aleveldb.BytewiseComparator\x03\x02\x04\x00",
-				"\x02\x02\x03\x03\x04\t",
-			},
-			edits: []VersionEdit{
-				{
-					ComparerName: "leveldb.BytewiseComparator",
-					NextFileNum:  2,
-				},
-				{
-					MinUnflushedLogNum: 0x2,
-					NextFileNum:        0x3,
-					LastSeqNum:         0x9,
-				},
-			},
-		},
-		// db-stage-3 and db-stage-4 have the same manifest.
-		{
-			filename: "db-stage-3/MANIFEST-000006",
-			encodedEdits: []string{
-				"\x01\x1aleveldb.BytewiseComparator\x02\x02\x03\a\x04\x00",
-				"\x02\x05\x03\x06\x04\x0eg\x00\x04\xc5\x05\vbar\x00\x0e\x00\x00\x00\x00\x00\x00\vfoo\x01\r\x00\x00\x00\x00\x00\x00\f\x0e\x06\x05\x84\xa6\xb8\xab\x06\x01",
-			},
-			edits: []VersionEdit{
-				{
-					ComparerName:       "leveldb.BytewiseComparator",
-					MinUnflushedLogNum: 0x2,
-					NextFileNum:        0x7,
-				},
-				{
-					MinUnflushedLogNum: 0x5,
-					NextFileNum:        0x6,
-					LastSeqNum:         0xe,
-					NewFiles: []NewFileEntry{
-						{
-							Level: 0,
-							Meta:  m,
-						},
-					},
-				},
-			},
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run("", func(t *testing.T) {
-			f, err := os.Open("../../testdata/" + tc.filename)
-			if err != nil {
-				t.Fatalf("filename=%q: open error: %v", tc.filename, err)
-			}
-			defer f.Close()
-			i, r := 0, record.NewReader(f, 0 /* logNum */)
-			for {
-				rr, err := r.Next()
-				if err == io.EOF {
-					break
-				}
-				if err != nil {
-					t.Fatalf("filename=%q i=%d: record reader error: %v", tc.filename, i, err)
-				}
-				if i >= len(tc.edits) {
-					t.Fatalf("filename=%q i=%d: too many version edits", tc.filename, i+1)
+	var inputBuf, outputBuf bytes.Buffer
+	datadriven.RunTest(t, "testdata/version_edit_decode",
+		func(t *testing.T, d *datadriven.TestData) string {
+			inputBuf.Reset()
+			outputBuf.Reset()
+			switch d.Cmd {
+			case "decode":
+				for _, l := range crstrings.Lines(d.Input) {
+					i := strings.IndexByte(l, '#')
+					if i == -1 {
+						i = len(l)
+					}
+					s := strings.Map(func(r rune) rune {
+						if unicode.IsSpace(r) {
+							return -1
+						}
+						return r
+					}, l[:i])
+					if strings.HasPrefix(s, "\"") {
+						unquoted, err := strconv.Unquote(s)
+						require.NoError(t, err)
+						io.WriteString(&inputBuf, unquoted)
+					} else {
+						b, err := hex.DecodeString(s)
+						require.NoError(t, err)
+						inputBuf.Write(b)
+					}
 				}
 
-				encodedEdit, err := io.ReadAll(rr)
-				if err != nil {
-					t.Fatalf("filename=%q i=%d: read error: %v", tc.filename, i, err)
-				}
-				if s := string(encodedEdit); s != tc.encodedEdits[i] {
-					t.Fatalf("filename=%q i=%d: got encoded %q, want %q", tc.filename, i, s, tc.encodedEdits[i])
+				r := bytes.NewReader(inputBuf.Bytes())
+				var ve VersionEdit
+				if err := ve.Decode(r); err != nil {
+					return fmt.Sprintf("err: %v", err)
 				}
 
-				var edit VersionEdit
-				err = edit.Decode(bytes.NewReader(encodedEdit))
-				if err != nil {
-					t.Fatalf("filename=%q i=%d: decode error: %v", tc.filename, i, err)
-				}
-				if !reflect.DeepEqual(edit, tc.edits[i]) {
-					t.Fatalf("filename=%q i=%d: decode\n\tgot  %#v\n\twant %#v\n%s", tc.filename, i, edit, tc.edits[i],
-						strings.Join(pretty.Diff(edit, tc.edits[i]), "\n"))
-				}
-				if err := checkRoundTrip(edit); err != nil {
-					t.Fatalf("filename=%q i=%d: round trip: %v", tc.filename, i, err)
+				// Ensure the version edit roundtrips.
+				if err := checkRoundTrip(ve); err != nil {
+					t.Fatal(err)
 				}
 
-				i++
-			}
-			if i != len(tc.edits) {
-				t.Fatalf("filename=%q: got %d edits, want %d", tc.filename, i, len(tc.edits))
+				serialized := inputBuf.Bytes()
+				for len(serialized) > 0 {
+					line := serialized[:min(30, len(serialized))]
+					serialized = serialized[len(line):]
+					outputBuf.WriteString(hex.EncodeToString(line))
+					outputBuf.WriteByte('\n')
+				}
+				fmt.Fprint(&outputBuf, ve.String())
+				return outputBuf.String()
+			default:
+				panic(fmt.Sprintf("unknown command: %s", d.Cmd))
 			}
 		})
-	}
 }
 
 func TestVersionEditEncodeLastSeqNum(t *testing.T) {
diff --git a/metamorphic/options_test.go b/metamorphic/options_test.go
index 827ca323..9b016cd4 100644
--- a/metamorphic/options_test.go
+++ b/metamorphic/options_test.go
@@ -93,21 +93,12 @@ func TestOptionsRoundtrip(t *testing.T) {
 		"Experimental.MultiLevelCompactionHeuristic.AddPropensity",
 	}
 
-	// Ensure that we unref any caches created, so invariants builds don't
-	// complain about the leaked ref counts.
-	maybeUnref := func(o *TestOptions) {
-		if o.Opts.Cache != nil {
-			o.Opts.Cache.Unref()
-		}
-	}
-
 	checkOptions := func(t *testing.T, o *TestOptions) {
 		s := optionsToString(o)
 		t.Logf("Serialized options:\n%s\n", s)
 
 		parsed := defaultTestOptions(TestkeysKeyFormat)
 		require.NoError(t, parseOptions(parsed, s, nil))
-		maybeUnref(parsed)
 		got := optionsToString(parsed)
 		require.Equal(t, s, got)
 		t.Logf("Re-serialized options:\n%s\n", got)
@@ -143,13 +134,12 @@ func TestOptionsRoundtrip(t *testing.T) {
 				cleaned = append(cleaned, d)
 			}
 		}
-		require.Equal(t, diff[:0], cleaned)
+		require.Equal(t, diff[:0], cleaned, "before:\n%#v\nafter:\n%#v\n", o.Opts, parsed.Opts)
 	}
 
 	standard := standardOptions(TestkeysKeyFormat)
 	for i := range standard {
 		t.Run(fmt.Sprintf("standard-%03d", i), func(t *testing.T) {
-			defer maybeUnref(standard[i])
 			checkOptions(t, standard[i])
 		})
 	}
@@ -157,7 +147,6 @@ func TestOptionsRoundtrip(t *testing.T) {
 	for i := 0; i < 100; i++ {
 		t.Run(fmt.Sprintf("random-%03d", i), func(t *testing.T) {
 			o := RandomOptions(rng, TestkeysKeyFormat, nil)
-			defer maybeUnref(o)
 			checkOptions(t, o)
 		})
 	}
@@ -234,7 +223,6 @@ func TestCustomOptionParser(t *testing.T) {
   foo=bar
 `, customOptionParsers))
 	require.NoError(t, parseOptions(o2, optionsToString(o1), customOptionParsers))
-	defer o2.Opts.Cache.Unref()
 
 	for _, o := range []*TestOptions{o1, o2} {
 		require.Equal(t, 1, len(o.CustomOpts))
diff --git a/metamorphic/test.go b/metamorphic/test.go
index 1ccf4bb4..da042f70 100644
--- a/metamorphic/test.go
+++ b/metamorphic/test.go
@@ -115,7 +115,9 @@ func (t *Test) init(
 
 	t.opsWaitOn, t.opsDone = computeSynchronizationPoints(t.ops)
 
-	defer t.opts.Cache.Unref()
+	if t.opts.Cache != nil {
+		defer t.opts.Cache.Unref()
+	}
 
 	// If an error occurs and we were using an in-memory FS, attempt to clone to
 	// on-disk in order to allow post-mortem debugging. Note that always using
@@ -285,7 +287,6 @@ func (t *Test) restartDB(dbID objID) error {
 	if len(t.dbs) > 1 {
 		return nil
 	}
-	t.opts.Cache.Ref()
 	fs := vfs.Root(t.opts.FS).(*vfs.MemFS)
 	crashFS := fs.CrashClone(vfs.CrashCloneCfg{UnsyncedDataPercent: 0})
 	if err := db.Close(); err != nil {
@@ -326,7 +327,6 @@ func (t *Test) restartDB(dbID objID) error {
 		}
 		return err
 	})
-	t.opts.Cache.Unref()
 	return err
 }
 
diff --git a/options_test.go b/options_test.go
index 3eaf4380..9e532e0d 100644
--- a/options_test.go
+++ b/options_test.go
@@ -240,13 +240,8 @@ func TestOptionsParse(t *testing.T) {
 	testComparer.Name = "test-comparer"
 	testMerger := *DefaultMerger
 	testMerger.Name = "test-merger"
-	var newCacheSize int64
 
 	hooks := &ParseHooks{
-		NewCache: func(size int64) *Cache {
-			newCacheSize = size
-			return nil
-		},
 		NewCleaner: func(name string) (Cleaner, error) {
 			if name == (testCleaner{}).String() {
 				return testCleaner{}, nil
@@ -306,7 +301,6 @@ func TestOptionsParse(t *testing.T) {
 			opts.EnsureDefaults()
 			str := opts.String()
 
-			newCacheSize = 0
 			var parsedOptions Options
 			require.NoError(t, parsedOptions.Parse(str, hooks))
 			parsedStr := parsedOptions.String()
@@ -314,7 +308,6 @@ func TestOptionsParse(t *testing.T) {
 				t.Fatalf("expected\n%s\nbut found\n%s", str, parsedStr)
 			}
 			require.Nil(t, parsedOptions.Cache)
-			require.NotEqual(t, newCacheSize, 0)
 		})
 	}
 }