Skip to content

Commit af83e7d

Browse files
committed
feat(blockdb): add lru cache for block entries
1 parent 578d0a9 commit af83e7d

File tree

3 files changed

+114
-7
lines changed

3 files changed

+114
-7
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/database.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const (
4141

4242
// BlockEntryVersion is the version of the block entry.
4343
BlockEntryVersion uint16 = 1
44+
45+
// defaultEntryCacheSize is the default number of entries to cache in memory
46+
defaultEntryCacheSize = 256
4447
)
4548

4649
// BlockHeight defines the type for block heights.
@@ -176,6 +179,7 @@ type Database struct {
176179
log logging.Logger
177180
closed bool
178181
fileCache *lru.Cache[int, *os.File]
182+
entryCache *lru.Cache[BlockHeight, BlockData]
179183
compressor compression.Compressor
180184

181185
// closeMu prevents the database from being closed while in use and prevents
@@ -223,6 +227,7 @@ func New(config DatabaseConfig, log logging.Logger) (*Database, error) {
223227
f.Close()
224228
}
225229
}),
230+
entryCache: lru.NewCache[BlockHeight, BlockData](defaultEntryCacheSize),
226231
compressor: compressor,
227232
}
228233

@@ -372,6 +377,11 @@ func (s *Database) Put(height BlockHeight, block BlockData) error {
372377
return err
373378
}
374379

380+
// Store a copy in cache to prevent external modifications from affecting the cached data
381+
blockCopy := make([]byte, len(block))
382+
copy(blockCopy, block)
383+
s.entryCache.Put(height, blockCopy)
384+
375385
s.log.Debug("Block written successfully",
376386
zap.Uint64("height", height),
377387
zap.Uint32("blockSize", blockDataLen),
@@ -385,12 +395,6 @@ func (s *Database) Put(height BlockHeight, block BlockData) error {
385395
// It returns database.ErrNotFound if the block does not exist.
386396
func (s *Database) readBlockIndex(height BlockHeight) (indexEntry, error) {
387397
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-
}
394398

395399
// Skip the index entry read if we know the block is past the max height.
396400
maxHeight := s.maxBlockHeight.Load()
@@ -436,6 +440,20 @@ func (s *Database) Get(height BlockHeight) (BlockData, error) {
436440
s.closeMu.RLock()
437441
defer s.closeMu.RUnlock()
438442

443+
if s.closed {
444+
s.log.Error("Failed to read block: database is closed",
445+
zap.Uint64("height", height),
446+
)
447+
return nil, database.ErrClosed
448+
}
449+
450+
if cachedData, ok := s.entryCache.Get(height); ok {
451+
// Return a copy to prevent external modifications from affecting the cache
452+
dataCopy := make([]byte, len(cachedData))
453+
copy(dataCopy, cachedData)
454+
return dataCopy, nil
455+
}
456+
439457
indexEntry, err := s.readBlockIndex(height)
440458
if err != nil {
441459
return nil, err
@@ -486,6 +504,10 @@ func (s *Database) Get(height BlockHeight) (BlockData, error) {
486504
return nil, fmt.Errorf("checksum mismatch: calculated %d, stored %d", calculatedChecksum, bh.Checksum)
487505
}
488506

507+
cacheCopy := make([]byte, len(decompressed))
508+
copy(cacheCopy, decompressed)
509+
s.entryCache.Put(height, cacheCopy)
510+
489511
return decompressed, nil
490512
}
491513

@@ -494,6 +516,14 @@ func (s *Database) Has(height BlockHeight) (bool, error) {
494516
s.closeMu.RLock()
495517
defer s.closeMu.RUnlock()
496518

519+
if s.closed {
520+
return false, database.ErrClosed
521+
}
522+
523+
if _, ok := s.entryCache.Get(height); ok {
524+
return true, nil
525+
}
526+
497527
_, err := s.readBlockIndex(height)
498528
if err != nil {
499529
if errors.Is(err, database.ErrNotFound) || errors.Is(err, ErrInvalidBlockHeight) {

x/blockdb/database_cache_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestEntryCache(t *testing.T) {
13+
store, cleanup := newTestDatabase(t, DefaultConfig())
14+
defer cleanup()
15+
16+
height := uint64(10)
17+
expectedBlock := randomBlock(t)
18+
19+
// Write the block - this should populate the cache
20+
require.NoError(t, store.Put(height, expectedBlock))
21+
22+
// First read should come from cache
23+
block, err := store.Get(height)
24+
require.NoError(t, err)
25+
require.Equal(t, expectedBlock, block)
26+
27+
// Close all data files to ensure the next read cannot come from disk
28+
store.fileCache.Flush()
29+
30+
// Read again - should still succeed because it comes from cache
31+
cachedBlock, err := store.Get(height)
32+
require.NoError(t, err)
33+
require.Equal(t, expectedBlock, cachedBlock, "block should be retrieved from cache after files are closed")
34+
}
35+
36+
func TestEntryCache_GetPopulatesCache(t *testing.T) {
37+
store, cleanup := newTestDatabase(t, DefaultConfig())
38+
defer cleanup()
39+
40+
height := uint64(20)
41+
expectedBlock := randomBlock(t)
42+
43+
// Write the block
44+
require.NoError(t, store.Put(height, expectedBlock))
45+
46+
// Evict the entry from cache to simulate a cache miss
47+
store.entryCache.Evict(height)
48+
49+
// Read the block - this should read from disk and populate the cache
50+
block, err := store.Get(height)
51+
require.NoError(t, err)
52+
require.Equal(t, expectedBlock, block)
53+
54+
// Verify it's now in cache
55+
cachedBlock, ok := store.entryCache.Get(height)
56+
require.True(t, ok, "entry should be in cache after Get")
57+
require.Equal(t, expectedBlock, cachedBlock)
58+
}
59+
60+
func TestEntryCacheHas(t *testing.T) {
61+
store, cleanup := newTestDatabase(t, DefaultConfig())
62+
defer cleanup()
63+
64+
height := uint64(30)
65+
expectedBlock := randomBlock(t)
66+
67+
// Write the block - this populates the cache
68+
require.NoError(t, store.Put(height, expectedBlock))
69+
70+
// Close all data files to ensure Has cannot read from disk
71+
store.fileCache.Flush()
72+
73+
// Has should still return true because it checks cache first
74+
has, err := store.Has(height)
75+
require.NoError(t, err)
76+
require.True(t, has, "Has should return true when block is in cache")
77+
}

0 commit comments

Comments
 (0)