Skip to content

Commit 7f188ab

Browse files
DracoLiyacovm
andauthored
feat(blockdb): add lru cache for block entries (#4425)
Co-authored-by: yacovm <[email protected]>
1 parent 29b4e6b commit 7f188ab

File tree

11 files changed

+337
-66
lines changed

11 files changed

+337
-66
lines changed

x/blockdb/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ BlockDB is a specialized database optimized for blockchain blocks.
1010
- **Configurable Durability**: Optional `syncToDisk` mode guarantees immediate recoverability
1111
- **Automatic Recovery**: Detects and recovers unindexed blocks after unclean shutdowns
1212
- **Block Compression**: zstd compression for block data
13+
- **In-Memory Cache**: LRU cache for recently accessed blocks
1314

1415
## Design
1516

@@ -167,7 +168,6 @@ if err != nil {
167168

168169
## TODO
169170

170-
- Implement a block cache for recently accessed blocks
171171
- Use a buffered pool to avoid allocations on reads and writes
172172
- Add performance benchmarks
173173
- Consider supporting missing data files (currently we error if any data files are missing)

x/blockdb/cache_db.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
2+
// See the file LICENSE for licensing terms.
3+
4+
package blockdb
5+
6+
import (
7+
"slices"
8+
"sync"
9+
10+
"go.uber.org/zap"
11+
12+
"github.com/ava-labs/avalanchego/cache/lru"
13+
"github.com/ava-labs/avalanchego/database"
14+
)
15+
16+
var _ database.HeightIndex = (*cacheDB)(nil)
17+
18+
// cacheDB caches data from the underlying [Database].
19+
//
20+
// Operations (Get, Has, Put) are not atomic with the underlying database.
21+
// Concurrent writes to the same height can result in cache inconsistencies where
22+
// the cache and database contain different values. This limitation is acceptable
23+
// because concurrent writes to the same height are not an intended use case.
24+
type cacheDB struct {
25+
db *Database
26+
cache *lru.Cache[BlockHeight, BlockData]
27+
28+
closeMu sync.RWMutex
29+
closed bool
30+
}
31+
32+
func newCacheDB(db *Database, size uint16) *cacheDB {
33+
return &cacheDB{
34+
db: db,
35+
cache: lru.NewCache[BlockHeight, BlockData](int(size)),
36+
}
37+
}
38+
39+
func (c *cacheDB) Get(height BlockHeight) (BlockData, error) {
40+
c.closeMu.RLock()
41+
defer c.closeMu.RUnlock()
42+
43+
if c.closed {
44+
c.db.log.Error("Failed Get: database closed", zap.Uint64("height", height))
45+
return nil, database.ErrClosed
46+
}
47+
48+
if cached, ok := c.cache.Get(height); ok {
49+
return slices.Clone(cached), nil
50+
}
51+
data, err := c.db.Get(height)
52+
if err != nil {
53+
return nil, err
54+
}
55+
c.cache.Put(height, slices.Clone(data))
56+
return data, nil
57+
}
58+
59+
func (c *cacheDB) Put(height BlockHeight, data BlockData) error {
60+
c.closeMu.RLock()
61+
defer c.closeMu.RUnlock()
62+
63+
if c.closed {
64+
c.db.log.Error("Failed Put: database closed", zap.Uint64("height", height))
65+
return database.ErrClosed
66+
}
67+
68+
if err := c.db.Put(height, data); err != nil {
69+
return err
70+
}
71+
72+
c.cache.Put(height, slices.Clone(data))
73+
return nil
74+
}
75+
76+
func (c *cacheDB) Has(height BlockHeight) (bool, error) {
77+
c.closeMu.RLock()
78+
defer c.closeMu.RUnlock()
79+
80+
if c.closed {
81+
c.db.log.Error("Failed Has: database closed", zap.Uint64("height", height))
82+
return false, database.ErrClosed
83+
}
84+
85+
if _, ok := c.cache.Get(height); ok {
86+
return true, nil
87+
}
88+
return c.db.Has(height)
89+
}
90+
91+
func (c *cacheDB) Close() error {
92+
c.closeMu.Lock()
93+
defer c.closeMu.Unlock()
94+
95+
if c.closed {
96+
return database.ErrClosed
97+
}
98+
c.closed = true
99+
c.cache.Flush()
100+
return c.db.Close()
101+
}

x/blockdb/cache_db_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
2+
// See the file LICENSE for licensing terms.
3+
4+
package blockdb
5+
6+
import (
7+
"slices"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/ava-labs/avalanchego/database"
13+
)
14+
15+
func TestCacheOnMiss(t *testing.T) {
16+
db := newCacheDatabase(t, DefaultConfig())
17+
height := uint64(20)
18+
block := randomBlock(t)
19+
require.NoError(t, db.Put(height, block))
20+
21+
// Evict the entry from cache to simulate a cache miss
22+
db.cache.Evict(height)
23+
24+
// Read the block - should populate the cache on cache miss
25+
_, err := db.Get(height)
26+
require.NoError(t, err)
27+
28+
_, ok := db.cache.Get(height)
29+
require.True(t, ok)
30+
}
31+
32+
func TestCacheGet(t *testing.T) {
33+
db := newCacheDatabase(t, DefaultConfig())
34+
height := uint64(30)
35+
block := randomBlock(t)
36+
37+
// Populate cache directly without writing to database
38+
db.cache.Put(height, block)
39+
40+
// Get should return the block from cache
41+
data, err := db.Get(height)
42+
require.NoError(t, err)
43+
require.Equal(t, block, data)
44+
}
45+
46+
func TestCacheHas(t *testing.T) {
47+
db := newCacheDatabase(t, DefaultConfig())
48+
height := uint64(40)
49+
block := randomBlock(t)
50+
51+
// Populate cache directly without writing to database
52+
db.cache.Put(height, block)
53+
54+
// Has should return true from cache even though block is not in database
55+
has, err := db.Has(height)
56+
require.NoError(t, err)
57+
require.True(t, has)
58+
}
59+
60+
func TestCachePutStoresClone(t *testing.T) {
61+
db := newCacheDatabase(t, DefaultConfig())
62+
height := uint64(40)
63+
block := randomBlock(t)
64+
clone := slices.Clone(block)
65+
require.NoError(t, db.Put(height, clone))
66+
67+
// Modify the original block after Put
68+
clone[0] = 99
69+
70+
// Cache should have the original unmodified data
71+
cached, ok := db.cache.Get(height)
72+
require.True(t, ok)
73+
require.Equal(t, block, cached)
74+
}
75+
76+
func TestCacheGetReturnsClone(t *testing.T) {
77+
db := newCacheDatabase(t, DefaultConfig())
78+
height := uint64(50)
79+
block := randomBlock(t)
80+
require.NoError(t, db.Put(height, block))
81+
82+
// Get the block and modify the returned data
83+
data, err := db.Get(height)
84+
require.NoError(t, err)
85+
data[0] = 99
86+
87+
// Cache should still have the original unmodified data
88+
cached, ok := db.cache.Get(height)
89+
require.True(t, ok)
90+
require.Equal(t, block, cached)
91+
92+
// Second Get should also return original data
93+
data, err = db.Get(height)
94+
require.NoError(t, err)
95+
require.Equal(t, block, data)
96+
}
97+
98+
func TestCachePutOverridesSameHeight(t *testing.T) {
99+
db := newCacheDatabase(t, DefaultConfig())
100+
height := uint64(60)
101+
b1 := randomBlock(t)
102+
require.NoError(t, db.Put(height, b1))
103+
104+
// Verify first block is in cache
105+
cached, ok := db.cache.Get(height)
106+
require.True(t, ok)
107+
require.Equal(t, b1, cached)
108+
109+
// Put second block at same height and verify it overrides the first one
110+
b2 := randomBlock(t)
111+
require.NoError(t, db.Put(height, b2))
112+
cached, ok = db.cache.Get(height)
113+
require.True(t, ok)
114+
require.Equal(t, b2, cached)
115+
116+
// Get should also return the new block
117+
data, err := db.Get(height)
118+
require.NoError(t, err)
119+
require.Equal(t, b2, data)
120+
}
121+
122+
func TestCacheClose(t *testing.T) {
123+
db := newCacheDatabase(t, DefaultConfig())
124+
height := uint64(70)
125+
block := randomBlock(t)
126+
require.NoError(t, db.Put(height, block))
127+
128+
_, ok := db.cache.Get(height)
129+
require.True(t, ok)
130+
require.NoError(t, db.Close())
131+
132+
// cache is flushed
133+
require.Zero(t, db.cache.Len())
134+
135+
// db operations now fails
136+
_, err := db.Get(height)
137+
require.ErrorIs(t, err, database.ErrClosed)
138+
_, err = db.Has(height)
139+
require.ErrorIs(t, err, database.ErrClosed)
140+
err = db.Put(height+1, block)
141+
require.ErrorIs(t, err, database.ErrClosed)
142+
err = db.Close()
143+
require.ErrorIs(t, err, database.ErrClosed)
144+
}

x/blockdb/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ const DefaultMaxDataFileSize = 500 * 1024 * 1024 * 1024
1111
// DefaultMaxDataFiles is the default maximum number of data files descriptors cached.
1212
const DefaultMaxDataFiles = 10
1313

14+
// DefaultBlockCacheSize is the default size of the block cache.
15+
const DefaultBlockCacheSize uint16 = 256
16+
1417
// DatabaseConfig contains configuration parameters for BlockDB.
1518
type DatabaseConfig struct {
1619
// IndexDir is the directory where the index file is stored.
@@ -28,6 +31,9 @@ type DatabaseConfig struct {
2831
// MaxDataFiles is the maximum number of data files descriptors cached.
2932
MaxDataFiles int
3033

34+
// BlockCacheSize is the size of the block cache (default: 256).
35+
BlockCacheSize uint16
36+
3137
// CheckpointInterval defines how frequently (in blocks) the index file header is updated (default: 1024).
3238
CheckpointInterval uint64
3339

@@ -43,6 +49,7 @@ func DefaultConfig() DatabaseConfig {
4349
MinimumHeight: 0,
4450
MaxDataFileSize: DefaultMaxDataFileSize,
4551
MaxDataFiles: DefaultMaxDataFiles,
52+
BlockCacheSize: DefaultBlockCacheSize,
4653
CheckpointInterval: 1024,
4754
SyncToDisk: true,
4855
}
@@ -91,6 +98,12 @@ func (c DatabaseConfig) WithMaxDataFiles(maxFiles int) DatabaseConfig {
9198
return c
9299
}
93100

101+
// WithBlockCacheSize returns a copy of the config with BlockCacheSize set to the given value.
102+
func (c DatabaseConfig) WithBlockCacheSize(size uint16) DatabaseConfig {
103+
c.BlockCacheSize = size
104+
return c
105+
}
106+
94107
// WithCheckpointInterval returns a copy of the config with CheckpointInterval set to the given value.
95108
func (c DatabaseConfig) WithCheckpointInterval(interval uint64) DatabaseConfig {
96109
c.CheckpointInterval = interval

x/blockdb/database.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ type Database struct {
197197
// Parameters:
198198
// - config: Configuration parameters
199199
// - log: Logger instance for structured logging
200-
func New(config DatabaseConfig, log logging.Logger) (*Database, error) {
200+
func New(config DatabaseConfig, log logging.Logger) (database.HeightIndex, error) {
201201
if err := config.Validate(); err != nil {
202202
return nil, err
203203
}
@@ -231,6 +231,7 @@ func New(config DatabaseConfig, log logging.Logger) (*Database, error) {
231231
zap.String("dataDir", config.DataDir),
232232
zap.Uint64("maxDataFileSize", config.MaxDataFileSize),
233233
zap.Int("maxDataFiles", config.MaxDataFiles),
234+
zap.Uint16("blockCacheSize", config.BlockCacheSize),
234235
)
235236

236237
if err := s.openAndInitializeIndex(); err != nil {
@@ -256,6 +257,9 @@ func New(config DatabaseConfig, log logging.Logger) (*Database, error) {
256257
zap.Uint64("maxBlockHeight", maxHeight),
257258
)
258259

260+
if config.BlockCacheSize > 0 {
261+
return newCacheDB(s, config.BlockCacheSize), nil
262+
}
259263
return s, nil
260264
}
261265

@@ -286,9 +290,7 @@ func (s *Database) Put(height BlockHeight, block BlockData) error {
286290
defer s.closeMu.RUnlock()
287291

288292
if s.closed {
289-
s.log.Error("Failed to write block: database is closed",
290-
zap.Uint64("height", height),
291-
)
293+
s.log.Error("Failed Put: database closed", zap.Uint64("height", height))
292294
return database.ErrClosed
293295
}
294296

@@ -385,12 +387,6 @@ func (s *Database) Put(height BlockHeight, block BlockData) error {
385387
// It returns database.ErrNotFound if the block does not exist.
386388
func (s *Database) readBlockIndex(height BlockHeight) (indexEntry, error) {
387389
var entry indexEntry
388-
if s.closed {
389-
s.log.Error("Failed to read block index: database is closed",
390-
zap.Uint64("height", height),
391-
)
392-
return entry, database.ErrClosed
393-
}
394390

395391
// Skip the index entry read if we know the block is past the max height.
396392
maxHeight := s.maxBlockHeight.Load()
@@ -436,6 +432,11 @@ func (s *Database) Get(height BlockHeight) (BlockData, error) {
436432
s.closeMu.RLock()
437433
defer s.closeMu.RUnlock()
438434

435+
if s.closed {
436+
s.log.Error("Failed Get: database closed", zap.Uint64("height", height))
437+
return nil, database.ErrClosed
438+
}
439+
439440
indexEntry, err := s.readBlockIndex(height)
440441
if err != nil {
441442
return nil, err
@@ -494,6 +495,15 @@ func (s *Database) Has(height BlockHeight) (bool, error) {
494495
s.closeMu.RLock()
495496
defer s.closeMu.RUnlock()
496497

498+
if s.closed {
499+
s.log.Error("Failed Has: database closed", zap.Uint64("height", height))
500+
return false, database.ErrClosed
501+
}
502+
503+
return s.hasWithoutLock(height)
504+
}
505+
506+
func (s *Database) hasWithoutLock(height BlockHeight) (bool, error) {
497507
_, err := s.readBlockIndex(height)
498508
if err != nil {
499509
if errors.Is(err, database.ErrNotFound) || errors.Is(err, ErrInvalidBlockHeight) {

0 commit comments

Comments
 (0)