mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
427 lines
13 KiB
Go
427 lines
13 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package blockdb
|
|
|
|
import (
|
|
"encoding"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"unsafe"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/luxfi/compress"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/node/cache/lru"
|
|
)
|
|
|
|
func TestNew_Params(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
tests := []struct {
|
|
name string
|
|
config DatabaseConfig
|
|
wantErr error
|
|
expectClose bool
|
|
}{
|
|
{
|
|
name: "default config",
|
|
config: DefaultConfig().WithDir(tempDir),
|
|
},
|
|
{
|
|
name: "custom config",
|
|
config: DefaultConfig().WithDir(tempDir).
|
|
WithMinimumHeight(100).
|
|
WithMaxDataFileSize(1024 * 1024). // 1MB
|
|
WithMaxDataFiles(50).
|
|
WithCheckpointInterval(512),
|
|
},
|
|
{
|
|
name: "empty index directory",
|
|
config: DefaultConfig().WithDataDir(tempDir),
|
|
wantErr: errors.New("IndexDir must be provided"),
|
|
},
|
|
{
|
|
name: "empty data directory",
|
|
config: DefaultConfig().WithIndexDir(tempDir),
|
|
wantErr: errors.New("DataDir must be provided"),
|
|
},
|
|
{
|
|
name: "different index and data directories",
|
|
config: DefaultConfig().WithIndexDir(filepath.Join(tempDir, "index")).WithDataDir(filepath.Join(tempDir, "data")),
|
|
},
|
|
{
|
|
name: "invalid config - zero checkpoint interval",
|
|
config: DefaultConfig().WithDir(tempDir).WithCheckpointInterval(0),
|
|
wantErr: errors.New("CheckpointInterval cannot be 0"),
|
|
},
|
|
{
|
|
name: "invalid config - zero max data files",
|
|
config: DefaultConfig().WithDir(tempDir).WithMaxDataFiles(0),
|
|
wantErr: errors.New("MaxDataFiles must be positive"),
|
|
},
|
|
{
|
|
name: "invalid config - negative max data files",
|
|
config: DefaultConfig().WithDir(tempDir).WithMaxDataFiles(-1),
|
|
wantErr: errors.New("MaxDataFiles must be positive"),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db, err := New(tt.config, nil)
|
|
|
|
if tt.wantErr != nil {
|
|
require.Equal(t, tt.wantErr.Error(), err.Error())
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, db)
|
|
|
|
// Verify the database was created with correct configuration
|
|
require.Equal(t, tt.config.MinimumHeight, db.config.MinimumHeight)
|
|
require.Equal(t, tt.config.MaxDataFileSize, db.config.MaxDataFileSize)
|
|
require.Equal(t, tt.config.MaxDataFiles, db.config.MaxDataFiles)
|
|
require.Equal(t, tt.config.CheckpointInterval, db.config.CheckpointInterval)
|
|
require.Equal(t, tt.config.SyncToDisk, db.config.SyncToDisk)
|
|
indexPath := filepath.Join(tt.config.IndexDir, indexFileName)
|
|
require.FileExists(t, indexPath)
|
|
|
|
// Test that we can close the database
|
|
require.NoError(t, db.Close())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNew_IndexFileErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func() (string, string)
|
|
wantErrMsg string
|
|
}{
|
|
{
|
|
name: "corrupted index file",
|
|
setup: func() (string, string) {
|
|
tempDir := t.TempDir()
|
|
indexDir := filepath.Join(tempDir, "index")
|
|
dataDir := filepath.Join(tempDir, "data")
|
|
require.NoError(t, os.MkdirAll(indexDir, 0o755))
|
|
require.NoError(t, os.MkdirAll(dataDir, 0o755))
|
|
|
|
// Create a corrupted index file
|
|
indexPath := filepath.Join(indexDir, indexFileName)
|
|
corruptedData := []byte("corrupted index file data")
|
|
require.NoError(t, os.WriteFile(indexPath, corruptedData, defaultFilePermissions))
|
|
|
|
return indexDir, dataDir
|
|
},
|
|
wantErrMsg: "failed to read index header",
|
|
},
|
|
{
|
|
name: "version mismatch in existing index file",
|
|
setup: func() (string, string) {
|
|
tempDir := t.TempDir()
|
|
indexDir := filepath.Join(tempDir, "index")
|
|
dataDir := filepath.Join(tempDir, "data")
|
|
|
|
// Create directories
|
|
require.NoError(t, os.MkdirAll(indexDir, 0o755))
|
|
require.NoError(t, os.MkdirAll(dataDir, 0o755))
|
|
|
|
// Create a valid index file with wrong version
|
|
indexPath := filepath.Join(indexDir, indexFileName)
|
|
header := indexFileHeader{
|
|
Version: 999, // Wrong version
|
|
MinHeight: 0,
|
|
MaxDataFileSize: DefaultMaxDataFileSize,
|
|
MaxHeight: unsetHeight,
|
|
NextWriteOffset: 0,
|
|
}
|
|
|
|
headerBytes, err := header.MarshalBinary()
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(indexPath, headerBytes, defaultFilePermissions))
|
|
|
|
return indexDir, dataDir
|
|
},
|
|
wantErrMsg: "mismatched index file version",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
indexDir, dataDir := tt.setup()
|
|
if indexDir == "" || dataDir == "" {
|
|
t.Skip("Setup failed, skipping test")
|
|
}
|
|
|
|
config := DefaultConfig().WithIndexDir(indexDir).WithDataDir(dataDir)
|
|
_, err := New(config, log.NoLog{})
|
|
require.Contains(t, err.Error(), tt.wantErrMsg)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIndexFileHeaderAlignment(t *testing.T) {
|
|
require.Equal(t, uint64(0), sizeOfIndexFileHeader%sizeOfIndexEntry,
|
|
"sizeOfIndexFileHeader (%d) is not a multiple of sizeOfIndexEntry (%d)",
|
|
sizeOfIndexFileHeader, sizeOfIndexEntry)
|
|
}
|
|
|
|
func TestIndexEntrySizePowerOfTwo(t *testing.T) {
|
|
// Check that sizeOfIndexEntry is a power of 2
|
|
// This is important for memory alignment and performance
|
|
require.Equal(t, uint64(0), sizeOfIndexEntry&(sizeOfIndexEntry-1),
|
|
"sizeOfIndexEntry (%d) is not a power of 2", sizeOfIndexEntry)
|
|
}
|
|
|
|
func TestNew_IndexFileConfigPrecedence(t *testing.T) {
|
|
// set up db
|
|
tempDir := t.TempDir()
|
|
initialConfig := DefaultConfig().WithDir(tempDir).WithMinimumHeight(100).WithMaxDataFileSize(1024 * 1024)
|
|
db, err := New(initialConfig, log.NoLog{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, db)
|
|
|
|
// Write a block at height 100 and close db
|
|
testBlock := []byte("test block data")
|
|
require.NoError(t, db.WriteBlock(100, testBlock))
|
|
readBlock, err := db.ReadBlock(100)
|
|
require.NoError(t, err)
|
|
require.Equal(t, testBlock, readBlock)
|
|
require.NoError(t, db.Close())
|
|
|
|
// Reopen with different config that has minimum height of 200 and smaller max data file size
|
|
differentConfig := DefaultConfig().WithDir(tempDir).WithMinimumHeight(200).WithMaxDataFileSize(512 * 1024)
|
|
db2, err := New(differentConfig, log.NoLog{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, db2)
|
|
defer db2.Close()
|
|
|
|
// The database should still accept blocks between 100 and 200
|
|
testBlock2 := []byte("test block data 2")
|
|
require.NoError(t, db2.WriteBlock(150, testBlock2))
|
|
readBlock2, err := db2.ReadBlock(150)
|
|
require.NoError(t, err)
|
|
require.Equal(t, testBlock2, readBlock2)
|
|
|
|
// Verify that writing below initial minimum height fails
|
|
err = db2.WriteBlock(50, []byte("invalid block"))
|
|
require.ErrorIs(t, err, ErrInvalidBlockHeight)
|
|
|
|
// Write a large block that would exceed the new config's 512KB limit
|
|
// but should succeed because we use the original 1MB limit from index file
|
|
largeBlock := make([]byte, 768*1024) // 768KB block
|
|
require.NoError(t, db2.WriteBlock(200, largeBlock))
|
|
readLargeBlock, err := db2.ReadBlock(200)
|
|
require.NoError(t, err)
|
|
require.Equal(t, largeBlock, readLargeBlock)
|
|
}
|
|
|
|
func TestFileCache_Eviction(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config DatabaseConfig
|
|
}{
|
|
{
|
|
name: "data file eviction",
|
|
config: DefaultConfig().WithMaxDataFileSize(3),
|
|
},
|
|
// Test a condition where the data file can be closed between getting the file
|
|
// handler and writing/reading from it. This can happen when another process
|
|
// evicts the file handler between the get and write/read.
|
|
// To trigger this in the test, we will create a cache with a size of 1 and
|
|
// small max data file size to force multiple data files.
|
|
// When trying to write or read a block, all other file handlers will be evicted.
|
|
{
|
|
name: "retry opening data file if it's evicted",
|
|
config: DefaultConfig().WithMaxDataFiles(1),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
store, cleanup := newTestDatabase(t, tt.config.WithMaxDataFileSize(1024*1.5))
|
|
store.compressor = compress.NewNoCompressor()
|
|
defer cleanup()
|
|
|
|
// Override the file cache with specified size
|
|
evictionCount := atomic.Int32{}
|
|
evictionMu := sync.Mutex{}
|
|
smallCache := lru.NewCacheWithOnEvict(tt.config.MaxDataFiles, func(_ int, file *os.File) {
|
|
evictionMu.Lock()
|
|
defer evictionMu.Unlock()
|
|
evictionCount.Add(1)
|
|
if file != nil {
|
|
file.Close()
|
|
}
|
|
})
|
|
store.fileCache = smallCache
|
|
|
|
const numGoroutines = 4
|
|
var wg sync.WaitGroup
|
|
var writeErrors atomic.Int32
|
|
|
|
// Thread-safe error message collection
|
|
var errorMu sync.Mutex
|
|
var errorMessages []string
|
|
|
|
// Create blocks of 0.5kb each
|
|
const numBlocks = 20 // 20 blocks will create 10 files
|
|
blocks := make([][]byte, numBlocks)
|
|
for i := range blocks {
|
|
blocks[i] = fixedSizeBlock(t, 512, uint64(i))
|
|
}
|
|
|
|
// each goroutine writes all blocks
|
|
for g := range numGoroutines {
|
|
wg.Add(1)
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
for i := range numBlocks {
|
|
height := uint64((i + goroutineID) % numBlocks)
|
|
err := store.WriteBlock(height, blocks[height])
|
|
if err != nil {
|
|
writeErrors.Add(1)
|
|
errorMu.Lock()
|
|
errorMessages = append(errorMessages, fmt.Sprintf("goroutine %d, height %d: %v", goroutineID, height, err))
|
|
errorMu.Unlock()
|
|
return
|
|
}
|
|
}
|
|
}(g)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Build error message if there were errors
|
|
var errorMsg string
|
|
if writeErrors.Load() > 0 {
|
|
errorMsg = fmt.Sprintf("concurrent writes had %d errors:\n", writeErrors.Load())
|
|
for _, msg := range errorMessages {
|
|
errorMsg += fmt.Sprintf(" %s\n", msg)
|
|
}
|
|
}
|
|
|
|
// Verify no write errors and we have evictions
|
|
require.Zero(t, writeErrors.Load(), errorMsg)
|
|
require.Positive(t, evictionCount.Load(), "should have had some cache evictions")
|
|
|
|
// Verify again that all blocks are readable
|
|
for i := range numBlocks {
|
|
block, err := store.ReadBlock(uint64(i))
|
|
require.NoError(t, err, "failed to read block at height %d", i)
|
|
require.Equal(t, blocks[i], block, "block data mismatch at height %d", i)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaxDataFiles_CacheLimit(t *testing.T) {
|
|
// Test that the file cache respects the MaxDataFiles limit
|
|
// Create a small cache size to test eviction behavior
|
|
config := DefaultConfig().
|
|
WithMaxDataFiles(2). // Only allow 2 files in cache
|
|
WithMaxDataFileSize(1024) // Small file size to force multiple files
|
|
|
|
store, cleanup := newTestDatabase(t, config)
|
|
defer cleanup()
|
|
|
|
// Create blocks that will span multiple data files
|
|
// Each block is ~512 bytes, so 2 blocks per file
|
|
numBlocks := 6 // This will create 3 files, more than our cache limit of 2
|
|
// Write blocks to force multiple data files
|
|
for i := range numBlocks {
|
|
block := fixedSizeBlock(t, 512, uint64(i))
|
|
require.NoError(t, store.WriteBlock(uint64(i), block))
|
|
}
|
|
|
|
// Verify all blocks are still readable despite evictions
|
|
for i := range numBlocks {
|
|
block, err := store.ReadBlock(uint64(i))
|
|
require.NoError(t, err, "failed to read block at height %d after eviction", i)
|
|
require.Len(t, block, 512, "block size mismatch at height %d", i)
|
|
}
|
|
}
|
|
|
|
// TestStructSizes verifies that our critical data structures have the expected sizes
|
|
func TestStructSizes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
memorySize uintptr
|
|
binarySize int
|
|
expectedMemorySize uintptr
|
|
expectedBinarySize int
|
|
expectedMarshalSize int
|
|
expectedPadding uintptr
|
|
createInstance func() interface{}
|
|
}{
|
|
{
|
|
name: "indexFileHeader",
|
|
memorySize: unsafe.Sizeof(indexFileHeader{}),
|
|
binarySize: binary.Size(indexFileHeader{}),
|
|
expectedMemorySize: 64,
|
|
expectedBinarySize: 64,
|
|
expectedMarshalSize: 64,
|
|
expectedPadding: 0,
|
|
createInstance: func() interface{} { return indexFileHeader{} },
|
|
},
|
|
{
|
|
name: "blockEntryHeader",
|
|
memorySize: unsafe.Sizeof(blockEntryHeader{}),
|
|
binarySize: binary.Size(blockEntryHeader{}),
|
|
expectedMemorySize: 32,
|
|
expectedBinarySize: 22,
|
|
expectedMarshalSize: 22,
|
|
expectedPadding: 10,
|
|
createInstance: func() interface{} { return blockEntryHeader{} },
|
|
},
|
|
{
|
|
name: "indexEntry",
|
|
memorySize: unsafe.Sizeof(indexEntry{}),
|
|
binarySize: binary.Size(indexEntry{}),
|
|
expectedMemorySize: 16,
|
|
expectedBinarySize: 16,
|
|
expectedMarshalSize: 16,
|
|
expectedPadding: 0,
|
|
createInstance: func() interface{} { return indexEntry{} },
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
actualMemorySize := tt.memorySize
|
|
require.Equal(t, tt.expectedMemorySize, actualMemorySize,
|
|
"%s has unexpected memory size: got %d bytes, expected %d bytes",
|
|
tt.name, actualMemorySize, tt.expectedMemorySize)
|
|
|
|
binarySize := tt.binarySize
|
|
require.Equal(t, tt.expectedBinarySize, binarySize,
|
|
"%s binary size should be compact: got %d bytes, expected %d bytes",
|
|
tt.name, binarySize, tt.expectedBinarySize)
|
|
|
|
instance := tt.createInstance()
|
|
var data []byte
|
|
var err error
|
|
|
|
data, err = instance.(encoding.BinaryMarshaler).MarshalBinary()
|
|
require.NoError(t, err, "%s MarshalBinary should not fail", tt.name)
|
|
require.Len(t, data, tt.expectedMarshalSize,
|
|
"%s MarshalBinary should produce exactly %d bytes, got %d bytes",
|
|
tt.name, tt.expectedMarshalSize, len(data))
|
|
|
|
padding := actualMemorySize - uintptr(binarySize)
|
|
require.Equal(t, tt.expectedPadding, padding,
|
|
"%s should have %d bytes of padding: memory=%d, binary=%d",
|
|
tt.name, tt.expectedPadding, actualMemorySize, binarySize)
|
|
})
|
|
}
|
|
}
|