mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
fix(dchain): persist head block so GetBlock(lastAccepted) survives restart
The block store is the in-RAM acceptedBlocks map; only bootstrapGenesis and
Accept populate it. On restart loadHead restored the head META (lastAccepted id,
height, root) but NOT the block object, so once the chain advanced past genesis
the engine's GetBlock(lastAccepted) — called immediately after Initialize —
returned ErrNotFound and luxd failed VM init with 'zap initialize: remote error:
get last accepted block: not found', taking the D-Chain down on the first
validator to restart.
Persist the head block's canonical bytes (metaHeadBlock) in the SAME atomic
overlay/batch as the head meta (Accept) and in bootstrapGenesis. loadHead now
reconstructs the head block via parseBlock (id/height asserted to match the head
pointer) and puts it back into acceptedBlocks. A legacy DB with no persisted head
block recovers genesis from the deterministic genesisBlock() (DRY with
bootstrapGenesis) and errors loudly for a non-genesis head rather than silently
resetting the chain.
TDD: TestVMRestartGetBlockHead advances two blocks, restarts (fresh VM, same DB),
and asserts GetBlock(head) returns the block — the exact engine call that failed;
proven to FAIL without the acceptedBlocks repopulation ('not found'), pass with.
TestVMRestartLegacyGenesisDB covers the no-metaHeadBlock genesis recovery. Full
pkg/dchain suite green CGO=0.
This commit is contained in:
@@ -221,6 +221,14 @@ func (b *Block) Accept(ctx context.Context) error {
|
||||
b.overlay.Abort()
|
||||
return err
|
||||
}
|
||||
// Persist the head block's canonical bytes in the SAME overlay so the head
|
||||
// pointer and the block it names commit atomically. loadHead reconstructs this
|
||||
// block on restart so GetBlock(lastAccepted) — called by the engine right after
|
||||
// Initialize — succeeds once the chain has advanced past genesis.
|
||||
if err := writeHeadBlock(b.overlay, b.bytes); err != nil {
|
||||
b.overlay.Abort()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.overlay.Commit(); err != nil {
|
||||
return fmt.Errorf("dchain: commit block %s: %w", b.id, err)
|
||||
|
||||
@@ -80,6 +80,7 @@ const (
|
||||
metaLastAccepted = prefixMeta + "lastAccepted"
|
||||
metaHeight = prefixMeta + "height"
|
||||
metaRoot = prefixMeta + "root"
|
||||
metaHeadBlock = prefixMeta + "headBlock" // canonical bytes of the accepted head block
|
||||
)
|
||||
|
||||
// seenKey builds seen:<txID:32>. A tx id committed here has been applied by an
|
||||
@@ -658,6 +659,23 @@ func writeRoot(db database.KeyValueWriter, root [32]byte) error {
|
||||
return db.Put([]byte(metaRoot), root[:])
|
||||
}
|
||||
|
||||
// readHeadBlock returns the canonical bytes of the accepted head block, or
|
||||
// database.ErrNotFound if none recorded. The block objects are otherwise held only
|
||||
// in the in-RAM acceptedBlocks map, which is empty after a restart; persisting the
|
||||
// head's bytes lets loadHead reconstruct it so GetBlock(lastAccepted) — which the
|
||||
// consensus engine calls immediately after Initialize — succeeds on a chain that
|
||||
// has advanced past genesis.
|
||||
func readHeadBlock(db database.KeyValueReader) ([]byte, error) {
|
||||
return db.Get([]byte(metaHeadBlock))
|
||||
}
|
||||
|
||||
// writeHeadBlock persists the canonical bytes of the accepted head block. It is
|
||||
// written in the SAME atomic batch/overlay as the head meta (lastAccepted/height/
|
||||
// root) so the head pointer and the block it names always commit together.
|
||||
func writeHeadBlock(db database.KeyValueWriter, blockBytes []byte) error {
|
||||
return db.Put([]byte(metaHeadBlock), blockBytes)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State root — REUSES the audited xvm execution_root construction:
|
||||
// keccak256 tagged binary Merkle (RFC-6962, github.com/luxfi/crypto/merkle) for
|
||||
|
||||
+68
-5
@@ -122,12 +122,21 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// genesisBlock deterministically reconstructs the genesis block (height 0, empty,
|
||||
// zero parent root). It is a pure function of the VM binding — every validator and
|
||||
// every restart computes the identical id — so it is the single source of the
|
||||
// genesis block for both bootstrapGenesis (fresh DB) and loadHead's legacy-DB
|
||||
// recovery (a DB written before head blocks were persisted).
|
||||
func (vm *VM) genesisBlock() (*Block, [Size]byte) {
|
||||
var parentRoot [Size]byte // zero
|
||||
root, _, _, _ := ExecutionRoot(parentRoot, nil, nil, nil, 0)
|
||||
return newBlock(vm, genesisParent, 0, time.Unix(0, 0).UTC(), root, nil), root
|
||||
}
|
||||
|
||||
// bootstrapGenesis writes the genesis block (height 0, empty, zero parent root)
|
||||
// and sets it as the accepted head. Must be called under vm.mu on a fresh DB.
|
||||
func (vm *VM) bootstrapGenesis() error {
|
||||
var parentRoot [Size]byte // zero
|
||||
root, _, _, _ := ExecutionRoot(parentRoot, nil, nil, nil, 0)
|
||||
gen := newBlock(vm, genesisParent, 0, time.Unix(0, 0).UTC(), root, nil)
|
||||
gen, root := vm.genesisBlock()
|
||||
|
||||
batch := vm.db.NewBatch()
|
||||
if err := writeLastAccepted(batch, gen.id); err != nil {
|
||||
@@ -139,6 +148,9 @@ func (vm *VM) bootstrapGenesis() error {
|
||||
if err := writeRoot(batch, root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeHeadBlock(batch, gen.bytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := batch.Write(); err != nil {
|
||||
return fmt.Errorf("dchain: write genesis: %w", err)
|
||||
}
|
||||
@@ -152,8 +164,12 @@ func (vm *VM) bootstrapGenesis() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadHead reads the accepted head (id, height, root) from durable meta. Must be
|
||||
// called under vm.mu.
|
||||
// loadHead reads the accepted head (id, height, root) from durable meta AND
|
||||
// reconstructs the head Block into the in-RAM acceptedBlocks map. The block store
|
||||
// is the acceptedBlocks map (empty after a restart), so without this the engine's
|
||||
// GetBlock(lastAccepted) — invoked immediately after Initialize — returns
|
||||
// ErrNotFound and VM init fails ("get last accepted block: not found") on any
|
||||
// chain that advanced past genesis. Must be called under vm.mu.
|
||||
func (vm *VM) loadHead() error {
|
||||
id, err := readLastAccepted(vm.db)
|
||||
if err != nil {
|
||||
@@ -167,13 +183,60 @@ func (vm *VM) loadHead() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("dchain: load head root: %w", err)
|
||||
}
|
||||
|
||||
head, err := vm.loadHeadBlock(id, height)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vm.lastAcceptedID = id
|
||||
vm.lastAcceptedHeight = height
|
||||
vm.lastRoot = root
|
||||
vm.heightIndex[height] = id
|
||||
vm.acceptedBlocks[id] = head
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadHeadBlock reconstructs the accepted head block named by (id, height). It
|
||||
// reads the persisted head-block bytes and parses them, asserting the reconstructed
|
||||
// id matches the head pointer. For a DB written before head blocks were persisted
|
||||
// (legacy), the bytes are absent: a height-0 head is recovered from the
|
||||
// deterministic genesis block (verified to match the stored id); a height>0 head
|
||||
// with no persisted block is unrecoverable corruption and errors loudly rather
|
||||
// than silently resetting the chain. Must be called under vm.mu.
|
||||
func (vm *VM) loadHeadBlock(id ids.ID, height uint64) (*Block, error) {
|
||||
raw, err := readHeadBlock(vm.db)
|
||||
if err == database.ErrNotFound {
|
||||
// Legacy DB (head block never persisted). Only genesis is reconstructible
|
||||
// without the bytes.
|
||||
if height != 0 {
|
||||
return nil, fmt.Errorf("dchain: head block missing at height %d (corrupt or pre-persistence state); cannot recover", height)
|
||||
}
|
||||
gen, _ := vm.genesisBlock()
|
||||
if gen.id != id {
|
||||
return nil, fmt.Errorf("dchain: genesis id mismatch on legacy DB: stored %s computed %s", id, gen.id)
|
||||
}
|
||||
gen.status = statusAccepted
|
||||
return gen, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: read head block: %w", err)
|
||||
}
|
||||
|
||||
head, err := parseBlock(vm, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: parse head block: %w", err)
|
||||
}
|
||||
if head.id != id {
|
||||
return nil, fmt.Errorf("dchain: head block id mismatch: pointer %s parsed %s", id, head.id)
|
||||
}
|
||||
if head.height != height {
|
||||
return nil, fmt.Errorf("dchain: head block height mismatch: meta %d block %d", height, head.height)
|
||||
}
|
||||
head.status = statusAccepted
|
||||
return head, nil
|
||||
}
|
||||
|
||||
// rebuildAllBooks folds every market's book from its order:* rows. This is the
|
||||
// restart path: after a crash the in-RAM books are reconstructed exactly from the
|
||||
// durable resting set, so trading resumes with the committed state. Must be
|
||||
|
||||
@@ -414,6 +414,115 @@ func drainSignals(ch chan block.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMRestartGetBlockHead reproduces the production restart failure: after a
|
||||
// chain advances past genesis and the VM restarts (fresh VM, same DB), the
|
||||
// consensus engine calls GetBlock(lastAccepted) immediately after Initialize.
|
||||
// The block store is the in-RAM acceptedBlocks map (empty after restart), so
|
||||
// before head blocks were persisted this returned ErrNotFound and luxd reported
|
||||
// "zap initialize: remote error: get last accepted block: not found", taking the
|
||||
// D-Chain down on the first validator to restart. loadHead now reconstructs the
|
||||
// head block from durable bytes, so GetBlock(head) succeeds at every height.
|
||||
func TestVMRestartGetBlockHead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := memdb.New()
|
||||
pool := [32]byte{0x07}
|
||||
|
||||
vm, toEngine := newTestVM(t, db)
|
||||
|
||||
// The engine queries the head block right after Initialize even on a FRESH
|
||||
// (genesis) chain — that must already work.
|
||||
genesisID := vm.lastAcceptedID
|
||||
if blk, err := vm.GetBlock(ctx, genesisID); err != nil {
|
||||
t.Fatalf("GetBlock(genesis) on fresh VM: %v", err)
|
||||
} else if blk.ID() != genesisID {
|
||||
t.Fatalf("GetBlock(genesis) id = %s, want %s", blk.ID(), genesisID)
|
||||
}
|
||||
|
||||
// Advance two blocks so the head is well past genesis.
|
||||
vm.mempool.Add(ensureMarketTx(t, pool))
|
||||
vm.mempool.Add(placePoolTx(t, pool, zapwire.SideSell, 101.0, 5.0, "maker-a"))
|
||||
drainSignals(toEngine)
|
||||
buildVerifyAccept(t, vm)
|
||||
|
||||
vm.mempool.Add(placePoolTx(t, pool, zapwire.SideBuy, 99.0, 3.0, "maker-c"))
|
||||
drainSignals(toEngine)
|
||||
blk2 := buildVerifyAccept(t, vm)
|
||||
|
||||
headID := vm.lastAcceptedID
|
||||
headHeight := vm.lastAcceptedHeight
|
||||
headBytes := append([]byte(nil), blk2.Bytes()...)
|
||||
if headHeight != 2 {
|
||||
t.Fatalf("head height = %d, want 2", headHeight)
|
||||
}
|
||||
|
||||
if err := vm.Shutdown(ctx); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
// RESTART: fresh VM, same DB — mirrors the rpc.Serve harness reopening the
|
||||
// persistent store. This is exactly what failed in production.
|
||||
vm2, _ := newTestVM(t, db)
|
||||
if vm2.lastAcceptedID != headID {
|
||||
t.Fatalf("restart head = %s, want %s", vm2.lastAcceptedID, headID)
|
||||
}
|
||||
|
||||
// THE REGRESSION ASSERTION: the engine's first call after Initialize.
|
||||
got, err := vm2.GetBlock(ctx, headID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlock(head) after restart: %v (the production failure)", err)
|
||||
}
|
||||
if got.ID() != headID {
|
||||
t.Fatalf("GetBlock(head) id = %s, want %s", got.ID(), headID)
|
||||
}
|
||||
if got.Height() != headHeight {
|
||||
t.Fatalf("GetBlock(head) height = %d, want %d", got.Height(), headHeight)
|
||||
}
|
||||
if string(got.Bytes()) != string(headBytes) {
|
||||
t.Fatalf("GetBlock(head) bytes differ from the accepted block")
|
||||
}
|
||||
if got.Status() != statusAccepted {
|
||||
t.Fatalf("GetBlock(head) status = %d, want accepted(%d)", got.Status(), statusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMRestartLegacyGenesisDB proves loadHead recovers a DB written before head
|
||||
// blocks were persisted (no metaHeadBlock) when the head is genesis: it
|
||||
// reconstructs the deterministic genesis block and serves it over GetBlock. A
|
||||
// non-genesis legacy head (no recoverable bytes) must error loudly instead of
|
||||
// silently resetting the chain.
|
||||
func TestVMRestartLegacyGenesisDB(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := memdb.New()
|
||||
|
||||
// Simulate a legacy genesis DB: write the head META (lastAccepted/height/root)
|
||||
// for genesis but NOT the head-block bytes, exactly as the pre-persistence code
|
||||
// left a fresh chain.
|
||||
gen, root := (&VM{}).genesisBlock()
|
||||
batch := db.NewBatch()
|
||||
if err := writeLastAccepted(batch, gen.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeHeight(batch, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeRoot(batch, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := batch.Write(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Initialize over that legacy DB: loadHead's genesis recovery must rebuild the
|
||||
// head block so GetBlock(genesis) works.
|
||||
vm2, _ := newTestVM(t, db)
|
||||
if vm2.lastAcceptedID != gen.id {
|
||||
t.Fatalf("legacy-DB head = %s, want genesis %s", vm2.lastAcceptedID, gen.id)
|
||||
}
|
||||
if _, err := vm2.GetBlock(ctx, gen.id); err != nil {
|
||||
t.Fatalf("GetBlock(genesis) on legacy DB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure the VM satisfies the consensus ChainVM interface at compile time.
|
||||
var _ block.ChainVM = (*VM)(nil)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user