trie/bintrie: mark stems created via Empty.Insert* as dirty

Empty.Insert and Empty.InsertValuesAtStem construct a fresh StemNode with
mustRecompute=true but left the new `dirty` field at its zero value. With
the skip-clean CollectNodes optimization enabled, the resulting stem was
treated as already-persisted and never flushed to disk. A parent
InternalNode's blob would be written referencing a hash for which no
blob existed, causing "missing trie node" errors on subsequent reads.

This is the path hit whenever a key is inserted into an Empty subtree —
the common case on the first insert, and frequently thereafter on splits
that leave one side Empty. A long-running deployment surfaced the bug
after ~15 hours of random ERC20 writes.

Add `dirty: true` to both struct literals, and add regression guards
TestEmptyInsertMarksDirty / TestEmptyInsertValuesAtStemMarksDirty that
assert the returned stem is dirty.
This commit is contained in:
CPerezz 2026-04-18 09:06:11 +02:00
parent fad11d5795
commit f57dd20461
No known key found for this signature in database
GPG key ID: 62045F34B97177DD
2 changed files with 44 additions and 0 deletions

View file

@ -36,6 +36,7 @@ func (e Empty) Insert(key []byte, value []byte, _ NodeResolverFn, depth int) (Bi
Values: values[:],
depth: depth,
mustRecompute: true,
dirty: true,
}, nil
}
@ -58,6 +59,7 @@ func (e Empty) InsertValuesAtStem(key []byte, values [][]byte, _ NodeResolverFn,
Values: values,
depth: depth,
mustRecompute: true,
dirty: true,
}, nil
}

View file

@ -220,3 +220,45 @@ func TestEmptyGetHeight(t *testing.T) {
t.Errorf("Expected height 0 for empty node, got %d", height)
}
}
// TestEmptyInsertMarksDirty verifies that a StemNode produced by Empty.Insert
// is marked dirty. Without this, CollectNodes would skip the freshly created
// stem and its blob would never reach disk, producing "missing trie node"
// errors on subsequent reads.
func TestEmptyInsertMarksDirty(t *testing.T) {
key := make([]byte, 32)
key[0] = 0xaa
val := make([]byte, 32)
val[0] = 0xbb
n, err := Empty{}.Insert(key, val, nil, 0)
if err != nil {
t.Fatalf("Insert: %v", err)
}
sn, ok := n.(*StemNode)
if !ok {
t.Fatalf("expected *StemNode, got %T", n)
}
if !sn.dirty {
t.Fatalf("stem produced by Empty.Insert must have dirty=true")
}
}
// TestEmptyInsertValuesAtStemMarksDirty is the analogous guard for the
// bulk-insert entry point. Fresh stems created here must be dirty.
func TestEmptyInsertValuesAtStemMarksDirty(t *testing.T) {
key := make([]byte, 32)
key[0] = 0xcc
values := make([][]byte, 256)
values[0] = make([]byte, 32)
n, err := Empty{}.InsertValuesAtStem(key, values, nil, 3)
if err != nil {
t.Fatalf("InsertValuesAtStem: %v", err)
}
sn, ok := n.(*StemNode)
if !ok {
t.Fatalf("expected *StemNode, got %T", n)
}
if !sn.dirty {
t.Fatalf("stem produced by Empty.InsertValuesAtStem must have dirty=true")
}
}