mirror of
https://github.com/luxfi/geth.git
synced 2026-10-30 15:05:39 +00:00
Implement ethdb.ResettableAncientStore over a single KV backend (the
canonical luxfi/zapdb). Replaces the upstream geth freezer
(.cdat/.ridx files per table) with one ZAP database per chain. Snappy
compression per-block (~10x cold data); single-byte kind markers keep
prefix scans efficient.
Key layout (single ZAP database per chain):
['a'][kind byte][big-endian uint64 number] -> snappy value
['m']['h'][kind byte] -> head pointer
['m']['t'][kind byte] -> tail pointer
ZAP is canonical Lux storage (luxfi/zap + luxfi/zapdb). PebbleDB stays
only as legacy in-place upgrade. New deployments use ZAP everywhere.
Surface:
- core/rawdb/zap_ancient.go (ZapAncientStore implementation)
- core/rawdb/zap_ancient_migration.go (exported wrappers for migrate-ancient)
- core/rawdb/zap_ancient_rlp.go (rlp.EncodeToBytes split out)
- core/rawdb/zap_ancient_test.go (7 tests: roundtrip, out-of-order,
range maxBytes, truncate head/tail,
snappy, persist-across-open)
- core/rawdb/zap_ancient_bench_test.go (write + read throughput)
- cmd/migrate-ancient/ (one-shot upstream-freezer → ZAP migrator)
- cmd/fleet-bench/ (CSV harness for write/read latency)
Benchmarks (in-memory backend, M1 Pro):
WriteThroughput: 6860 ns/op, 4 puts per block (header+hash+body+receipt)
ReadHot: 920 ns/op
Migration command for an existing chain:
migrate-ancient --src /data/db/<net>/chainData/<chainID>/ancient \
--dst /data/ancient/<chainID>
The luxfi/operator NodeFleet CRD ships archive pods with
--ancient-store-backend=zap as the default.
Tests: 7 zap-ancient tests + full core/rawdb suite race-clean.
343 lines
10 KiB
Go
343 lines
10 KiB
Go
// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
//
|
|
// ZAP-native ancient store tests.
|
|
//
|
|
// We exercise the store against the in-memory KV backend so tests are
|
|
// hermetic and race-clean. The ZAP-backed integration path is the same
|
|
// thanks to the zapAncientBackend interface — anything that ethdb/zapdb
|
|
// New() satisfies, the store works against.
|
|
package rawdb
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
|
|
"github.com/luxfi/geth/ethdb"
|
|
"github.com/luxfi/geth/ethdb/memorydb"
|
|
)
|
|
|
|
// memBackend wraps memorydb so ZapAncientStore can take ownership of
|
|
// Close. The standard memorydb.Database is a struct, not an interface;
|
|
// this small wrapper hands the right methods to the store.
|
|
type memBackend struct{ *memorydb.Database }
|
|
|
|
func (m memBackend) NewBatch() ethdb.Batch { return m.Database.NewBatch() }
|
|
|
|
func newTestStore(t *testing.T) (*ZapAncientStore, func()) {
|
|
t.Helper()
|
|
tables := map[string]freezerTableConfig{
|
|
"headers": {noSnappy: false, prunable: false},
|
|
"hashes": {noSnappy: true, prunable: false},
|
|
"bodies": {noSnappy: false, prunable: true},
|
|
"receipts": {noSnappy: false, prunable: true},
|
|
}
|
|
store, err := NewZapAncientStore(t.TempDir(), tables, memBackend{memorydb.New()})
|
|
if err != nil {
|
|
t.Fatalf("NewZapAncientStore: %v", err)
|
|
}
|
|
return store, func() { _ = store.Close() }
|
|
}
|
|
|
|
// TestZapAncientStore_AppendRoundtrip writes blocks 0..N and reads them
|
|
// back. Asserts the (kind, number) → value mapping survives compression
|
|
// and the head pointers advance in lockstep.
|
|
func TestZapAncientStore_AppendRoundtrip(t *testing.T) {
|
|
store, cleanup := newTestStore(t)
|
|
defer cleanup()
|
|
|
|
const n = 16
|
|
type want struct {
|
|
kind string
|
|
bytes []byte
|
|
}
|
|
wants := make([]want, 0, n*4)
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
for i := uint64(0); i < n; i++ {
|
|
headerVal := bytes.Repeat([]byte{byte(i)}, 100)
|
|
hashVal := bytes.Repeat([]byte{byte(0xff - i)}, 32)
|
|
bodyVal := bytes.Repeat([]byte{byte(i + 1)}, 200)
|
|
receiptVal := bytes.Repeat([]byte{byte(i + 2)}, 64)
|
|
if err := op.AppendRaw("headers", i, headerVal); err != nil {
|
|
return err
|
|
}
|
|
if err := op.AppendRaw("hashes", i, hashVal); err != nil {
|
|
return err
|
|
}
|
|
if err := op.AppendRaw("bodies", i, bodyVal); err != nil {
|
|
return err
|
|
}
|
|
if err := op.AppendRaw("receipts", i, receiptVal); err != nil {
|
|
return err
|
|
}
|
|
wants = append(wants,
|
|
want{"headers", headerVal},
|
|
want{"hashes", hashVal},
|
|
want{"bodies", bodyVal},
|
|
want{"receipts", receiptVal},
|
|
)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("ModifyAncients: %v", err)
|
|
}
|
|
|
|
if h, err := store.Ancients(); err != nil || h != n {
|
|
t.Fatalf("Ancients head = %d (err=%v), want %d", h, err, n)
|
|
}
|
|
|
|
for i := uint64(0); i < n; i++ {
|
|
for _, kind := range []string{"headers", "hashes", "bodies", "receipts"} {
|
|
got, err := store.Ancient(kind, i)
|
|
if err != nil {
|
|
t.Fatalf("Ancient(%s, %d): %v", kind, i, err)
|
|
}
|
|
// Find expected.
|
|
var w want
|
|
for _, e := range wants {
|
|
if e.kind == kind {
|
|
w = e
|
|
wants = wants[1:]
|
|
break
|
|
}
|
|
}
|
|
if !bytes.Equal(got, w.bytes) {
|
|
t.Errorf("Ancient(%s, %d) length=%d, want %d", kind, i, len(got), len(w.bytes))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestZapAncientStore_OutOfOrderAppendRejected asserts the writeOp
|
|
// refuses to skip a number — the freezer's append-only no-gap rule.
|
|
func TestZapAncientStore_OutOfOrderAppendRejected(t *testing.T) {
|
|
store, cleanup := newTestStore(t)
|
|
defer cleanup()
|
|
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
// Number 0 is fine.
|
|
if err := op.AppendRaw("headers", 0, []byte("a")); err != nil {
|
|
return err
|
|
}
|
|
// Number 2 is the gap — must error.
|
|
return op.AppendRaw("headers", 2, []byte("c"))
|
|
}); err == nil {
|
|
t.Fatal("expected out-of-order error, got nil")
|
|
}
|
|
}
|
|
|
|
// TestZapAncientStore_RangeReadsRespectMaxBytes asserts AncientRange
|
|
// returns at least one item even if maxBytes is below the first item's
|
|
// size — the contract callers depend on for progress.
|
|
func TestZapAncientStore_RangeReadsRespectMaxBytes(t *testing.T) {
|
|
store, cleanup := newTestStore(t)
|
|
defer cleanup()
|
|
|
|
val := bytes.Repeat([]byte{0xab}, 500)
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
for i := uint64(0); i < 4; i++ {
|
|
if err := op.AppendRaw("bodies", i, val); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("ModifyAncients: %v", err)
|
|
}
|
|
|
|
out, err := store.AncientRange("bodies", 0, 10, 100)
|
|
if err != nil {
|
|
t.Fatalf("AncientRange: %v", err)
|
|
}
|
|
// With maxBytes=100 < 500 we must still get exactly one item.
|
|
if len(out) != 1 {
|
|
t.Errorf("AncientRange should return 1 item with maxBytes < itemSize, got %d", len(out))
|
|
}
|
|
|
|
out, err = store.AncientRange("bodies", 0, 10, 1100)
|
|
if err != nil {
|
|
t.Fatalf("AncientRange: %v", err)
|
|
}
|
|
// 1100 fits 2 items @ 500 bytes each.
|
|
if len(out) != 2 {
|
|
t.Errorf("AncientRange maxBytes=1100 should return 2 items, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
// TestZapAncientStore_TruncateHead asserts a truncate drops the head
|
|
// pointer + records, and the next append number resumes at the new
|
|
// head.
|
|
func TestZapAncientStore_TruncateHead(t *testing.T) {
|
|
store, cleanup := newTestStore(t)
|
|
defer cleanup()
|
|
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
for i := uint64(0); i < 10; i++ {
|
|
val := []byte{byte(i)}
|
|
for _, kind := range []string{"headers", "hashes", "bodies", "receipts"} {
|
|
if err := op.AppendRaw(kind, i, val); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("ModifyAncients: %v", err)
|
|
}
|
|
|
|
prev, err := store.TruncateHead(5)
|
|
if err != nil {
|
|
t.Fatalf("TruncateHead: %v", err)
|
|
}
|
|
if prev != 10 {
|
|
t.Errorf("TruncateHead prev head = %d, want 10", prev)
|
|
}
|
|
|
|
if _, err := store.Ancient("headers", 5); err == nil {
|
|
t.Error("Ancient(5) should be gone after TruncateHead(5)")
|
|
}
|
|
if _, err := store.Ancient("headers", 4); err != nil {
|
|
t.Errorf("Ancient(4) should remain: %v", err)
|
|
}
|
|
|
|
// Append must resume at 5.
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
for _, kind := range []string{"headers", "hashes", "bodies", "receipts"} {
|
|
if err := op.AppendRaw(kind, 5, []byte{0x55}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("resumed ModifyAncients: %v", err)
|
|
}
|
|
got, err := store.Ancient("headers", 5)
|
|
if err != nil || len(got) != 1 || got[0] != 0x55 {
|
|
t.Errorf("Ancient(5) after resumed append = %v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
// TestZapAncientStore_TruncateTailSkipsNonPrunable asserts the tail
|
|
// truncation honors the per-table prunable flag. Headers + hashes are
|
|
// non-prunable; bodies + receipts are prunable.
|
|
func TestZapAncientStore_TruncateTailSkipsNonPrunable(t *testing.T) {
|
|
store, cleanup := newTestStore(t)
|
|
defer cleanup()
|
|
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
for i := uint64(0); i < 8; i++ {
|
|
val := []byte{byte(i)}
|
|
for _, kind := range []string{"headers", "hashes", "bodies", "receipts"} {
|
|
if err := op.AppendRaw(kind, i, val); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("ModifyAncients: %v", err)
|
|
}
|
|
|
|
if _, err := store.TruncateTail(4); err != nil {
|
|
t.Fatalf("TruncateTail: %v", err)
|
|
}
|
|
|
|
// bodies / receipts are prunable — tail records gone.
|
|
if _, err := store.Ancient("bodies", 2); err == nil {
|
|
t.Error("bodies @ 2 should be pruned (prunable=true)")
|
|
}
|
|
// headers / hashes are non-prunable — record stays.
|
|
if _, err := store.Ancient("headers", 2); err != nil {
|
|
t.Errorf("headers @ 2 should remain (prunable=false): %v", err)
|
|
}
|
|
}
|
|
|
|
// TestZapAncientStore_CompressionEnabledForCompressibleTables asserts
|
|
// a snappy-encoded record on disk is smaller than the raw payload for
|
|
// a compressible value, while a no-compression table stores the raw
|
|
// bytes verbatim.
|
|
func TestZapAncientStore_CompressionEnabledForCompressibleTables(t *testing.T) {
|
|
store, cleanup := newTestStore(t)
|
|
defer cleanup()
|
|
|
|
// A highly-compressible body (all zero bytes).
|
|
compressible := make([]byte, 10_000)
|
|
// A non-compressible hash (random-looking content).
|
|
uncompressible := bytes.Repeat([]byte{0x12, 0x34, 0x56, 0x78}, 8)
|
|
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
if err := op.AppendRaw("bodies", 0, compressible); err != nil {
|
|
return err
|
|
}
|
|
return op.AppendRaw("hashes", 0, uncompressible)
|
|
}); err != nil {
|
|
t.Fatalf("ModifyAncients: %v", err)
|
|
}
|
|
|
|
// AncientSize on the bodies table must be far less than the raw
|
|
// payload — snappy compresses zero bytes hard.
|
|
sz, err := store.AncientSize("bodies")
|
|
if err != nil {
|
|
t.Fatalf("AncientSize bodies: %v", err)
|
|
}
|
|
if sz >= uint64(len(compressible)) {
|
|
t.Errorf("expected snappy-compressed bodies (got %d bytes) to be < %d", sz, len(compressible))
|
|
}
|
|
|
|
// AncientSize on hashes must match raw size — noSnappy=true.
|
|
sz, err = store.AncientSize("hashes")
|
|
if err != nil {
|
|
t.Fatalf("AncientSize hashes: %v", err)
|
|
}
|
|
if sz != uint64(len(uncompressible)) {
|
|
t.Errorf("non-snappy table size: got %d want %d", sz, len(uncompressible))
|
|
}
|
|
|
|
// Roundtrip the decompressed value is identical.
|
|
got, err := store.Ancient("bodies", 0)
|
|
if err != nil {
|
|
t.Fatalf("Ancient bodies: %v", err)
|
|
}
|
|
if !bytes.Equal(got, compressible) {
|
|
t.Errorf("compressed body roundtrip mismatch (len=%d, want=%d)", len(got), len(compressible))
|
|
}
|
|
}
|
|
|
|
// TestZapAncientStore_PersistAcrossOpen asserts the on-disk metadata
|
|
// (head, tail) survives a Close + re-open against the same backend.
|
|
func TestZapAncientStore_PersistAcrossOpen(t *testing.T) {
|
|
tables := map[string]freezerTableConfig{
|
|
"headers": {noSnappy: false, prunable: false},
|
|
}
|
|
mem := memBackend{memorydb.New()}
|
|
store, err := NewZapAncientStore(t.TempDir(), tables, mem)
|
|
if err != nil {
|
|
t.Fatalf("NewZapAncientStore: %v", err)
|
|
}
|
|
if _, err := store.ModifyAncients(func(op ethdb.AncientWriteOp) error {
|
|
for i := uint64(0); i < 5; i++ {
|
|
if err := op.AppendRaw("headers", i, []byte{byte(i)}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("ModifyAncients: %v", err)
|
|
}
|
|
// NOTE: Close would shut the backend; reuse the backend via the
|
|
// re-open path instead. Production paths share an existing *DB
|
|
// across processes via the FLOCK file.
|
|
store2, err := NewZapAncientStore(t.TempDir(), tables, mem)
|
|
if err != nil {
|
|
t.Fatalf("re-open: %v", err)
|
|
}
|
|
defer store2.Close()
|
|
h, err := store2.Ancients()
|
|
if err != nil {
|
|
t.Fatalf("Ancients: %v", err)
|
|
}
|
|
if h != 5 {
|
|
t.Errorf("re-open head = %d, want 5", h)
|
|
}
|
|
}
|