mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
The module already called itself github.com/luxfi/node while containing no node in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and the Go column of the matrix measured a VM plugin. A repo that fetches the thing it claims to be has not replaced it, so the Go sources move in — 1515 files, the daemon and everything under it — and the build reaches outside this module for nothing. Rust and C++ sit beside it unchanged and are still measured against it by the differential. That is the point of one repo: the reference implementation and the two ports are the same checkout, so parity is a thing you can run rather than a thing you assert. Verified: builds, and the closure carries no lux-private — checked at build time rather than believed. The daemon starts twelve chains on a local network (P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
336 lines
8.6 KiB
Go
336 lines
8.6 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package blockdb
|
|
|
|
import (
|
|
"math"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/luxfi/compress"
|
|
|
|
safemath "github.com/luxfi/math"
|
|
)
|
|
|
|
func TestWriteBlock_Basic(t *testing.T) {
|
|
customConfig := DefaultConfig().WithMinimumHeight(10)
|
|
|
|
tests := []struct {
|
|
name string
|
|
blockHeights []uint64 // block heights to write, in order
|
|
config DatabaseConfig
|
|
expectedMCH uint64 // expected max contiguous height
|
|
expectedMaxHeight uint64
|
|
syncToDisk bool
|
|
checkpointInterval uint64
|
|
}{
|
|
{
|
|
name: "no blocks to write",
|
|
expectedMCH: unsetHeight,
|
|
expectedMaxHeight: unsetHeight,
|
|
},
|
|
{
|
|
name: "single block at min height",
|
|
blockHeights: []uint64{0},
|
|
expectedMCH: 0,
|
|
expectedMaxHeight: 0,
|
|
},
|
|
{
|
|
name: "sequential blocks from min",
|
|
blockHeights: []uint64{0, 1, 2, 3},
|
|
expectedMCH: 3,
|
|
expectedMaxHeight: 3,
|
|
},
|
|
{
|
|
name: "out of order with no gaps",
|
|
blockHeights: []uint64{3, 1, 2, 0, 4},
|
|
expectedMCH: 4,
|
|
expectedMaxHeight: 4,
|
|
},
|
|
{
|
|
name: "blocks with gaps",
|
|
blockHeights: []uint64{0, 1, 3, 5, 6},
|
|
expectedMCH: 1,
|
|
expectedMaxHeight: 6,
|
|
},
|
|
{
|
|
name: "start with gap",
|
|
blockHeights: []uint64{5, 6},
|
|
expectedMCH: unsetHeight,
|
|
expectedMaxHeight: 6,
|
|
},
|
|
{
|
|
name: "overwrite same height",
|
|
blockHeights: []uint64{0, 1, 0}, // Write to height 0 twice
|
|
expectedMCH: 1,
|
|
expectedMaxHeight: 1,
|
|
},
|
|
{
|
|
name: "custom min height single block",
|
|
blockHeights: []uint64{10},
|
|
config: customConfig,
|
|
expectedMCH: 10,
|
|
expectedMaxHeight: 10,
|
|
},
|
|
{
|
|
name: "custom min height out of order",
|
|
blockHeights: []uint64{13, 11, 10, 12},
|
|
config: customConfig,
|
|
expectedMCH: 13,
|
|
expectedMaxHeight: 13,
|
|
},
|
|
{
|
|
name: "custom min height with gaps",
|
|
blockHeights: []uint64{10, 11, 13, 15},
|
|
config: customConfig,
|
|
expectedMCH: 11,
|
|
expectedMaxHeight: 15,
|
|
},
|
|
{
|
|
name: "custom min height start with gap",
|
|
blockHeights: []uint64{11, 12},
|
|
config: customConfig,
|
|
expectedMCH: unsetHeight,
|
|
expectedMaxHeight: 12,
|
|
},
|
|
{
|
|
name: "with sync to disk",
|
|
blockHeights: []uint64{0, 1, 2, 5},
|
|
syncToDisk: true,
|
|
expectedMCH: 2,
|
|
expectedMaxHeight: 5,
|
|
},
|
|
{
|
|
name: "custom checkpoint interval",
|
|
blockHeights: []uint64{0, 1, 2, 3, 4},
|
|
checkpointInterval: 2,
|
|
expectedMCH: 4,
|
|
expectedMaxHeight: 4,
|
|
},
|
|
{
|
|
name: "complicated gaps",
|
|
blockHeights: []uint64{
|
|
10, 3, 2, 9, 35, 34, 30, 1, 9, 88, 83, 4, 43, 5, 0,
|
|
},
|
|
expectedMCH: 5,
|
|
expectedMaxHeight: 88,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := tt.config
|
|
if config.CheckpointInterval == 0 {
|
|
config = DefaultConfig()
|
|
}
|
|
|
|
store, cleanup := newTestDatabase(t, config)
|
|
defer cleanup()
|
|
|
|
blocksWritten := make(map[uint64][]byte)
|
|
for _, h := range tt.blockHeights {
|
|
block := randomBlock(t)
|
|
err := store.WriteBlock(h, block)
|
|
require.NoError(t, err, "unexpected error at height %d", h)
|
|
|
|
blocksWritten[h] = block
|
|
}
|
|
|
|
// Verify all written blocks are readable and data is correct
|
|
for h, expectedBlock := range blocksWritten {
|
|
readBlock, err := store.ReadBlock(h)
|
|
require.NoError(t, err, "ReadBlock failed at height %d", h)
|
|
require.Equal(t, expectedBlock, readBlock)
|
|
}
|
|
|
|
checkDatabaseState(t, store, tt.expectedMaxHeight, tt.expectedMCH)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteBlock_Concurrency(t *testing.T) {
|
|
store, cleanup := newTestDatabase(t, DefaultConfig())
|
|
defer cleanup()
|
|
|
|
var wg sync.WaitGroup
|
|
var errors atomic.Int32
|
|
|
|
// Pre-generate blocks for reuse
|
|
blocks := make([][]byte, 20)
|
|
for i := range 20 {
|
|
blocks[i] = randomBlock(t)
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
var height uint64
|
|
block := blocks[i]
|
|
|
|
// create gaps at heights 5 and 10 and rewrite last block
|
|
if i == 5 || i == 10 {
|
|
height = uint64(i - 1)
|
|
block = blocks[i-1]
|
|
} else {
|
|
height = uint64(i)
|
|
}
|
|
|
|
err := store.WriteBlock(height, block)
|
|
if err != nil {
|
|
errors.Add(1)
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
require.Zero(t, errors.Load(), "concurrent writes had errors")
|
|
|
|
// Verify that all expected heights have blocks (except 5, 10)
|
|
for i := range 20 {
|
|
height := uint64(i)
|
|
block, err := store.ReadBlock(height)
|
|
if i == 5 || i == 10 {
|
|
require.ErrorIs(t, err, ErrBlockNotFound, "expected ErrBlockNotFound at gap height %d", height)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, blocks[i], block, "block mismatch at height %d", height)
|
|
}
|
|
}
|
|
checkDatabaseState(t, store, 19, 4)
|
|
}
|
|
|
|
func TestWriteBlock_Errors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
height uint64
|
|
block []byte
|
|
setup func(db *Database)
|
|
config DatabaseConfig
|
|
disableCompression bool
|
|
wantErr error
|
|
wantErrMsg string
|
|
// needsUnprivileged marks a case whose failure is provoked by FILE
|
|
// PERMISSIONS. root ignores the permission bits, so the operation
|
|
// succeeds there and the case cannot prove anything.
|
|
needsUnprivileged bool
|
|
}{
|
|
{
|
|
name: "empty block nil",
|
|
height: 0,
|
|
block: nil,
|
|
wantErr: ErrBlockEmpty,
|
|
},
|
|
{
|
|
name: "empty block zero length",
|
|
height: 0,
|
|
block: []byte{},
|
|
wantErr: ErrBlockEmpty,
|
|
},
|
|
{
|
|
name: "height below custom minimum",
|
|
height: 5,
|
|
block: randomBlock(t),
|
|
config: DefaultConfig().WithMinimumHeight(10),
|
|
wantErr: ErrInvalidBlockHeight,
|
|
},
|
|
{
|
|
name: "height causes overflow",
|
|
height: math.MaxUint64,
|
|
block: randomBlock(t),
|
|
wantErr: ErrInvalidBlockHeight,
|
|
},
|
|
{
|
|
name: "database closed",
|
|
height: 0,
|
|
block: randomBlock(t),
|
|
setup: func(db *Database) {
|
|
db.Close()
|
|
},
|
|
wantErr: ErrDatabaseClosed,
|
|
},
|
|
{
|
|
name: "exceed max data file size",
|
|
height: 0,
|
|
disableCompression: true,
|
|
block: make([]byte, 1003), // Block + header will exceed 1024 limit (1003 + 26 = 1029 > 1024)
|
|
config: DefaultConfig().WithMaxDataFileSize(1024),
|
|
wantErr: ErrBlockTooLarge,
|
|
},
|
|
{
|
|
name: "data file offset overflow",
|
|
height: 0,
|
|
block: make([]byte, 100),
|
|
disableCompression: true,
|
|
config: DefaultConfig(),
|
|
setup: func(db *Database) {
|
|
// Set the next write offset to near max to trigger overflow
|
|
db.nextDataWriteOffset.Store(math.MaxUint64 - 50)
|
|
},
|
|
wantErr: safemath.ErrOverflow,
|
|
},
|
|
{
|
|
name: "writeBlockAt - failed to get data file",
|
|
height: 0,
|
|
block: make([]byte, 100),
|
|
setup: func(db *Database) {
|
|
// Change file permissions to read-only
|
|
file, err := db.getOrOpenDataFile(0)
|
|
require.NoError(t, err)
|
|
filePath := file.Name()
|
|
file.Close()
|
|
require.NoError(t, os.Chmod(filePath, 0o444))
|
|
},
|
|
wantErrMsg: "failed to get data file for writing block",
|
|
needsUnprivileged: true,
|
|
},
|
|
{
|
|
name: "writeIndexEntryAt - index file write failure",
|
|
height: 0,
|
|
block: make([]byte, 100),
|
|
setup: func(db *Database) {
|
|
db.indexFile.Close()
|
|
},
|
|
wantErrMsg: "failed to write index entry",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := tt.config
|
|
if config.CheckpointInterval == 0 {
|
|
config = DefaultConfig()
|
|
}
|
|
|
|
if tt.needsUnprivileged && os.Geteuid() == 0 {
|
|
t.Skip("runs as root, which bypasses the permission bits this case relies on")
|
|
}
|
|
|
|
store, cleanup := newTestDatabase(t, config)
|
|
if tt.disableCompression {
|
|
store.compressor = compress.NewNoCompressor()
|
|
}
|
|
defer cleanup()
|
|
|
|
if tt.setup != nil {
|
|
tt.setup(store)
|
|
}
|
|
|
|
err := store.WriteBlock(tt.height, tt.block)
|
|
if tt.wantErrMsg != "" {
|
|
// require.Error FIRST: err.Error() on a nil err panics, and a
|
|
// panic in a subtest aborts the entire package -- which is how
|
|
// one wrong case took down all of x/blockdb and hid its own name.
|
|
require.Error(t, err, "expected an error starting with %s", tt.wantErrMsg)
|
|
require.True(t, strings.HasPrefix(err.Error(), tt.wantErrMsg), "expected error message to start with %s, got %s", tt.wantErrMsg, err.Error())
|
|
} else {
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
}
|
|
checkDatabaseState(t, store, unsetHeight, unsetHeight)
|
|
})
|
|
}
|
|
}
|