From 33a6cb0397a997ed163d3b6b758ef2befc66adb2 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 2 Jul 2026 14:48:59 -0500 Subject: [PATCH 01/44] flatKV WAL implementation --- sei-db/state_db/sc/flatkv/wal/flatkv_wal.go | 82 +++ .../sc/flatkv/wal/flatkv_wal_config.go | 51 ++ .../sc/flatkv/wal/flatkv_wal_config_test.go | 24 + .../sc/flatkv/wal/flatkv_wal_entry.go | 151 ++++ .../sc/flatkv/wal/flatkv_wal_entry_test.go | 101 +++ .../state_db/sc/flatkv/wal/flatkv_wal_file.go | 432 +++++++++++ .../sc/flatkv/wal/flatkv_wal_file_test.go | 150 ++++ .../state_db/sc/flatkv/wal/flatkv_wal_impl.go | 686 ++++++++++++++++++ .../sc/flatkv/wal/flatkv_wal_impl_test.go | 468 ++++++++++++ .../sc/flatkv/wal/flatkv_wal_iterator.go | 178 +++++ .../sc/flatkv/wal/flatkv_wal_iterator_test.go | 122 ++++ sei-db/state_db/sc/flatkv/wal/metrics.go | 48 ++ 12 files changed, 2493 insertions(+) create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry_test.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_file_test.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go create mode 100644 sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go create mode 100644 sei-db/state_db/sc/flatkv/wal/metrics.go diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go new file mode 100644 index 0000000000..b6de7573da --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go @@ -0,0 +1,82 @@ +package wal + +import "github.com/sei-protocol/sei-chain/sei-db/proto" + +// A WAL for flatKV. +type FlatKVWAL interface { + + // Write a set of changes to the WAL. + // + // This method only schedules the write, it does not block until the write is complete. + // + // The FlatKVWal rejects writes for blocks if provided out of order. To avoid errors, observe + // the following rules: + // + // - The block numbers passed to Write() may never decrease. + // - If data has been written for block N, you cannot write data for block N+1 until you have called + // SignalEndOfBlock(). + Write( + // The block number associated with the changeset. + blockNumber uint64, + // The changeset to write. + cs []*proto.NamedChangeSet, + ) error + + // Signal that there will be no more writes for the current block number. Attempting to write additional + // changes for the same block number after calling this method may result in an error. + // + // Similar to Write(), this method is asynchronous. Calling this method does not, by itself, make data immediately + // crash durable. + SignalEndOfBlock() error + + // Flush the WAL to disk. All data previously passed to Write() before this call will be crash durable + // after this call returns. + Flush() error + + // Get the range of block numbers stored in the WAL. + GetStoredRange() ( + // If true, there is data in the WAL. If false, the WAL is empty and startBlockNumber and + // endBlockNumber are undefined. + ok bool, + // The lowest block number stored in the WAL, inclusive. Only valid if ok is true. + startBlockNumber uint64, + // The highest block number stored in the WAL, inclusive. Only valid if ok is true. + endBlockNumber uint64, + // Any error encountered while retrieving the range. + err error, + ) + + // Prune the WAL, removing all entries with block numbers less than lowestBlockNumberToKeep. + // + // This method merely schedules the prune operation, it does not block until the prune is complete. Pruning + // is async and lazy, and implementations are free to delay pruning arbitrarily long. If crashed or closed + // before the prune is complete, the WAL may not attempt to prune again on the next open unless Prune() is + // called again or for a higher block number. + Prune(lowestBlockNumberToKeep uint64) error + + // Create an iterator over the WAL, starting at the given block number. Iterates all data passed to Write() + // before this call, but may also iterate over data after this call if the iterator is not fully consumed before + // more data is written. + Iterator(startingBlockNumber uint64) (FlatKVWalIterator, error) + + // Close the WAL, flushing any pending writes and releasing resources. + Close() error +} + +// Iterates over data in a flatKV WAL, in ascending block order, yielding one entry per block. All changesets +// written for a block (across one or more Write calls) are coalesced, in write order, into that block's single +// entry; the entry's EndOfBlock field is always false. Incomplete trailing blocks (those with no end-of-block +// marker) are not yielded. +type FlatKVWalIterator interface { + // Next advances the iterator to the next block. It returns false when iteration is complete (no more + // blocks), and returns an error if advancing failed. After Next returns (false, nil), iteration is + // complete; after it returns an error, the iterator must not be used further (other than Close). + Next() (bool, error) + + // Entry returns the coalesced entry for the block at the iterator's current position. It is only valid to + // call Entry after Next has returned (true, nil). The returned entry must not be modified. + Entry() *FlatKVWalEntry + + // Close releases the resources held by the iterator. + Close() error +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go new file mode 100644 index 0000000000..f6602034f1 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go @@ -0,0 +1,51 @@ +package wal + +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/common/unit" +) + +// Configuration for a flatKV WAL. +type FlatKVWALConfig struct { + // The directory where the WAL writes its files. + Path string + + // The size of the channel used to send work from the caller to the serialization goroutine. + RequestBufferSize uint + + // The size of the channel used to send serialized records from the serialization goroutine to the + // writer goroutine. + WriteBufferSize uint + + // The size a WAL file may reach before it is sealed and a fresh one is opened. Rotation only happens on + // block boundaries, so a file may exceed this by the size of a single block. Must be greater than 0. + TargetFileSize uint + + // When true, Flush calls fsync on the underlying file so that flushed data survives a power loss, not + // merely a process crash. When false, Flush only flushes the in-process buffer to the OS. + FsyncOnFlush bool +} + +// Constructor for a default flatKV WAL configuration. +func DefaultFlatKVWALConfig(path string) *FlatKVWALConfig { + return &FlatKVWALConfig{ + Path: path, + RequestBufferSize: 16, + WriteBufferSize: 16, + TargetFileSize: 64 * unit.MB, + FsyncOnFlush: true, + } +} + +// Validate the configuration, returning nil if valid, or an error describing the problem if invalid. +func (c *FlatKVWALConfig) Validate() error { + if c.Path == "" { + return fmt.Errorf("path is required") + } + if c.TargetFileSize == 0 { + // A zero target would seal and rotate a fresh file after every single block. + return fmt.Errorf("target file size must be greater than 0") + } + return nil +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go new file mode 100644 index 0000000000..d38c98905e --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go @@ -0,0 +1,24 @@ +package wal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigValidate(t *testing.T) { + t.Run("default config is valid", func(t *testing.T) { + require.NoError(t, DefaultFlatKVWALConfig("/tmp/wal").Validate()) + }) + + t.Run("empty path is rejected", func(t *testing.T) { + cfg := DefaultFlatKVWALConfig("") + require.Error(t, cfg.Validate()) + }) + + t.Run("zero target file size is rejected", func(t *testing.T) { + cfg := DefaultFlatKVWALConfig("/tmp/wal") + cfg.TargetFileSize = 0 + require.Error(t, cfg.Validate()) + }) +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry.go new file mode 100644 index 0000000000..f62ea861cb --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry.go @@ -0,0 +1,151 @@ +package wal + +import ( + "encoding/binary" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/proto" +) + +// The kind of a WAL record. Every serialized entry begins with one of these bytes. +type entryKind byte + +const ( + // A changeset record: a block number plus the set of changes written for that block. + kindChangeset entryKind = 1 + // An end-of-block record: marks that no more changes will be written for a block number. On reload, a + // block whose changeset records are not followed by an end-of-block marker is discarded. + kindEndOfBlock entryKind = 2 +) + +// A WAL entry for flatKV. +// +// An entry is either a changeset record (EndOfBlock is false, Changeset holds the block's changes) or an +// end-of-block marker (EndOfBlock is true, Changeset is nil). A single block may be described by several +// changeset records followed by exactly one end-of-block marker. +type FlatKVWalEntry struct { + + // The block number associated with this entry. + BlockNumber uint64 + + // The changeset associated with this entry. Nil for end-of-block markers. + Changeset []*proto.NamedChangeSet + + // True if this entry marks the end of a block. End-of-block entries carry no changeset. + EndOfBlock bool +} + +// Constructor for a changeset entry. +func NewFlatKVWalEntry(blockNumber uint64, changeset []*proto.NamedChangeSet) *FlatKVWalEntry { + return &FlatKVWalEntry{ + BlockNumber: blockNumber, + Changeset: changeset, + } +} + +// Constructor for an end-of-block marker entry. +func NewFlatKVEndOfBlockEntry(blockNumber uint64) *FlatKVWalEntry { + return &FlatKVWalEntry{ + BlockNumber: blockNumber, + EndOfBlock: true, + } +} + +// Serialize the WAL entry to bytes. The returned bytes are the record payload; the file layer is responsible +// for framing (length prefix and checksum). The layout is: +// +// [1-byte kind][uvarint block number] +// +// followed, for changeset records only, by: +// +// [uvarint changeset count]([uvarint marshaled length][marshaled NamedChangeSet])* +func (e *FlatKVWalEntry) Serialize() ([]byte, error) { + var buf []byte + var scratch [binary.MaxVarintLen64]byte + + if e.EndOfBlock { + buf = append(buf, byte(kindEndOfBlock)) + n := binary.PutUvarint(scratch[:], e.BlockNumber) + buf = append(buf, scratch[:n]...) + return buf, nil + } + + buf = append(buf, byte(kindChangeset)) + n := binary.PutUvarint(scratch[:], e.BlockNumber) + buf = append(buf, scratch[:n]...) + + n = binary.PutUvarint(scratch[:], uint64(len(e.Changeset))) + buf = append(buf, scratch[:n]...) + + for i, ncs := range e.Changeset { + if ncs == nil { + return nil, fmt.Errorf("changeset %d is nil", i) + } + marshaled, err := ncs.Marshal() + if err != nil { + return nil, fmt.Errorf("failed to marshal changeset %d: %w", i, err) + } + n = binary.PutUvarint(scratch[:], uint64(len(marshaled))) + buf = append(buf, scratch[:n]...) + buf = append(buf, marshaled...) + } + + return buf, nil +} + +// DeserializeFlatKVWalEntry parses a record payload previously produced by Serialize. +func DeserializeFlatKVWalEntry(data []byte) ( + // The resulting WAL entry. + entry *FlatKVWalEntry, + // If true, the WAL entry was successfully deserialized. + // If false, the data was truncated or otherwise incomplete and entry is nil. + ok bool, + // Returns an error if the data could not be deserialized due to an unexpected error (e.g. a corrupt + // protobuf payload). Does not return an error if the data is simply truncated. + err error, +) { + if len(data) == 0 { + return nil, false, nil + } + + kind := entryKind(data[0]) + rest := data[1:] + + blockNumber, n := binary.Uvarint(rest) + if n <= 0 { + return nil, false, nil + } + rest = rest[n:] + + switch kind { + case kindEndOfBlock: + return NewFlatKVEndOfBlockEntry(blockNumber), true, nil + case kindChangeset: + count, n := binary.Uvarint(rest) + if n <= 0 { + return nil, false, nil + } + rest = rest[n:] + + changeset := make([]*proto.NamedChangeSet, 0, count) + for i := uint64(0); i < count; i++ { + length, n := binary.Uvarint(rest) + if n <= 0 { + return nil, false, nil + } + rest = rest[n:] + if uint64(len(rest)) < length { + return nil, false, nil + } + ncs := &proto.NamedChangeSet{} + if err := ncs.Unmarshal(rest[:length]); err != nil { + return nil, false, fmt.Errorf("failed to unmarshal changeset %d: %w", i, err) + } + rest = rest[length:] + changeset = append(changeset, ncs) + } + return NewFlatKVWalEntry(blockNumber, changeset), true, nil + default: + return nil, false, fmt.Errorf("unknown WAL entry kind %d", kind) + } +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry_test.go new file mode 100644 index 0000000000..524fca0e2b --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry_test.go @@ -0,0 +1,101 @@ +package wal + +import ( + "testing" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/stretchr/testify/require" +) + +func makeChangeSet(name string, key []byte, value []byte) *proto.NamedChangeSet { + return &proto.NamedChangeSet{ + Name: name, + Changeset: proto.ChangeSet{ + Pairs: []*proto.KVPair{{Key: key, Value: value}}, + }, + } +} + +func TestEntrySerializeRoundTrip(t *testing.T) { + t.Run("changeset with multiple named change sets", func(t *testing.T) { + entry := NewFlatKVWalEntry(42, []*proto.NamedChangeSet{ + makeChangeSet("bank", []byte("a"), []byte("1")), + makeChangeSet("evm", []byte("b"), []byte("2")), + }) + + data, err := entry.Serialize() + require.NoError(t, err) + + got, ok, err := DeserializeFlatKVWalEntry(data) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, entry, got) + }) + + t.Run("empty (non-nil) changeset", func(t *testing.T) { + entry := NewFlatKVWalEntry(7, []*proto.NamedChangeSet{}) + data, err := entry.Serialize() + require.NoError(t, err) + + got, ok, err := DeserializeFlatKVWalEntry(data) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(7), got.BlockNumber) + require.False(t, got.EndOfBlock) + require.Empty(t, got.Changeset) + }) + + t.Run("end of block marker", func(t *testing.T) { + entry := NewFlatKVEndOfBlockEntry(99) + data, err := entry.Serialize() + require.NoError(t, err) + + got, ok, err := DeserializeFlatKVWalEntry(data) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(99), got.BlockNumber) + require.True(t, got.EndOfBlock) + require.Nil(t, got.Changeset) + }) +} + +func TestDeserializeTruncated(t *testing.T) { + entry := NewFlatKVWalEntry(42, []*proto.NamedChangeSet{ + makeChangeSet("bank", []byte("hello"), []byte("world")), + }) + data, err := entry.Serialize() + require.NoError(t, err) + + // Every strict prefix is either incomplete (ok=false) or, by chance, a shorter valid record; it must + // never yield the original entry and never panic. + for length := 0; length < len(data); length++ { + got, ok, err := DeserializeFlatKVWalEntry(data[:length]) + if ok { + require.NotEqual(t, entry, got) + } + _ = err + } + + // Empty input is cleanly reported as incomplete. + got, ok, err := DeserializeFlatKVWalEntry(nil) + require.NoError(t, err) + require.False(t, ok) + require.Nil(t, got) +} + +func TestDeserializeCorruptChangeset(t *testing.T) { + // A changeset record whose declared change set length points at bytes that are not a valid + // NamedChangeSet protobuf must surface an error, not a false "ok". + // Layout: [kind=changeset][blockNumber=1][count=1][len=2][0x08 0xFF] where 0x08 is a varint field tag + // (field 1, wire type 0) followed by a truncated varint, which the protobuf decoder rejects. + payload := []byte{byte(kindChangeset), 0x01, 0x01, 0x02, 0x08, 0xFF} + _, ok, err := DeserializeFlatKVWalEntry(payload) + require.Error(t, err) + require.False(t, ok) +} + +func TestDeserializeUnknownKind(t *testing.T) { + _, ok, err := DeserializeFlatKVWalEntry([]byte{0xFF, 0x01}) + require.Error(t, err) + require.False(t, ok) +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go new file mode 100644 index 0000000000..de887c3698 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go @@ -0,0 +1,432 @@ +package wal + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "hash/crc32" + "os" + "path/filepath" + "regexp" + "strconv" + + "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" +) + +// FlatKV WAL files use the following naming schema (mirroring the hashlog package): +// +// For mutable files: {index}.fkvwal.u +// For sealed files: {index}-{first block}-{last block}.fkvwal +// +// The on-disk serialization version is recorded in each file's header rather than its name. + +const ( + // The file extension for unsealed (mutable) WAL files. + walUnsealedExtension = ".fkvwal.u" + // The file extension for sealed (immutable) WAL files. + walSealedExtension = ".fkvwal" + // The serialization version written into each file's header. Bumped if the on-disk format changes. + walFormatVersion = byte(1) +) + +// The magic prefix written at the start of every WAL file, followed by a single format-version byte. +var walFileMagic = []byte("FKVWAL") + +// The length of a WAL file header: magic prefix plus the format-version byte. +const walHeaderSize = 7 // len("FKVWAL") + 1 + +var ( + unsealedFileRegex = regexp.MustCompile(`^(\d+)\.fkvwal\.u$`) + sealedFileRegex = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)\.fkvwal$`) +) + +// The result of parsing a WAL file name. +type parsedFileName struct { + index uint64 + firstBlock uint64 + lastBlock uint64 + sealed bool +} + +// Parse a WAL file name into its components. Returns false if the name is not a WAL file name. +func parseFileName(fileName string) (parsedFileName, bool) { + if m := sealedFileRegex.FindStringSubmatch(fileName); m != nil { + index, err1 := strconv.ParseUint(m[1], 10, 64) + first, err2 := strconv.ParseUint(m[2], 10, 64) + last, err3 := strconv.ParseUint(m[3], 10, 64) + if err1 != nil || err2 != nil || err3 != nil { + return parsedFileName{}, false + } + return parsedFileName{index: index, firstBlock: first, lastBlock: last, sealed: true}, true + } + if m := unsealedFileRegex.FindStringSubmatch(fileName); m != nil { + index, err := strconv.ParseUint(m[1], 10, 64) + if err != nil { + return parsedFileName{}, false + } + return parsedFileName{index: index, sealed: false}, true + } + return parsedFileName{}, false +} + +// Build the name of an unsealed (mutable) WAL file. +func unsealedFileName(index uint64) string { + return fmt.Sprintf("%d%s", index, walUnsealedExtension) +} + +// Build the name of a sealed WAL file. +func sealedFileName(index uint64, firstBlock uint64, lastBlock uint64) string { + return fmt.Sprintf("%d-%d-%d%s", index, firstBlock, lastBlock, walSealedExtension) +} + +// A single WAL file on disk, either the current mutable file being appended to or a sealed file. +type walFile struct { + // The directory this file lives in. + directory string + + // The open file handle and buffered writer. Only set for the mutable file being written this session. + file *os.File + writer *bufio.Writer + + // The unique, monotonically increasing index of this file. + index uint64 + + // If true, this file is sealed and rejects writes. + sealed bool + + // The first and last block numbers that appear in this file, valid only when hasBlocks is true. + firstBlock uint64 + lastBlock uint64 + hasBlocks bool + + // The highest block number in this file terminated by an end-of-block marker, and the file size at that + // marker. Valid only when hasCompleteBlock is true. On seal, any records past completeSize (an incomplete + // trailing block) are truncated so the sealed file ends cleanly on a block boundary. + lastCompleteBlock uint64 + completeSize uint64 + hasCompleteBlock bool + + // The number of bytes written to this file so far, including the header. + size uint64 +} + +// Create a new mutable WAL file on disk, writing its header, ready to accept records. +func newWalFile(directory string, index uint64) (*walFile, error) { + path := filepath.Join(directory, unsealedFileName(index)) + file, err := os.Create(path) //nolint:gosec // path derived from a validated directory + if err != nil { + return nil, fmt.Errorf("failed to create WAL file %s: %w", path, err) + } + + writer := bufio.NewWriter(file) + header := append(append([]byte(nil), walFileMagic...), walFormatVersion) + if _, err := writer.Write(header); err != nil { + _ = file.Close() + return nil, fmt.Errorf("failed to write WAL header to %s: %w", path, err) + } + + return &walFile{ + directory: directory, + file: file, + writer: writer, + index: index, + size: walHeaderSize, + }, nil +} + +// frameRecord wraps a serialized payload in its on-disk framing: +// [uvarint payload length][payload][uint32 CRC32(payload)]. +func frameRecord(payload []byte) []byte { + var lenBuf [binary.MaxVarintLen64]byte + lenN := binary.PutUvarint(lenBuf[:], uint64(len(payload))) + + record := make([]byte, 0, lenN+len(payload)+4) + record = append(record, lenBuf[:lenN]...) + record = append(record, payload...) + var crcBuf [4]byte + binary.BigEndian.PutUint32(crcBuf[:], crc32.ChecksumIEEE(payload)) + record = append(record, crcBuf[:]...) + return record +} + +// Append a pre-framed record (see frameRecord) for the given block number to this file. endOfBlock marks the +// record as an end-of-block marker, which advances the file's completed-block boundary. +func (f *walFile) writeRecord(record []byte, blockNumber uint64, endOfBlock bool) error { + if f.sealed { + return fmt.Errorf("cannot write to a sealed WAL file") + } + if _, err := f.writer.Write(record); err != nil { + return fmt.Errorf("failed to write WAL record: %w", err) + } + f.size += uint64(len(record)) + + if !f.hasBlocks { + f.firstBlock = blockNumber + f.hasBlocks = true + } + f.lastBlock = blockNumber + if endOfBlock { + f.lastCompleteBlock = blockNumber + f.completeSize = f.size + f.hasCompleteBlock = true + } + return nil +} + +// Serialize, frame, and append a WAL entry to this file. A convenience wrapper over frameRecord and +// writeRecord for callers (rollback rewrite, tests) that hold entries rather than pre-framed bytes. +func (f *walFile) writeEntry(entry *FlatKVWalEntry) error { + payload, err := entry.Serialize() + if err != nil { + return fmt.Errorf("failed to serialize WAL entry: %w", err) + } + return f.writeRecord(frameRecord(payload), entry.BlockNumber, entry.EndOfBlock) +} + +// Flush buffered data to the OS. When fsync is true, also fsync the file so the data survives power loss. +func (f *walFile) flush(fsync bool) error { + if f.writer != nil { + if err := f.writer.Flush(); err != nil { + return fmt.Errorf("failed to flush WAL file: %w", err) + } + } + if fsync && f.file != nil { + if err := f.file.Sync(); err != nil { + return fmt.Errorf("failed to fsync WAL file: %w", err) + } + } + return nil +} + +// Seal this file: flush it, truncate away any incomplete trailing block, then atomically rename it to its +// sealed name. A file with no complete blocks (including one that never received a record) is removed rather +// than sealed. Idempotent. Returns the sealed file name, or "" if the file was removed. +func (f *walFile) seal() (string, error) { + if f.sealed { + return "", nil + } + if err := f.flush(true); err != nil { + return "", fmt.Errorf("failed to flush before sealing: %w", err) + } + + unsealedPath := filepath.Join(f.directory, unsealedFileName(f.index)) + if !f.hasCompleteBlock { + if f.file != nil { + if err := f.file.Close(); err != nil { + return "", fmt.Errorf("failed to close WAL file: %w", err) + } + } + if err := os.Remove(unsealedPath); err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("failed to remove empty WAL file: %w", err) + } + f.sealed = true + return "", nil + } + + if f.file != nil { + // Drop any records past the last end-of-block marker so the sealed file ends on a block boundary. + if f.size > f.completeSize { + if err := f.file.Truncate(int64(f.completeSize)); err != nil { //nolint:gosec // completeSize <= size + return "", fmt.Errorf("failed to truncate incomplete trailing block: %w", err) + } + if err := f.file.Sync(); err != nil { + return "", fmt.Errorf("failed to fsync WAL file after truncation: %w", err) + } + } + if err := f.file.Close(); err != nil { + return "", fmt.Errorf("failed to close WAL file: %w", err) + } + } + + sealedName := sealedFileName(f.index, f.firstBlock, f.lastCompleteBlock) + sealedPath := filepath.Join(f.directory, sealedName) + if err := util.AtomicRename(unsealedPath, sealedPath, true); err != nil { + return "", fmt.Errorf("failed to seal WAL file: %w", err) + } + f.sealed = true + return sealedName, nil +} + +// The result of reading a WAL file from disk. +type walFileContents struct { + // The parsed file name components. + parsed parsedFileName + + // The intact entries read from the file, in order. Excludes any torn trailing record. + entries []*FlatKVWalEntry + + // The first and last block numbers across the intact entries, valid only when hasBlocks is true. + firstBlock uint64 + lastBlock uint64 + hasBlocks bool + + // The byte offset just past the last record terminated by an end-of-block marker. Data beyond this offset + // belongs to an incomplete (uncommitted) block, or is a torn trailing record, and is discarded on recovery. + lastCompleteBlockOffset int64 + + // The highest block number terminated by an end-of-block marker, valid only when hasCompleteBlock is true. + lastCompleteBlock uint64 + hasCompleteBlock bool + + // One entry per end-of-block marker, recording the marker's block number and the byte offset just past its + // record. Ordered by ascending offset. Used to truncate the file at a block boundary (e.g. for rollback). + blockBoundaries []blockBoundary +} + +// The byte offset just past an end-of-block marker for a given block number. +type blockBoundary struct { + block uint64 + offset int64 +} + +// Read a WAL file from disk, tolerating a torn trailing record (a crash mid-write can leave a final record +// whose length prefix, payload, or checksum is incomplete). Any bytes past the last intact record are +// discarded; the last intact record's boundaries are reported so callers can recover incomplete tail blocks. +func readWalFile(path string) (*walFileContents, error) { + name := filepath.Base(path) + parsed, ok := parseFileName(name) + if !ok { + return nil, fmt.Errorf("not a WAL file: %s", name) + } + + data, err := os.ReadFile(path) //nolint:gosec // caller-supplied path + if err != nil { + return nil, fmt.Errorf("failed to read WAL file %s: %w", path, err) + } + + contents := &walFileContents{parsed: parsed} + + if len(data) < walHeaderSize { + // A file too short to even contain a header carries no committed data. + return contents, nil + } + if !bytes.Equal(data[:len(walFileMagic)], walFileMagic) { + return nil, fmt.Errorf("WAL file %s has an invalid magic prefix", path) + } + if version := data[len(walFileMagic)]; version != walFormatVersion { + return nil, fmt.Errorf("WAL file %s has unsupported format version %d", path, version) + } + contents.lastCompleteBlockOffset = walHeaderSize + + offset := walHeaderSize + for offset < len(data) { + length, lenN := binary.Uvarint(data[offset:]) + if lenN <= 0 { + break // torn or incomplete length prefix + } + payloadStart := offset + lenN + remaining := uint64(len(data) - payloadStart) //nolint:gosec // payloadStart <= len(data), so non-negative + if remaining < 4 || length > remaining-4 { + break // torn record: payload or checksum truncated (4 trailing bytes are the CRC32) + } + payloadLen := int(length) //nolint:gosec // bounded above by remaining-4, which is <= len(data) + payload := data[payloadStart : payloadStart+payloadLen] + recordEnd := payloadStart + payloadLen + 4 + gotCRC := binary.BigEndian.Uint32(data[payloadStart+payloadLen : recordEnd]) + if gotCRC != crc32.ChecksumIEEE(payload) { + break // torn or corrupt record + } + + entry, entryOK, err := DeserializeFlatKVWalEntry(payload) + if err != nil { + return nil, fmt.Errorf("failed to deserialize record in %s: %w", path, err) + } + if !entryOK { + break // torn payload + } + + contents.entries = append(contents.entries, entry) + if !contents.hasBlocks { + contents.firstBlock = entry.BlockNumber + contents.hasBlocks = true + } + contents.lastBlock = entry.BlockNumber + if entry.EndOfBlock { + contents.lastCompleteBlockOffset = int64(recordEnd) + contents.lastCompleteBlock = entry.BlockNumber + contents.hasCompleteBlock = true + contents.blockBoundaries = append(contents.blockBoundaries, + blockBoundary{block: entry.BlockNumber, offset: int64(recordEnd)}) + } + + offset = recordEnd + } + + return contents, nil +} + +// Seal an orphaned mutable file discovered on disk at startup (left behind by a crash before it could be +// sealed). Any incomplete trailing block (records not terminated by an end-of-block marker) or torn trailing +// record is truncated away first, so the sealed file ends cleanly on a block boundary. A file left with no +// complete blocks is removed. +func sealOrphanFile(directory string, name string) error { + path := filepath.Join(directory, name) + contents, err := readWalFile(path) + if err != nil { + return fmt.Errorf("failed to read orphaned WAL file %s: %w", path, err) + } + + if !contents.hasCompleteBlock { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove empty orphaned WAL file %s: %w", path, err) + } + return nil + } + + if err := os.Truncate(path, contents.lastCompleteBlockOffset); err != nil { + return fmt.Errorf("failed to truncate orphaned WAL file %s: %w", path, err) + } + sealedPath := filepath.Join(directory, + sealedFileName(contents.parsed.index, contents.firstBlock, contents.lastCompleteBlock)) + if err := util.AtomicRename(path, sealedPath, true); err != nil { + return fmt.Errorf("failed to seal orphaned WAL file %s: %w", path, err) + } + return nil +} + +// Truncate a sealed file to drop every block beyond rollbackThrough, renaming it to reflect the reduced block +// range. A file whose blocks all lie beyond rollbackThrough is removed entirely. Used by the rollback +// constructor after all orphans have been sealed. +func rollbackFile(directory string, name string, rollbackThrough uint64) error { + path := filepath.Join(directory, name) + contents, err := readWalFile(path) + if err != nil { + return fmt.Errorf("failed to read WAL file %s during rollback: %w", path, err) + } + + truncateTo := int64(walHeaderSize) + var lastKept uint64 + kept := false + for _, boundary := range contents.blockBoundaries { + if boundary.block > rollbackThrough { + break + } + truncateTo = boundary.offset + lastKept = boundary.block + kept = true + } + + if !kept { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove WAL file %s during rollback: %w", path, err) + } + return nil + } + if lastKept == contents.lastBlock { + return nil // nothing beyond the rollback point; leave the file untouched + } + + if err := os.Truncate(path, truncateTo); err != nil { + return fmt.Errorf("failed to truncate WAL file %s during rollback: %w", path, err) + } + newPath := filepath.Join(directory, + sealedFileName(contents.parsed.index, contents.firstBlock, lastKept)) + if newPath == path { + return nil + } + if err := util.AtomicRename(path, newPath, true); err != nil { + return fmt.Errorf("failed to rename WAL file %s during rollback: %w", path, err) + } + return nil +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file_test.go new file mode 100644 index 0000000000..62e458fd63 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file_test.go @@ -0,0 +1,150 @@ +package wal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/stretchr/testify/require" +) + +func TestFileNaming(t *testing.T) { + require.Equal(t, "3.fkvwal.u", unsealedFileName(3)) + require.Equal(t, "3-10-20.fkvwal", sealedFileName(3, 10, 20)) + + parsed, ok := parseFileName("3.fkvwal.u") + require.True(t, ok) + require.Equal(t, parsedFileName{index: 3, sealed: false}, parsed) + + parsed, ok = parseFileName("3-10-20.fkvwal") + require.True(t, ok) + require.Equal(t, parsedFileName{index: 3, firstBlock: 10, lastBlock: 20, sealed: true}, parsed) + + _, ok = parseFileName("not-a-wal-file.txt") + require.False(t, ok) +} + +// writeMutableFile creates a mutable file at index 0, applies fn to it, then flushes and closes the underlying +// handle without sealing, leaving an unsealed file on disk. It returns the file path. +func writeMutableFile(t *testing.T, dir string, fn func(f *walFile)) string { + t.Helper() + f, err := newWalFile(dir, 0) + require.NoError(t, err) + fn(f) + require.NoError(t, f.flush(true)) + require.NoError(t, f.file.Close()) + return filepath.Join(dir, unsealedFileName(0)) +} + +func writeCompleteBlock(t *testing.T, f *walFile, block uint64) { + t.Helper() + cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} + require.NoError(t, f.writeEntry(NewFlatKVWalEntry(block, cs))) + require.NoError(t, f.writeEntry(NewFlatKVEndOfBlockEntry(block))) +} + +func TestReadWalFileCleanTail(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeCompleteBlock(t, f, 1) + writeCompleteBlock(t, f, 2) + }) + + contents, err := readWalFile(path) + require.NoError(t, err) + require.True(t, contents.hasCompleteBlock) + require.Equal(t, uint64(1), contents.firstBlock) + require.Equal(t, uint64(2), contents.lastCompleteBlock) + require.Len(t, contents.entries, 4) // 2 changeset + 2 end-of-block records +} + +func TestReadWalFileIncompleteTailBlock(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeCompleteBlock(t, f, 1) + writeCompleteBlock(t, f, 2) + // Block 3 changeset with no end-of-block marker. + require.NoError(t, f.writeEntry(NewFlatKVWalEntry(3, + []*proto.NamedChangeSet{makeChangeSet("evm", []byte{3}, []byte{3})}))) + }) + + contents, err := readWalFile(path) + require.NoError(t, err) + require.True(t, contents.hasCompleteBlock) + require.Equal(t, uint64(2), contents.lastCompleteBlock) + // The dangling block-3 changeset is read as an entry, but the completed boundary stops at block 2. + require.Equal(t, uint64(3), contents.lastBlock) +} + +func TestReadWalFilePartialLengthPrefix(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeCompleteBlock(t, f, 1) + }) + + // Append a lone 0x80 byte: an incomplete uvarint length prefix, as a torn write would leave. + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + _, err = f.Write([]byte{0x80}) + require.NoError(t, err) + require.NoError(t, f.Close()) + + contents, err := readWalFile(path) + require.NoError(t, err) + require.True(t, contents.hasCompleteBlock) + require.Equal(t, uint64(1), contents.lastCompleteBlock) + require.Len(t, contents.entries, 2) +} + +func TestReadWalFileMidRecordTruncation(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeCompleteBlock(t, f, 1) + writeCompleteBlock(t, f, 2) + }) + + info, err := os.Stat(path) + require.NoError(t, err) + // Lop a few bytes off the end, tearing block 2's end-of-block record. + require.NoError(t, os.Truncate(path, info.Size()-3)) + + contents, err := readWalFile(path) + require.NoError(t, err) + require.True(t, contents.hasCompleteBlock) + require.Equal(t, uint64(1), contents.lastCompleteBlock) +} + +func TestReadWalFileChecksumMismatch(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeCompleteBlock(t, f, 1) + }) + + // Flip the final byte (part of the end-of-block record's CRC), so that record fails its checksum. + data, err := os.ReadFile(path) + require.NoError(t, err) + data[len(data)-1] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + + contents, err := readWalFile(path) + require.NoError(t, err) + // The changeset record survives; the corrupt end-of-block record is dropped, so no complete block remains. + require.False(t, contents.hasCompleteBlock) + require.Len(t, contents.entries, 1) +} + +func TestReadWalFileBadMagic(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeCompleteBlock(t, f, 1) + }) + + data, err := os.ReadFile(path) + require.NoError(t, err) + data[0] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + + _, err = readWalFile(path) + require.Error(t, err) +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go new file mode 100644 index 0000000000..93ee226dd7 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go @@ -0,0 +1,686 @@ +package wal + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + + "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/seilog" +) + +var _ FlatKVWAL = (*flatKVWalImpl)(nil) + +var logger = seilog.NewLogger("db", "state-db", "sc", "flatkv", "wal") + +// dataToBeSerialized carries an entry from a caller to the serializer to be serialized. +type dataToBeSerialized struct { + entry *FlatKVWalEntry +} + +// dataToBeWritten carries a framed record from the serializer to the writer to be appended. +type dataToBeWritten struct { + record []byte + blockNumber uint64 + endOfBlock bool +} + +// flushRequest asks the writer to flush (and optionally fsync) the mutable file, signaling done when durable. +type flushRequest struct { + done chan error +} + +// rangeQuery asks the writer to report the stored block range. +type rangeQuery struct { + reply chan storedRange +} + +// pruneRequest asks the writer to drop whole sealed files below `through`. +type pruneRequest struct { + through uint64 +} + +// closeRequest asks the writer to seal the mutable file and shut down, signaling done when sealed. +type closeRequest struct { + done chan error +} + +// pinRequest registers an iterator's read lease. The writer pins the lowest block the iterator will read +// (clamped up to the oldest stored block) so pruning cannot delete files it still needs, and replies with the +// block it actually pinned. The iterator passes that block back via unpinRequest when it closes. +type pinRequest struct { + startBlock uint64 + reply chan uint64 +} + +// unpinRequest releases a read lease previously registered via pinRequest. +type unpinRequest struct { + block uint64 +} + +// The block range reported by GetStoredRange. +type storedRange struct { + ok bool + start uint64 + end uint64 +} + +// Bookkeeping for a sealed WAL file, owned by the writer goroutine. +type sealedFileInfo struct { + name string + firstBlock uint64 + lastBlock uint64 +} + +// A standard flatKV WAL implementation. +type flatKVWalImpl struct { + // The configuration this WAL was opened with. Read-only after construction. + config *FlatKVWALConfig + + // caller ──serializerChan──▶ serializer ──writerChan──▶ writer + + // Caller entry points funnel through serializerChan as a single ordered stream to the serializer. + serializerChan chan any + + // The serializer forwards serialized records and control messages to the writer over writerChan. + writerChan chan any + + // The hard-stop context the serializer and writer watch. Cancelled by fail() on a fatal error and by + // Close() once everything has drained. + ctx context.Context + cancel context.CancelFunc + + // A child of ctx that the serializerChan producers watch, cancelled once the serializer stops reading so an + // in-flight or future push aborts rather than deadlocking. + senderCtx context.Context + senderCancel context.CancelFunc + + // Tracks the serializer and writer goroutines so Close() can wait for them to exit. + wg sync.WaitGroup + + // Guarantees the Close() shutdown sequence runs at most once. + closeOnce sync.Once + + // Set by Close() so subsequent scheduling calls fail fast. + closed atomic.Bool + + // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). + asyncErr atomic.Pointer[error] + + // Guards the write-ordering contract state below, which is read/written synchronously in Write and + // SignalEndOfBlock (not on the background goroutines). + mu sync.Mutex + // The block number of the most recent Write or SignalEndOfBlock. + currentBlock uint64 + // Whether currentBlock has been finalized by SignalEndOfBlock. + currentBlockEnded bool + // Whether any block has been observed (this session or recovered from disk). + hasCurrentBlock bool + + // The following fields are owned exclusively by the writer goroutine. + + // The current mutable file accepting records. + mutableFile *walFile + + // The index to assign the next mutable file. + nextIndex uint64 + + // Sealed files in ascending block order. Rotation appends to the back; pruning removes from the front. + sealedFiles *util.RandomAccessDeque[*sealedFileInfo] + + // Read leases held by live iterators: block number -> reference count. Pruning will not delete a file + // whose block range contains a leased block. Mutated only by the writer goroutine. + blockRefs map[uint64]int +} + +// NewFlatKVWAL opens (or creates) a flatKV WAL in the configured directory, recovering any files left behind +// by a previous session. +func NewFlatKVWAL(config *FlatKVWALConfig) (FlatKVWAL, error) { + return newFlatKVWal(config, nil) +} + +// NewFlatKVWALWithRollback opens a flatKV WAL and deletes all data for blocks beyond rollbackBlockNumber +// before returning, so the WAL contains no block greater than rollbackBlockNumber. +func NewFlatKVWALWithRollback(config *FlatKVWALConfig, rollbackBlockNumber uint64) (FlatKVWAL, error) { + return newFlatKVWal(config, &rollbackBlockNumber) +} + +func newFlatKVWal(config *FlatKVWALConfig, rollbackThrough *uint64) (FlatKVWAL, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid flatKV WAL config: %w", err) + } + if err := util.EnsureDirectoryExists(config.Path, true); err != nil { + return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) + } + + if err := recoverOrphans(config.Path); err != nil { + return nil, fmt.Errorf("failed to recover orphaned WAL files: %w", err) + } + if rollbackThrough != nil { + if err := rollbackDirectory(config.Path, *rollbackThrough); err != nil { + return nil, fmt.Errorf("failed to roll back WAL beyond block %d: %w", *rollbackThrough, err) + } + } + + sealedFiles, nextIndex, err := scanSealedFiles(config.Path) + if err != nil { + return nil, fmt.Errorf("failed to scan sealed WAL files: %w", err) + } + + mutable, err := newWalFile(config.Path, nextIndex) + if err != nil { + return nil, fmt.Errorf("failed to open mutable WAL file: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + senderCtx, senderCancel := context.WithCancel(ctx) + + w := &flatKVWalImpl{ + config: config, + serializerChan: make(chan any, config.RequestBufferSize), + writerChan: make(chan any, config.WriteBufferSize), + ctx: ctx, + cancel: cancel, + senderCtx: senderCtx, + senderCancel: senderCancel, + mutableFile: mutable, + nextIndex: nextIndex + 1, + sealedFiles: sealedFiles, + blockRefs: make(map[uint64]int), + } + // Recover the write-ordering position from the last complete block already on disk. + if r := w.blockRange(); r.ok { + w.currentBlock = r.end + w.currentBlockEnded = true + w.hasCurrentBlock = true + } + + w.wg.Add(2) + go w.serializerLoop() + go w.writerLoop() + + return w, nil +} + +// Write schedules a changeset record for the given block number. +func (w *flatKVWalImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { + if w.closed.Load() { + return fmt.Errorf("flatKV WAL is closed") + } + if err := w.enforceWriteOrdering(blockNumber); err != nil { + return fmt.Errorf("write rejected: %w", err) + } + if err := w.sendToSerializer(dataToBeSerialized{entry: NewFlatKVWalEntry(blockNumber, cs)}); err != nil { + return fmt.Errorf("failed to schedule write for block %d: %w", blockNumber, err) + } + return nil +} + +// SignalEndOfBlock schedules an end-of-block marker for the current block. +func (w *flatKVWalImpl) SignalEndOfBlock() error { + if w.closed.Load() { + return fmt.Errorf("flatKV WAL is closed") + } + + w.mu.Lock() + if !w.hasCurrentBlock || w.currentBlockEnded { + w.mu.Unlock() + return fmt.Errorf("no block in progress to end") + } + blockNumber := w.currentBlock + w.currentBlockEnded = true + w.mu.Unlock() + + if err := w.sendToSerializer(dataToBeSerialized{entry: NewFlatKVEndOfBlockEntry(blockNumber)}); err != nil { + return fmt.Errorf("failed to schedule end-of-block for block %d: %w", blockNumber, err) + } + return nil +} + +// enforceWriteOrdering rejects a Write that violates the block-ordering rules (no decreasing block numbers; no +// advancing to a new block before the current one is ended) and records the new position when it is allowed. +func (w *flatKVWalImpl) enforceWriteOrdering(blockNumber uint64) error { + w.mu.Lock() + defer w.mu.Unlock() + + if !w.hasCurrentBlock { + w.currentBlock = blockNumber + w.currentBlockEnded = false + w.hasCurrentBlock = true + return nil + } + if blockNumber < w.currentBlock { + return fmt.Errorf("block number %d is less than the current block number %d", blockNumber, w.currentBlock) + } + if blockNumber == w.currentBlock { + if w.currentBlockEnded { + return fmt.Errorf("block number %d has already ended; cannot write more changes to it", blockNumber) + } + return nil + } + // blockNumber > currentBlock + if !w.currentBlockEnded { + return fmt.Errorf( + "cannot write block %d before calling SignalEndOfBlock for block %d", blockNumber, w.currentBlock) + } + w.currentBlock = blockNumber + w.currentBlockEnded = false + return nil +} + +// Flush blocks until all previously scheduled writes are durable. +func (w *flatKVWalImpl) Flush() error { + done := make(chan error, 1) + if err := w.sendToSerializer(flushRequest{done: done}); err != nil { + return fmt.Errorf("failed to schedule flush: %w", err) + } + select { + case err := <-done: + return err // already wrapped by the writer, or nil on success + case <-w.ctx.Done(): + if err := w.asyncError(); err != nil { + return fmt.Errorf("flush aborted: %w", err) + } + return fmt.Errorf("flush aborted: %w", w.ctx.Err()) + } +} + +// GetStoredRange reports the range of complete blocks stored in the WAL. +func (w *flatKVWalImpl) GetStoredRange() (bool, uint64, uint64, error) { + reply := make(chan storedRange, 1) + if err := w.sendToSerializer(rangeQuery{reply: reply}); err != nil { + return false, 0, 0, fmt.Errorf("failed to schedule stored-range query: %w", err) + } + select { + case r := <-reply: + return r.ok, r.start, r.end, nil + case <-w.ctx.Done(): + if err := w.asyncError(); err != nil { + return false, 0, 0, fmt.Errorf("stored-range query aborted: %w", err) + } + return false, 0, 0, fmt.Errorf("stored-range query aborted: %w", w.ctx.Err()) + } +} + +// Prune schedules removal of whole sealed files below lowestBlockNumberToKeep. It does not block on completion. +func (w *flatKVWalImpl) Prune(lowestBlockNumberToKeep uint64) error { + if err := w.sendToSerializer(pruneRequest{through: lowestBlockNumberToKeep}); err != nil { + return fmt.Errorf("failed to schedule prune below block %d: %w", lowestBlockNumberToKeep, err) + } + return nil +} + +// Iterator returns an iterator over the WAL starting at startingBlockNumber. It registers a read lease first so +// pruning cannot delete files out from under the iterator, then flushes so all previously scheduled writes are +// visible. The lease is released by the iterator's Close. +func (w *flatKVWalImpl) Iterator(startingBlockNumber uint64) (FlatKVWalIterator, error) { + pinned, err := w.pinBlock(startingBlockNumber) + if err != nil { + return nil, fmt.Errorf("failed to pin starting block %d: %w", startingBlockNumber, err) + } + if err := w.Flush(); err != nil { + w.unpinBlock(pinned) + return nil, fmt.Errorf("failed to flush before creating iterator: %w", err) + } + return newWalIterator(w, startingBlockNumber, pinned), nil +} + +// pinBlock registers a read lease on the given start block and returns the block actually pinned. Blocks until +// the writer has recorded the lease, so a subsequent Prune cannot race ahead of it. +func (w *flatKVWalImpl) pinBlock(startBlock uint64) (uint64, error) { + reply := make(chan uint64, 1) + if err := w.sendToSerializer(pinRequest{startBlock: startBlock, reply: reply}); err != nil { + return 0, err + } + select { + case pinned := <-reply: + return pinned, nil + case <-w.ctx.Done(): + if err := w.asyncError(); err != nil { + return 0, fmt.Errorf("pin aborted: %w", err) + } + return 0, fmt.Errorf("pin aborted: %w", w.ctx.Err()) + } +} + +// unpinBlock releases a read lease. Best-effort: if the WAL is already shutting down the lease is moot. +func (w *flatKVWalImpl) unpinBlock(block uint64) { + _ = w.sendToSerializer(unpinRequest{block: block}) +} + +// Close flushes pending writes, seals the mutable file, and releases resources. +func (w *flatKVWalImpl) Close() error { + var closeErr error + w.closeOnce.Do(func() { + w.closed.Store(true) + done := make(chan error, 1) + if err := w.sendToSerializer(closeRequest{done: done}); err == nil { + select { + case closeErr = <-done: + case <-w.ctx.Done(): + } + } + w.wg.Wait() + w.cancel() + }) + if err := w.asyncError(); err != nil { + return fmt.Errorf("flatKV WAL closed with error: %w", err) + } + return closeErr // already wrapped by the writer, or nil on a clean seal +} + +// sendToSerializer enqueues a message onto the serializer's input channel, aborting if the WAL is +// shutting down or has failed. +func (w *flatKVWalImpl) sendToSerializer(msg any) error { + select { + case w.serializerChan <- msg: + return nil + case <-w.senderCtx.Done(): + if err := w.asyncError(); err != nil { + return fmt.Errorf("flatKV WAL failed: %w", err) + } + return fmt.Errorf("flatKV WAL is closed") + } +} + +// serializerLoop turns dataToBeSerialized messages into dataToBeWritten messages and forwards every message to +// the writer in FIFO order. Runs on its own goroutine until close or a fatal error. +func (w *flatKVWalImpl) serializerLoop() { + defer w.wg.Done() + for { + var msg any + select { + case <-w.ctx.Done(): + return + case msg = <-w.serializerChan: + } + + // A dataToBeSerialized becomes a dataToBeWritten; all other messages are forwarded unchanged. + if req, ok := msg.(dataToBeSerialized); ok { + payload, err := req.entry.Serialize() + if err != nil { + w.fail(fmt.Errorf("failed to serialize WAL entry: %w", err)) + return + } + msg = dataToBeWritten{ + record: frameRecord(payload), + blockNumber: req.entry.BlockNumber, + endOfBlock: req.entry.EndOfBlock, + } + } + + select { + case w.writerChan <- msg: + case <-w.ctx.Done(): + return + } + + if _, ok := msg.(closeRequest); ok { + // FIFO guarantees every prior write has been forwarded. Stop reading and forbid further + // pushes so any racing/future schedule aborts instead of deadlocking. + w.senderCancel() + return + } + } +} + +// writerLoop consumes forwarded messages, appending records to the mutable file and handling control messages. +// It owns all file bookkeeping and runs on its own goroutine until close or a fatal error. +func (w *flatKVWalImpl) writerLoop() { + defer w.wg.Done() + for { + var msg any + select { + case <-w.ctx.Done(): + return + case msg = <-w.writerChan: + } + + switch m := msg.(type) { + case dataToBeWritten: + if err := w.appendRecord(m); err != nil { + w.fail(err) + return + } + case flushRequest: + m.done <- w.mutableFile.flush(w.config.FsyncOnFlush) + case rangeQuery: + m.reply <- w.blockRange() + case pruneRequest: + if err := w.pruneSealedFiles(m.through); err != nil { + w.fail(err) + return + } + case pinRequest: + m.reply <- w.pinLowestReadableBlock(m.startBlock) + case unpinRequest: + w.releaseBlock(m.block) + case closeRequest: + _, err := w.mutableFile.seal() + m.done <- err + return + } + } +} + +// appendRecord appends a record to the mutable file, updates bookkeeping, and rotates on block boundaries once +// the file exceeds the target size. +func (w *flatKVWalImpl) appendRecord(m dataToBeWritten) error { + if err := w.mutableFile.writeRecord(m.record, m.blockNumber, m.endOfBlock); err != nil { + return fmt.Errorf("failed to append record for block %d: %w", m.blockNumber, err) + } + walBytesWritten.Add(w.ctx, int64(len(m.record))) + + if m.endOfBlock { + walBlocksWritten.Add(w.ctx, 1) + if w.mutableFile.size >= uint64(w.config.TargetFileSize) { + if err := w.rotate(); err != nil { + return fmt.Errorf("failed to rotate after block %d: %w", m.blockNumber, err) + } + } + } + return nil +} + +// rotate seals the current mutable file, records its bookkeeping, and opens a fresh mutable file. It is only +// called immediately after an end-of-block marker, so the mutable file ends on a block boundary. +func (w *flatKVWalImpl) rotate() error { + first := w.mutableFile.firstBlock + last := w.mutableFile.lastCompleteBlock + sealedName, err := w.mutableFile.seal() + if err != nil { + return fmt.Errorf("failed to seal WAL file during rotation: %w", err) + } + w.sealedFiles.PushBack(&sealedFileInfo{name: sealedName, firstBlock: first, lastBlock: last}) + walFilesSealed.Add(w.ctx, 1) + + mutable, err := newWalFile(w.config.Path, w.nextIndex) + if err != nil { + return fmt.Errorf("failed to open new mutable WAL file during rotation: %w", err) + } + w.mutableFile = mutable + w.nextIndex++ + return nil +} + +// pruneSealedFiles deletes sealed files whose highest block is below pruneThrough. Files are removed +// oldest-first (from the front of the deque) with a directory fsync after each removal, so a crash mid-prune +// leaves a contiguous suffix of files rather than a gap in the block sequence. The mutable file is never +// pruned. Iteration stops at the first retained file: block ranges grow toward the back, so once a file is +// kept every later file is kept too. +func (w *flatKVWalImpl) pruneSealedFiles(pruneThrough uint64) error { + for { + front, ok := w.sealedFiles.TryPeekFront() + if !ok || front.lastBlock >= pruneThrough { + break + } + if w.blockPinnedInRange(front.firstBlock, front.lastBlock) { + break // a live iterator still needs this file; leave it (and everything after) in place + } + path := filepath.Join(w.config.Path, front.name) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to prune WAL file %s: %w", path, err) + } + if err := util.SyncParentPath(path); err != nil { + return fmt.Errorf("failed to fsync directory after pruning %s: %w", path, err) + } + w.sealedFiles.PopFront() + walFilesPruned.Add(w.ctx, 1) + } + return nil +} + +// pinLowestReadableBlock records a read lease and returns the pinned block. An iterator reads blocks at or +// above startBlock but never below the oldest block actually stored, so the lease is clamped up to that: a +// stale low start must not pin files that no longer exist (or wedge pruning forever). +func (w *flatKVWalImpl) pinLowestReadableBlock(startBlock uint64) uint64 { + pinned := startBlock + if r := w.blockRange(); r.ok && r.start > pinned { + pinned = r.start + } + w.blockRefs[pinned]++ + return pinned +} + +// releaseBlock drops one reference to a leased block, forgetting it once the count reaches zero. +func (w *flatKVWalImpl) releaseBlock(block uint64) { + if w.blockRefs[block] <= 1 { + delete(w.blockRefs, block) + return + } + w.blockRefs[block]-- +} + +// blockPinnedInRange reports whether any live read lease falls within [firstBlock, lastBlock]. +func (w *flatKVWalImpl) blockPinnedInRange(firstBlock uint64, lastBlock uint64) bool { + for block := range w.blockRefs { + if block >= firstBlock && block <= lastBlock { + return true + } + } + return false +} + +// blockRange reports the range of complete blocks across all files. Complete blocks live in the sealed files +// (all complete) and in the mutable file up to its last end-of-block marker. Owned by the writer goroutine. +func (w *flatKVWalImpl) blockRange() storedRange { + var r storedRange + + // The highest complete block is in the mutable file if it has one, otherwise in the newest sealed file. + if w.mutableFile.hasCompleteBlock { + r = storedRange{ok: true, end: w.mutableFile.lastCompleteBlock} + } else if back, ok := w.sealedFiles.TryPeekBack(); ok { + r = storedRange{ok: true, end: back.lastBlock} + } else { + return storedRange{} // nothing complete stored yet + } + + // The lowest stored block is in the oldest sealed file if any, otherwise in the mutable file. + if front, ok := w.sealedFiles.TryPeekFront(); ok { + r.start = front.firstBlock + } else { + r.start = w.mutableFile.firstBlock + } + return r +} + +// fail records the first fatal background error and triggers shutdown of the pipeline. +func (w *flatKVWalImpl) fail(err error) { + w.asyncErr.CompareAndSwap(nil, &err) + w.cancel() + logger.Error("flatKV WAL encountered a fatal error", "err", err) +} + +// asyncError returns the first fatal background error, or nil if none occurred. +func (w *flatKVWalImpl) asyncError() error { + if p := w.asyncErr.Load(); p != nil { + return *p + } + return nil +} + +// recoverOrphans seals any unsealed WAL files left behind by a crash. +func recoverOrphans(directory string) error { + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + parsed, ok := parseFileName(entry.Name()) + if !ok || parsed.sealed { + continue + } + if err := sealOrphanFile(directory, entry.Name()); err != nil { + return fmt.Errorf("failed to seal orphan %s: %w", entry.Name(), err) + } + } + return nil +} + +// rollbackDirectory drops all data beyond rollbackThrough from every sealed file. Assumes orphans are sealed. +func rollbackDirectory(directory string, rollbackThrough uint64) error { + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + parsed, ok := parseFileName(entry.Name()) + if !ok || !parsed.sealed { + continue + } + if err := rollbackFile(directory, entry.Name(), rollbackThrough); err != nil { + return fmt.Errorf("failed to roll back %s: %w", entry.Name(), err) + } + } + return nil +} + +// scanSealedFiles loads the sealed files in a directory into an ascending-order deque and returns the index to +// assign the next mutable file (one past the highest sealed index, or 0 when there are none). File indices +// must be contiguous: a gap means a sealed file went missing, which is unrecoverable corruption, so this fails +// with an informative error rather than silently leaving a hole in the block sequence. +func scanSealedFiles(directory string) (*util.RandomAccessDeque[*sealedFileInfo], uint64, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, 0, fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + + parsed := make([]parsedFileName, 0, len(entries)) + names := make(map[uint64]string, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + p, ok := parseFileName(entry.Name()) + if !ok || !p.sealed { + continue + } + parsed = append(parsed, p) + names[p.index] = entry.Name() + } + sort.Slice(parsed, func(i int, j int) bool { return parsed[i].index < parsed[j].index }) + + sealedFiles := util.NewRandomAccessDeque[*sealedFileInfo](uint64(len(parsed))) + var nextIndex uint64 + for i, p := range parsed { + if i > 0 && p.index != parsed[i-1].index+1 { + return nil, 0, fmt.Errorf( + "WAL is corrupt: sealed file indices are not contiguous (gap between %d and %d)", + parsed[i-1].index, p.index) + } + sealedFiles.PushBack(&sealedFileInfo{name: names[p.index], firstBlock: p.firstBlock, lastBlock: p.lastBlock}) + nextIndex = p.index + 1 + } + return sealedFiles, nextIndex, nil +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go new file mode 100644 index 0000000000..4abd420d7e --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go @@ -0,0 +1,468 @@ +package wal + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/stretchr/testify/require" +) + +func testConfig(dir string) *FlatKVWALConfig { + return DefaultFlatKVWALConfig(dir) +} + +func openWAL(t *testing.T, cfg *FlatKVWALConfig) FlatKVWAL { + t.Helper() + w, err := NewFlatKVWAL(cfg) + require.NoError(t, err) + return w +} + +// writeBlock writes a single changeset for the block and signals end of block. +func writeBlock(t *testing.T, w FlatKVWAL, block uint64) { + t.Helper() + cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} + require.NoError(t, w.Write(block, cs)) + require.NoError(t, w.SignalEndOfBlock()) +} + +// collectBlocks iterates from start and returns the block number of each coalesced block entry, verifying +// that entries are strictly increasing and never carry an end-of-block marker. +func collectBlocks(t *testing.T, w FlatKVWAL, start uint64) []uint64 { + t.Helper() + it, err := w.Iterator(start) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + var blocks []uint64 + for { + ok, err := it.Next() + require.NoError(t, err) + if !ok { + break + } + entry := it.Entry() + require.GreaterOrEqual(t, entry.BlockNumber, start) + require.False(t, entry.EndOfBlock) + if len(blocks) > 0 { + require.Greater(t, entry.BlockNumber, blocks[len(blocks)-1]) + } + blocks = append(blocks, entry.BlockNumber) + } + return blocks +} + +func TestWriteFlushReopenGetRange(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for block := uint64(1); block <= 5; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + ok, start, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(5), end) + require.NoError(t, w.Close()) + + w2 := openWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + + ok, start, end, err = w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(5), end) + + require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectBlocks(t, w2, 1)) +} + +func TestContractViolations(t *testing.T) { + t.Run("block numbers may not decrease", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + writeBlock(t, w, 5) + require.Error(t, w.Write(4, nil)) + }) + + t.Run("cannot advance block without ending the previous one", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + require.NoError(t, w.Write(1, nil)) + require.Error(t, w.Write(2, nil)) + }) + + t.Run("cannot write to an ended block", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + require.NoError(t, w.Write(1, nil)) + require.NoError(t, w.SignalEndOfBlock()) + require.Error(t, w.Write(1, nil)) + }) + + t.Run("end of block with no block in progress is an error", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + require.Error(t, w.SignalEndOfBlock()) + }) + + t.Run("multiple writes to the same block are allowed before end of block", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("b", []byte("k2"), []byte("v2"))})) + require.NoError(t, w.SignalEndOfBlock()) + }) +} + +func TestIncompleteTailBlockDiscardedOnReopen(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for block := uint64(1); block <= 3; block++ { + writeBlock(t, w, block) + } + // Block 4 is written but never ended (a crash mid-block). + require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{0x04}, []byte{0x04})})) + require.NoError(t, w.Flush()) + require.NoError(t, w.Close()) + + w2 := openWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + + ok, start, end, err := w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(3), end) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) + + // Block 4 may now be re-executed cleanly. + writeBlock(t, w2, 4) + require.NoError(t, w2.Flush()) + ok, _, end, err = w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(4), end) +} + +func TestOrphanFileRecovery(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + // Fabricate an orphaned unsealed file: blocks 1 and 2 complete, block 3 incomplete, left unsealed as if + // the process crashed before it could seal. + f, err := newWalFile(dir, 0) + require.NoError(t, err) + writeCompleteBlock(t, f, 1) + writeCompleteBlock(t, f, 2) + cs := []*proto.NamedChangeSet{makeChangeSet("a", []byte{3}, []byte{3})} + require.NoError(t, f.writeEntry(NewFlatKVWalEntry(3, cs))) // no end-of-block marker: block 3 is incomplete + require.NoError(t, f.flush(true)) + require.NoError(t, f.file.Close()) + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + ok, start, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(2), end) + require.Equal(t, []uint64{1, 2}, collectBlocks(t, w, 1)) +} + +func TestRotationProducesContiguousSealedFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // rotate after every completed block + + w := openWAL(t, cfg) + for block := uint64(1); block <= 6; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + ok, start, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(6), end) + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectBlocks(t, w, 1)) + require.NoError(t, w.Close()) + + // Every completed block should have produced its own sealed file with a clean [k,k] range. + var sealed []parsedFileName + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, entry := range entries { + if parsed, okName := parseFileName(entry.Name()); okName && parsed.sealed { + sealed = append(sealed, parsed) + require.Equal(t, parsed.firstBlock, parsed.lastBlock) + } + } + require.Len(t, sealed, 6) +} + +func countSealedFiles(t *testing.T, dir string) int { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + count := 0 + for _, entry := range entries { + if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { + count++ + } + } + return count +} + +func TestBlockNeverSplitAcrossFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 128 // tiny, so a single block's data dwarfs the rotation threshold + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + // Write many changesets for the same block, far exceeding TargetFileSize, without ending the block. + const changesetCount = 50 + value := make([]byte, 100) + for i := 0; i < changesetCount; i++ { + cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(i)}, value)} + require.NoError(t, w.Write(1, cs)) + } + require.NoError(t, w.Flush()) + + // Despite blowing past TargetFileSize many times over, the still-open block must not have been sealed: + // no sealed file exists yet, so all of block 1's data lives in the single mutable file. + require.Equal(t, 0, countSealedFiles(t, dir)) + + // Closing the block permits rotation; block 1's data is sealed into exactly one file. + require.NoError(t, w.SignalEndOfBlock()) + require.NoError(t, w.Flush()) + + require.Equal(t, 1, countSealedFiles(t, dir)) + + // The iterator coalesces all of block 1's Write records into a single entry whose changeset is the + // concatenation, in write order, of every record's changesets. + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + entry := it.Entry() + require.Equal(t, uint64(1), entry.BlockNumber) + require.False(t, entry.EndOfBlock) + require.Len(t, entry.Changeset, changesetCount) + for i, ncs := range entry.Changeset { + require.Equal(t, []byte{byte(i)}, ncs.Changeset.Pairs[0].Key) + } + + ok, err = it.Next() + require.NoError(t, err) + require.False(t, ok) +} + +func TestPruneDropsWholeFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one block per file, so pruning can drop whole files + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 10; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + require.NoError(t, w.Prune(5)) + + ok, start, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), start) + require.Equal(t, uint64(10), end) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 0)) +} + +func TestPrunePastAllBlocksEmptiesRange(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one block per file so every block sits in a prunable sealed file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 5; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + require.NoError(t, w.Prune(100)) + + ok, _, _, err := w.GetStoredRange() + require.NoError(t, err) + require.False(t, ok) +} + +func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one block per sealed file, so pruning works file-by-file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 10; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + // Hold an iterator anchored at block 1 (the oldest). Its read lease must keep block 1's file alive. + it, err := w.Iterator(1) + require.NoError(t, err) + + require.NoError(t, w.Prune(5)) + ok, start, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start, "block 1 must survive pruning while a live iterator pins it") + require.Equal(t, uint64(10), end) + + // The iterator still sees the full, intact sequence. + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 1)) + + // Releasing the lease lets the same prune make progress. + require.NoError(t, it.Close()) + require.NoError(t, w.Prune(5)) + ok, start, _, err = w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), start) +} + +func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 10; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + // An iterator anchored at block 8 does not need blocks below 5, so pruning to 5 proceeds. + it, err := w.Iterator(8) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + require.NoError(t, w.Prune(5)) + ok, start, _, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), start) +} + +func TestScanRejectsGapInSealedFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one block per sealed file + + w := openWAL(t, cfg) + for block := uint64(1); block <= 4; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + // Delete a middle sealed file to punch a gap in the index sequence, simulating corruption. + var sealed []parsedFileName + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, entry := range entries { + if p, ok := parseFileName(entry.Name()); ok && p.sealed { + sealed = append(sealed, p) + } + } + require.GreaterOrEqual(t, len(sealed), 3) + sort.Slice(sealed, func(i int, j int) bool { return sealed[i].index < sealed[j].index }) + victim := sealed[len(sealed)/2] + require.NoError(t, os.Remove(filepath.Join(dir, sealedFileName(victim.index, victim.firstBlock, victim.lastBlock)))) + + _, err = NewFlatKVWAL(cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "not contiguous") +} + +func TestGetStoredRangeEmpty(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + ok, _, _, err := w.GetStoredRange() + require.NoError(t, err) + require.False(t, ok) +} + +func TestRollbackConstructor(t *testing.T) { + t.Run("drops whole files beyond the rollback point", func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one block per file + + w := openWAL(t, cfg) + for block := uint64(1); block <= 6; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + w2, err := NewFlatKVWALWithRollback(cfg, 3) + require.NoError(t, err) + defer func() { require.NoError(t, w2.Close()) }() + + ok, start, end, err := w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(3), end) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) + }) + + t.Run("truncates within a file at the rollback point", func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: all blocks land in one file + + w := openWAL(t, cfg) + for block := uint64(1); block <= 6; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + w2, err := NewFlatKVWALWithRollback(cfg, 3) + require.NoError(t, err) + defer func() { require.NoError(t, w2.Close()) }() + + ok, start, end, err := w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(3), end) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) + + // Writing continues cleanly after the rollback point. + writeBlock(t, w2, 4) + require.NoError(t, w2.Flush()) + _, _, end, err = w2.GetStoredRange() + require.NoError(t, err) + require.Equal(t, uint64(4), end) + }) +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go new file mode 100644 index 0000000000..915e811cc6 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go @@ -0,0 +1,178 @@ +package wal + +import ( + "fmt" + "os" + "path/filepath" +) + +var _ FlatKVWalIterator = (*walIterator)(nil) + +// walIterator iterates the WAL a block at a time, in ascending block order. All records written for a block +// (one per Write call) plus its end-of-block marker are coalesced into a single entry whose Changeset is the +// concatenation, in write order, of every record's changesets. It loads one file at a time from disk, so its +// memory use is bounded by a single WAL file (plus the block being assembled). It re-lists the directory as it +// advances between files, so files rotated (mutable sealed) or created after construction are still observed. +type walIterator struct { + // The WAL this iterator reads from. Set to nil by Close so the read lease is released exactly once. + wal *flatKVWalImpl + + start uint64 + + // The block pinned as this iterator's read lease, released on Close. + pinnedBlock uint64 + + // The smallest file index not yet consumed. + nextIndex uint64 + + // The records loaded from the current file, filtered to complete blocks at or beyond start. + buffer []*FlatKVWalEntry + // The position within buffer; -1 before the first record is read. + pos int + + // The coalesced block entry returned by Entry, set by the most recent successful Next. + result *FlatKVWalEntry + + // Set once no further blocks remain. + done bool +} + +// newWalIterator creates an iterator over wal starting at startingBlockNumber. pinnedBlock is the read lease +// registered on the iterator's behalf, released by Close. +func newWalIterator(wal *flatKVWalImpl, startingBlockNumber uint64, pinnedBlock uint64) *walIterator { + return &walIterator{ + wal: wal, + start: startingBlockNumber, + pinnedBlock: pinnedBlock, + pos: -1, + } +} + +func (it *walIterator) Next() (bool, error) { + if it.done { + return false, nil + } + + var block *FlatKVWalEntry + for { + record, ok, err := it.nextRecord() + if err != nil { + it.done = true + return false, fmt.Errorf("failed to advance WAL iterator: %w", err) + } + if !ok { + // End of stream. A complete block always ends with an end-of-block marker, so reaching here + // mid-block should not happen; emit any assembled changes defensively rather than dropping them. + it.done = true + if block != nil { + it.result = block + return true, nil + } + return false, nil + } + + if block == nil { + block = &FlatKVWalEntry{BlockNumber: record.BlockNumber} + } + if record.EndOfBlock { + it.result = block + return true, nil + } + block.Changeset = append(block.Changeset, record.Changeset...) + } +} + +func (it *walIterator) Entry() *FlatKVWalEntry { + return it.result +} + +func (it *walIterator) Close() error { + if it.wal != nil { + it.wal.unpinBlock(it.pinnedBlock) + it.wal = nil // release the lease exactly once, even if Close is called repeatedly + } + it.buffer = nil + it.result = nil + it.done = true + return nil +} + +// nextRecord returns the next individual record (changeset or end-of-block marker) in ascending order, +// advancing across files as needed. It returns ok=false once no further records remain. +func (it *walIterator) nextRecord() (*FlatKVWalEntry, bool, error) { + for { + it.pos++ + if it.pos < len(it.buffer) { + return it.buffer[it.pos], true, nil + } + loaded, err := it.loadNextFile() + if err != nil { + return nil, false, err + } + if !loaded { + return nil, false, nil + } + it.pos = -1 + } +} + +// loadNextFile finds the next file at or beyond nextIndex, loads its records (filtered to complete blocks at +// or beyond start), and advances nextIndex. It returns false when no further file exists. A file entirely +// below start is skipped without being read; a file that yields no matching records leaves buffer empty. +func (it *walIterator) loadNextFile() (bool, error) { + name, parsed, ok, err := findFileByMinIndex(it.wal.config.Path, it.nextIndex) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + it.nextIndex = parsed.index + 1 + it.buffer = nil + + if parsed.sealed && parsed.lastBlock < it.start { + return true, nil // entirely below the start block; skip without reading + } + + contents, err := readWalFile(filepath.Join(it.wal.config.Path, name)) + if err != nil { + return false, fmt.Errorf("failed to read WAL file %s during iteration: %w", name, err) + } + if !contents.hasCompleteBlock { + return true, nil + } + for _, entry := range contents.entries { + if entry.BlockNumber < it.start || entry.BlockNumber > contents.lastCompleteBlock { + continue + } + it.buffer = append(it.buffer, entry) + } + return true, nil +} + +// findFileByMinIndex returns the WAL file with the smallest index greater than or equal to minIndex. +func findFileByMinIndex(directory string, minIndex uint64) (string, parsedFileName, bool, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return "", parsedFileName{}, false, fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + + var bestName string + var best parsedFileName + found := false + for _, entry := range entries { + if entry.IsDir() { + continue + } + parsed, ok := parseFileName(entry.Name()) + if !ok || parsed.index < minIndex { + continue + } + if !found || parsed.index < best.index { + best = parsed + bestName = entry.Name() + found = true + } + } + return bestName, best, found, nil +} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go new file mode 100644 index 0000000000..c01139e700 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go @@ -0,0 +1,122 @@ +package wal + +import ( + "testing" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/stretchr/testify/require" +) + +func TestIteratorEmpty(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + it, err := w.Iterator(0) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.False(t, ok) +} + +func TestIteratorFromMiddle(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 5; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{3, 4, 5}, collectBlocks(t, w, 3)) +} + +func TestIteratorAcrossFiles(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.TargetFileSize = 1 // one block per file + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 5; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{2, 3, 4, 5}, collectBlocks(t, w, 2)) +} + +func TestIteratorStopsBeforeIncompleteTail(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 3; block++ { + writeBlock(t, w, block) + } + // Block 4 written but not ended. + require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{4}, []byte{4})})) + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1)) +} + +func TestIteratorYieldsChangesetContents(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte("key"), []byte("value"))} + require.NoError(t, w.Write(1, cs)) + require.NoError(t, w.SignalEndOfBlock()) + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + entry := it.Entry() + require.Equal(t, uint64(1), entry.BlockNumber) + require.False(t, entry.EndOfBlock) + require.Len(t, entry.Changeset, 1) + require.Equal(t, "evm", entry.Changeset[0].Name) + require.Equal(t, []byte("key"), entry.Changeset[0].Changeset.Pairs[0].Key) + require.Equal(t, []byte("value"), entry.Changeset[0].Changeset.Pairs[0].Value) + + // The end-of-block marker is folded into the block's single entry, not surfaced separately. + ok, err = it.Next() + require.NoError(t, err) + require.False(t, ok) +} + +func TestIteratorCoalescesMultipleWritesInOrder(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{ + makeChangeSet("b", []byte("k2"), []byte("v2")), + makeChangeSet("c", []byte("k3"), []byte("v3")), + })) + require.NoError(t, w.SignalEndOfBlock()) + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + + entry := it.Entry() + require.Equal(t, uint64(1), entry.BlockNumber) + require.False(t, entry.EndOfBlock) + // Three changesets total (1 from the first Write, 2 from the second), concatenated in write order. + require.Len(t, entry.Changeset, 3) + require.Equal(t, "a", entry.Changeset[0].Name) + require.Equal(t, "b", entry.Changeset[1].Name) + require.Equal(t, "c", entry.Changeset[2].Name) + + ok, err = it.Next() + require.NoError(t, err) + require.False(t, ok) +} diff --git a/sei-db/state_db/sc/flatkv/wal/metrics.go b/sei-db/state_db/sc/flatkv/wal/metrics.go new file mode 100644 index 0000000000..569c9d7ba7 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/wal/metrics.go @@ -0,0 +1,48 @@ +package wal + +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +// The name of the OpenTelemetry meter for flatKV WAL metrics. +const walMeterName = "seidb_flatkv_wal" + +var ( + walMeter = otel.Meter(walMeterName) + + // The number of blocks (end-of-block markers) written to the WAL. + walBlocksWritten = must(walMeter.Int64Counter( + "flatkv_wal_blocks_written", + metric.WithDescription("Number of blocks written to the flatKV WAL"), + metric.WithUnit("{count}"), + )) + + // The number of record bytes appended to the WAL (including framing). + walBytesWritten = must(walMeter.Int64Counter( + "flatkv_wal_bytes_written", + metric.WithDescription("Number of bytes written to the flatKV WAL"), + metric.WithUnit("By"), + )) + + // The number of WAL files sealed (rotated) after reaching the target size. + walFilesSealed = must(walMeter.Int64Counter( + "flatkv_wal_files_sealed", + metric.WithDescription("Number of flatKV WAL files sealed on rotation"), + metric.WithUnit("{count}"), + )) + + // The number of sealed WAL files deleted by pruning. + walFilesPruned = must(walMeter.Int64Counter( + "flatkv_wal_files_pruned", + metric.WithDescription("Number of flatKV WAL files removed by pruning"), + metric.WithUnit("{count}"), + )) +) + +func must[V any](v V, err error) V { + if err != nil { + panic(err) + } + return v +} From 855cbe9ab0808b60043b8ea731b2d1fe913f2677 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 2 Jul 2026 14:55:17 -0500 Subject: [PATCH 02/44] iterator improvements --- .../sc/flatkv/wal/flatkv_wal_config.go | 19 ++- .../sc/flatkv/wal/flatkv_wal_config_test.go | 6 + .../state_db/sc/flatkv/wal/flatkv_wal_impl.go | 2 +- .../sc/flatkv/wal/flatkv_wal_iterator.go | 158 +++++++++++++----- .../sc/flatkv/wal/flatkv_wal_iterator_test.go | 37 ++++ 5 files changed, 171 insertions(+), 51 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go index f6602034f1..1261451cdd 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go @@ -25,16 +25,22 @@ type FlatKVWALConfig struct { // When true, Flush calls fsync on the underlying file so that flushed data survives a power loss, not // merely a process crash. When false, Flush only flushes the in-process buffer to the OS. FsyncOnFlush bool + + // The number of blocks an iterator's reader thread may prefetch ahead of the consumer. A larger value + // keeps the reader busy while the consumer processes blocks, which matters for startup replay speed. + // Must be greater than 0. + IteratorPrefetchSize uint } // Constructor for a default flatKV WAL configuration. func DefaultFlatKVWALConfig(path string) *FlatKVWALConfig { return &FlatKVWALConfig{ - Path: path, - RequestBufferSize: 16, - WriteBufferSize: 16, - TargetFileSize: 64 * unit.MB, - FsyncOnFlush: true, + Path: path, + RequestBufferSize: 16, + WriteBufferSize: 16, + TargetFileSize: 64 * unit.MB, + FsyncOnFlush: true, + IteratorPrefetchSize: 32, } } @@ -47,5 +53,8 @@ func (c *FlatKVWALConfig) Validate() error { // A zero target would seal and rotate a fresh file after every single block. return fmt.Errorf("target file size must be greater than 0") } + if c.IteratorPrefetchSize == 0 { + return fmt.Errorf("iterator prefetch size must be greater than 0") + } return nil } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go index d38c98905e..3ae2b575a7 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go @@ -21,4 +21,10 @@ func TestConfigValidate(t *testing.T) { cfg.TargetFileSize = 0 require.Error(t, cfg.Validate()) }) + + t.Run("zero iterator prefetch size is rejected", func(t *testing.T) { + cfg := DefaultFlatKVWALConfig("/tmp/wal") + cfg.IteratorPrefetchSize = 0 + require.Error(t, cfg.Validate()) + }) } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go index 93ee226dd7..64b1318f63 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go @@ -327,7 +327,7 @@ func (w *flatKVWalImpl) Iterator(startingBlockNumber uint64) (FlatKVWalIterator, w.unpinBlock(pinned) return nil, fmt.Errorf("failed to flush before creating iterator: %w", err) } - return newWalIterator(w, startingBlockNumber, pinned), nil + return newWalIterator(w, startingBlockNumber, pinned, w.config.IteratorPrefetchSize), nil } // pinBlock registers a read lease on the given start block and returns the block actually pinned. Blocks until diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go index 915e811cc6..0cd1f73f3a 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go @@ -4,99 +4,167 @@ import ( "fmt" "os" "path/filepath" + "sync" ) var _ FlatKVWalIterator = (*walIterator)(nil) -// walIterator iterates the WAL a block at a time, in ascending block order. All records written for a block -// (one per Write call) plus its end-of-block marker are coalesced into a single entry whose Changeset is the -// concatenation, in write order, of every record's changesets. It loads one file at a time from disk, so its -// memory use is bounded by a single WAL file (plus the block being assembled). It re-lists the directory as it -// advances between files, so files rotated (mutable sealed) or created after construction are still observed. +// A block produced by the reader goroutine, or a terminal error. +type iteratorResult struct { + entry *FlatKVWalEntry + err error +} + +// walIterator iterates the WAL a block at a time, in ascending block order. A dedicated reader goroutine reads +// WAL files from disk, coalesces each block's records (one per Write call, plus its end-of-block marker) into a +// single entry, and pushes it onto a buffered channel; Next simply dequeues. Decoupling disk reads from the +// consumer keeps the reader busy while the consumer works, which matters for startup replay speed. The reader +// loads one file at a time, so its memory use is bounded by a single WAL file plus the prefetch buffer. +// +// A read lease (pinnedBlock) holds the files the reader needs against concurrent pruning; Close releases it. type walIterator struct { - // The WAL this iterator reads from. Set to nil by Close so the read lease is released exactly once. + // The WAL this iterator reads from. wal *flatKVWalImpl + // The lowest block the consumer asked for; blocks below it are skipped. start uint64 // The block pinned as this iterator's read lease, released on Close. pinnedBlock uint64 + // Coalesced blocks produced by the reader goroutine. Closed by the reader on clean EOF. + results chan iteratorResult + + // Closed by Close to tell the reader goroutine to stop early. + stop chan struct{} + + // Closed by the reader goroutine when it exits, so Close can wait for it. + readerExited chan struct{} + + // Ensures the shutdown sequence runs at most once. + closeOnce sync.Once + + // The entry returned by Entry, set by the most recent successful Next. Consumer-owned. + result *FlatKVWalEntry + + // Set once iteration is complete. Consumer-owned. + done bool + + // The following fields are owned exclusively by the reader goroutine. + // The smallest file index not yet consumed. nextIndex uint64 - // The records loaded from the current file, filtered to complete blocks at or beyond start. buffer []*FlatKVWalEntry // The position within buffer; -1 before the first record is read. pos int - - // The coalesced block entry returned by Entry, set by the most recent successful Next. - result *FlatKVWalEntry - - // Set once no further blocks remain. - done bool } -// newWalIterator creates an iterator over wal starting at startingBlockNumber. pinnedBlock is the read lease -// registered on the iterator's behalf, released by Close. -func newWalIterator(wal *flatKVWalImpl, startingBlockNumber uint64, pinnedBlock uint64) *walIterator { - return &walIterator{ - wal: wal, - start: startingBlockNumber, - pinnedBlock: pinnedBlock, - pos: -1, +// newWalIterator creates an iterator over wal starting at startingBlockNumber and launches its reader +// goroutine. pinnedBlock is the read lease registered on the iterator's behalf, released by Close. +// prefetch is the number of blocks the reader may buffer ahead of the consumer. +func newWalIterator(wal *flatKVWalImpl, startingBlockNumber uint64, pinnedBlock uint64, prefetch uint) *walIterator { + it := &walIterator{ + wal: wal, + start: startingBlockNumber, + pinnedBlock: pinnedBlock, + results: make(chan iteratorResult, prefetch), + stop: make(chan struct{}), + readerExited: make(chan struct{}), + pos: -1, } + go it.read() + return it } func (it *walIterator) Next() (bool, error) { if it.done { return false, nil } + result, ok := <-it.results + if !ok { + it.done = true + return false, nil + } + if result.err != nil { + it.done = true + return false, result.err + } + it.result = result.entry + return true, nil +} +func (it *walIterator) Entry() *FlatKVWalEntry { + return it.result +} + +func (it *walIterator) Close() error { + it.closeOnce.Do(func() { + close(it.stop) // tell the reader to stop if it is mid-read + <-it.readerExited // wait for it to actually exit before releasing resources + it.wal.unpinBlock(it.pinnedBlock) + }) + it.done = true + return nil +} + +// read is the reader goroutine: it produces coalesced blocks onto the results channel until the WAL is +// exhausted (then closes the channel), a read fails (then sends the error), or Close signals a stop. +func (it *walIterator) read() { + defer close(it.readerExited) + for { + block, ok, err := it.nextBlock() + if err != nil { + it.send(iteratorResult{err: err}) + return + } + if !ok { + close(it.results) + return + } + if !it.send(iteratorResult{entry: block}) { + return // Close signalled a stop + } + } +} + +// send pushes a result onto the channel, returning false if Close signalled a stop first. +func (it *walIterator) send(result iteratorResult) bool { + select { + case it.results <- result: + return true + case <-it.stop: + return false + } +} + +// nextBlock assembles the next block by draining records until it consumes that block's end-of-block marker. +// Returns ok=false once no records remain. +func (it *walIterator) nextBlock() (*FlatKVWalEntry, bool, error) { var block *FlatKVWalEntry for { record, ok, err := it.nextRecord() if err != nil { - it.done = true - return false, fmt.Errorf("failed to advance WAL iterator: %w", err) + return nil, false, err } if !ok { // End of stream. A complete block always ends with an end-of-block marker, so reaching here // mid-block should not happen; emit any assembled changes defensively rather than dropping them. - it.done = true if block != nil { - it.result = block - return true, nil + return block, true, nil } - return false, nil + return nil, false, nil } - if block == nil { block = &FlatKVWalEntry{BlockNumber: record.BlockNumber} } if record.EndOfBlock { - it.result = block - return true, nil + return block, true, nil } block.Changeset = append(block.Changeset, record.Changeset...) } } -func (it *walIterator) Entry() *FlatKVWalEntry { - return it.result -} - -func (it *walIterator) Close() error { - if it.wal != nil { - it.wal.unpinBlock(it.pinnedBlock) - it.wal = nil // release the lease exactly once, even if Close is called repeatedly - } - it.buffer = nil - it.result = nil - it.done = true - return nil -} - // nextRecord returns the next individual record (changeset or end-of-block marker) in ascending order, // advancing across files as needed. It returns ok=false once no further records remain. func (it *walIterator) nextRecord() (*FlatKVWalEntry, bool, error) { diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go index c01139e700..e39cd7d40b 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go @@ -44,6 +44,43 @@ func TestIteratorAcrossFiles(t *testing.T) { require.Equal(t, []uint64{2, 3, 4, 5}, collectBlocks(t, w, 2)) } +func TestIteratorWithTinyPrefetchBuffer(t *testing.T) { + // A prefetch buffer smaller than the number of blocks exercises reader backpressure: the reader must + // block on a full channel and resume as the consumer drains, without deadlocking or dropping blocks. + cfg := testConfig(t.TempDir()) + cfg.IteratorPrefetchSize = 1 + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 20; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, + collectBlocks(t, w, 1)) +} + +func TestIteratorCloseBeforeDrainDoesNotLeak(t *testing.T) { + // Closing an iterator before consuming it must unblock and shut down the reader goroutine cleanly. + cfg := testConfig(t.TempDir()) + cfg.IteratorPrefetchSize = 1 + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 20; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + // Consume just one block, then close while the reader is still mid-stream (blocked on the full buffer). + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, it.Close()) + require.NoError(t, it.Close()) // idempotent +} + func TestIteratorStopsBeforeIncompleteTail(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() From 6a1efd7fb4d145710d9ed554e5cd7953fa8dd514 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 2 Jul 2026 15:27:38 -0500 Subject: [PATCH 03/44] bugfixes --- .../state_db/sc/flatkv/wal/flatkv_wal_file.go | 96 +++++++-- .../state_db/sc/flatkv/wal/flatkv_wal_impl.go | 185 ++++++++++++++---- .../sc/flatkv/wal/flatkv_wal_impl_test.go | 42 ++++ .../sc/flatkv/wal/flatkv_wal_iterator.go | 61 +++--- .../sc/flatkv/wal/flatkv_wal_iterator_test.go | 85 ++++++++ 5 files changed, 383 insertions(+), 86 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go index de887c3698..8e14b0bb8c 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "fmt" "hash/crc32" + "io" "os" "path/filepath" "regexp" @@ -119,6 +120,14 @@ func newWalFile(directory string, index uint64) (*walFile, error) { return nil, fmt.Errorf("failed to create WAL file %s: %w", path, err) } + // Persist the new directory entry so a later fsync of the file's contents (via flush) cannot be undone by a + // power loss that drops the unsynced create. Without this, flushed data could be lost if the file is never + // sealed (a seal fsyncs the directory via the atomic rename) before a crash. + if err := util.SyncParentPath(path); err != nil { + _ = file.Close() + return nil, fmt.Errorf("failed to fsync WAL directory after creating %s: %w", path, err) + } + writer := bufio.NewWriter(file) header := append(append([]byte(nil), walFileMagic...), walFormatVersion) if _, err := writer.Write(header); err != nil { @@ -295,6 +304,26 @@ func readWalFile(path string) (*walFileContents, error) { return nil, fmt.Errorf("failed to read WAL file %s: %w", path, err) } + return parseWalFileData(data, parsed, path) +} + +// readWalFileFromHandle reads and parses a WAL file from an already-open handle, then closes the handle. The +// handle is opened by the writer goroutine (see openIteratorFile) so that neither a rename nor a removal can +// occur between resolving the file and opening it; the heavy read happens here, on the iterator's reader +// goroutine. parsed carries the file-name components the handle was opened for. +func readWalFileFromHandle(file *os.File, parsed parsedFileName) (*walFileContents, error) { + defer func() { _ = file.Close() }() + data, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read WAL file %s: %w", file.Name(), err) + } + return parseWalFileData(data, parsed, file.Name()) +} + +// parseWalFileData parses the raw bytes of a WAL file (already read into memory) into its intact entries, +// tolerating a torn trailing record. name is used only for error messages. It is shared by readWalFile (which +// reads by path) and the iterator (which reads through a file handle opened on the writer goroutine). +func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFileContents, error) { contents := &walFileContents{parsed: parsed} if len(data) < walHeaderSize { @@ -302,10 +331,10 @@ func readWalFile(path string) (*walFileContents, error) { return contents, nil } if !bytes.Equal(data[:len(walFileMagic)], walFileMagic) { - return nil, fmt.Errorf("WAL file %s has an invalid magic prefix", path) + return nil, fmt.Errorf("WAL file %s has an invalid magic prefix", name) } if version := data[len(walFileMagic)]; version != walFormatVersion { - return nil, fmt.Errorf("WAL file %s has unsupported format version %d", path, version) + return nil, fmt.Errorf("WAL file %s has unsupported format version %d", name, version) } contents.lastCompleteBlockOffset = walHeaderSize @@ -330,7 +359,7 @@ func readWalFile(path string) (*walFileContents, error) { entry, entryOK, err := DeserializeFlatKVWalEntry(payload) if err != nil { - return nil, fmt.Errorf("failed to deserialize record in %s: %w", path, err) + return nil, fmt.Errorf("failed to deserialize record in %s: %w", name, err) } if !entryOK { break // torn payload @@ -356,6 +385,41 @@ func readWalFile(path string) (*walFileContents, error) { return contents, nil } +// truncateAndSync truncates the file at path to size and fsyncs it, so the shorter length is durable on its +// own — before any subsequent rename. Without the fsync, a crash could persist a rename while losing the +// truncation, leaving a file whose name promises fewer blocks than its content actually holds. +func truncateAndSync(path string, size int64) error { + file, err := os.OpenFile(path, os.O_RDWR, 0) //nolint:gosec // caller-supplied path + if err != nil { + return fmt.Errorf("failed to open %s for truncation: %w", path, err) + } + if err := file.Truncate(size); err != nil { + _ = file.Close() + return fmt.Errorf("failed to truncate %s: %w", path, err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("failed to fsync %s after truncation: %w", path, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("failed to close %s after truncation: %w", path, err) + } + return nil +} + +// removeAndSyncDir removes the named file and fsyncs its parent directory, so the removal is durable before the +// caller proceeds. Callers rely on this to keep the sealed-file index sequence gap-free across a crash. +func removeAndSyncDir(directory string, name string) error { + path := filepath.Join(directory, name) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove WAL file %s: %w", path, err) + } + if err := util.SyncParentPath(path); err != nil { + return fmt.Errorf("failed to fsync directory after removing %s: %w", path, err) + } + return nil +} + // Seal an orphaned mutable file discovered on disk at startup (left behind by a crash before it could be // sealed). Any incomplete trailing block (records not terminated by an end-of-block marker) or torn trailing // record is truncated away first, so the sealed file ends cleanly on a block boundary. A file left with no @@ -368,13 +432,10 @@ func sealOrphanFile(directory string, name string) error { } if !contents.hasCompleteBlock { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove empty orphaned WAL file %s: %w", path, err) - } - return nil + return removeAndSyncDir(directory, name) } - if err := os.Truncate(path, contents.lastCompleteBlockOffset); err != nil { + if err := truncateAndSync(path, contents.lastCompleteBlockOffset); err != nil { return fmt.Errorf("failed to truncate orphaned WAL file %s: %w", path, err) } sealedPath := filepath.Join(directory, @@ -385,10 +446,13 @@ func sealOrphanFile(directory string, name string) error { return nil } -// Truncate a sealed file to drop every block beyond rollbackThrough, renaming it to reflect the reduced block -// range. A file whose blocks all lie beyond rollbackThrough is removed entirely. Used by the rollback -// constructor after all orphans have been sealed. -func rollbackFile(directory string, name string, rollbackThrough uint64) error { +// rollbackStraddlingFile handles the single sealed file that spans rollbackThrough: it truncates away every +// block beyond the rollback point and renames the file to reflect the reduced range. The truncation is fsynced +// before the rename (see truncateAndSync), so a crash can never leave the file's content holding blocks past +// the rollback point once the rename is durable — the iterator, which bounds sealed reads by content, would +// otherwise replay the discarded blocks. Files entirely beyond the rollback point are removed by the caller; +// this handles only the boundary file. +func rollbackStraddlingFile(directory string, name string, rollbackThrough uint64) error { path := filepath.Join(directory, name) contents, err := readWalFile(path) if err != nil { @@ -408,16 +472,14 @@ func rollbackFile(directory string, name string, rollbackThrough uint64) error { } if !kept { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove WAL file %s during rollback: %w", path, err) - } - return nil + // The content holds no complete block at or below the rollback point after all; drop the whole file. + return removeAndSyncDir(directory, name) } if lastKept == contents.lastBlock { return nil // nothing beyond the rollback point; leave the file untouched } - if err := os.Truncate(path, truncateTo); err != nil { + if err := truncateAndSync(path, truncateTo); err != nil { return fmt.Errorf("failed to truncate WAL file %s during rollback: %w", path, err) } newPath := filepath.Join(directory, diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go index 64b1318f63..e145c90476 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go @@ -50,17 +50,43 @@ type closeRequest struct { done chan error } -// pinRequest registers an iterator's read lease. The writer pins the lowest block the iterator will read -// (clamped up to the oldest stored block) so pruning cannot delete files it still needs, and replies with the -// block it actually pinned. The iterator passes that block back via unpinRequest when it closes. -type pinRequest struct { +// unpinRequest releases a read lease previously registered when an iterator was created. +type unpinRequest struct { + block uint64 +} + +// iteratorStartRequest asks the writer to construct an iterator. The writer flushes the mutable file (so the +// iterator's independent file handles observe all prior writes), registers the read lease, and builds the +// iterator, all on its own goroutine so construction is serialized with rotation/seal/prune. +type iteratorStartRequest struct { startBlock uint64 - reply chan uint64 + reply chan iteratorStartResponse } -// unpinRequest releases a read lease previously registered via pinRequest. -type unpinRequest struct { - block uint64 +// The iterator (or an error) produced by the writer in response to an iteratorStartRequest. +type iteratorStartResponse struct { + iterator *walIterator + err error +} + +// iteratorFileRequest asks the writer to open the WAL file with the smallest index >= minIndex and hand the +// open file handle back to the iterator's reader goroutine. Resolving and opening on the writer goroutine makes +// it impossible for a file to be renamed (sealed) or removed (pruned) between resolution and open: those +// mutations run on the same goroutine, and an already-open handle survives a later rename or unlink. +type iteratorFileRequest struct { + minIndex uint64 + start uint64 + reply chan iteratorFileResponse +} + +// The open file handle (or an error) produced by the writer in response to an iteratorFileRequest. When ok is +// true but file is nil, the resolved file lies entirely below the iterator's start block: the reader should +// advance past it without reading. +type iteratorFileResponse struct { + file *os.File + parsed parsedFileName + ok bool + err error } // The block range reported by GetStoredRange. @@ -72,6 +98,7 @@ type storedRange struct { // Bookkeeping for a sealed WAL file, owned by the writer goroutine. type sealedFileInfo struct { + index uint64 name string firstBlock uint64 lastBlock uint64 @@ -315,36 +342,26 @@ func (w *flatKVWalImpl) Prune(lowestBlockNumberToKeep uint64) error { return nil } -// Iterator returns an iterator over the WAL starting at startingBlockNumber. It registers a read lease first so -// pruning cannot delete files out from under the iterator, then flushes so all previously scheduled writes are -// visible. The lease is released by the iterator's Close. +// Iterator returns an iterator over the WAL starting at startingBlockNumber. Construction runs on the writer +// goroutine (see iteratorStartRequest): the writer flushes so all previously scheduled writes are visible, +// registers a read lease so pruning cannot delete files out from under the iterator, and builds the iterator. +// The lease is released by the iterator's Close. func (w *flatKVWalImpl) Iterator(startingBlockNumber uint64) (FlatKVWalIterator, error) { - pinned, err := w.pinBlock(startingBlockNumber) - if err != nil { - return nil, fmt.Errorf("failed to pin starting block %d: %w", startingBlockNumber, err) - } - if err := w.Flush(); err != nil { - w.unpinBlock(pinned) - return nil, fmt.Errorf("failed to flush before creating iterator: %w", err) - } - return newWalIterator(w, startingBlockNumber, pinned, w.config.IteratorPrefetchSize), nil -} - -// pinBlock registers a read lease on the given start block and returns the block actually pinned. Blocks until -// the writer has recorded the lease, so a subsequent Prune cannot race ahead of it. -func (w *flatKVWalImpl) pinBlock(startBlock uint64) (uint64, error) { - reply := make(chan uint64, 1) - if err := w.sendToSerializer(pinRequest{startBlock: startBlock, reply: reply}); err != nil { - return 0, err + reply := make(chan iteratorStartResponse, 1) + if err := w.sendToSerializer(iteratorStartRequest{startBlock: startingBlockNumber, reply: reply}); err != nil { + return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) } select { - case pinned := <-reply: - return pinned, nil + case resp := <-reply: + if resp.err != nil { + return nil, fmt.Errorf("failed to create iterator: %w", resp.err) + } + return resp.iterator, nil case <-w.ctx.Done(): if err := w.asyncError(); err != nil { - return 0, fmt.Errorf("pin aborted: %w", err) + return nil, fmt.Errorf("iterator creation aborted: %w", err) } - return 0, fmt.Errorf("pin aborted: %w", w.ctx.Err()) + return nil, fmt.Errorf("iterator creation aborted: %w", w.ctx.Err()) } } @@ -456,8 +473,10 @@ func (w *flatKVWalImpl) writerLoop() { w.fail(err) return } - case pinRequest: - m.reply <- w.pinLowestReadableBlock(m.startBlock) + case iteratorStartRequest: + m.reply <- w.startIterator(m.startBlock) + case iteratorFileRequest: + m.reply <- w.openIteratorFile(m.minIndex, m.start) case unpinRequest: w.releaseBlock(m.block) case closeRequest: @@ -490,13 +509,14 @@ func (w *flatKVWalImpl) appendRecord(m dataToBeWritten) error { // rotate seals the current mutable file, records its bookkeeping, and opens a fresh mutable file. It is only // called immediately after an end-of-block marker, so the mutable file ends on a block boundary. func (w *flatKVWalImpl) rotate() error { + index := w.mutableFile.index first := w.mutableFile.firstBlock last := w.mutableFile.lastCompleteBlock sealedName, err := w.mutableFile.seal() if err != nil { return fmt.Errorf("failed to seal WAL file during rotation: %w", err) } - w.sealedFiles.PushBack(&sealedFileInfo{name: sealedName, firstBlock: first, lastBlock: last}) + w.sealedFiles.PushBack(&sealedFileInfo{index: index, name: sealedName, firstBlock: first, lastBlock: last}) walFilesSealed.Add(w.ctx, 1) mutable, err := newWalFile(w.config.Path, w.nextIndex) @@ -535,6 +555,66 @@ func (w *flatKVWalImpl) pruneSealedFiles(pruneThrough uint64) error { return nil } +// startIterator builds an iterator on the writer goroutine. It flushes the mutable file so the iterator's +// independent file handles observe every previously scheduled write, registers the read lease, and constructs +// the iterator (which launches its reader goroutine). Running here serializes construction with rotation, seal, +// and prune, so the iterator's view of the on-disk files is consistent from its very first read. +func (w *flatKVWalImpl) startIterator(startBlock uint64) iteratorStartResponse { + if err := w.mutableFile.flush(w.config.FsyncOnFlush); err != nil { + return iteratorStartResponse{err: fmt.Errorf("failed to flush before creating iterator: %w", err)} + } + pinned := w.pinLowestReadableBlock(startBlock) + it := newWalIterator(w, startBlock, pinned, w.config.IteratorPrefetchSize) + return iteratorStartResponse{iterator: it} +} + +// openIteratorFile resolves the WAL file with the smallest index >= minIndex and opens it for an iterator's +// reader goroutine. Both the resolution (against the writer's in-memory bookkeeping) and the open run on the +// writer goroutine, so no rename (seal) or removal (prune) can occur between them; the returned handle stays +// valid for the reader even if the file is later sealed or pruned. The reader owns closing the handle. +func (w *flatKVWalImpl) openIteratorFile(minIndex uint64, start uint64) iteratorFileResponse { + name, parsed, ok := w.resolveFileByMinIndex(minIndex) + if !ok { + return iteratorFileResponse{ok: false} + } + // A sealed file whose highest block is below the start block holds nothing the iterator wants; report it so + // the reader can advance past it, but do not bother opening it. + if parsed.sealed && parsed.lastBlock < start { + return iteratorFileResponse{parsed: parsed, ok: true} + } + path := filepath.Join(w.config.Path, name) + file, err := os.Open(path) //nolint:gosec // path derived from the writer's own bookkeeping + if err != nil { + return iteratorFileResponse{err: fmt.Errorf("failed to open WAL file %s for iteration: %w", name, err)} + } + return iteratorFileResponse{file: file, parsed: parsed, ok: true} +} + +// resolveFileByMinIndex returns the WAL file with the smallest index >= minIndex, drawn from the writer's live +// bookkeeping (the sealed-file deque plus the mutable file) rather than a directory scan. Returns ok=false when +// no such file exists. Owned by the writer goroutine. +func (w *flatKVWalImpl) resolveFileByMinIndex(minIndex uint64) (string, parsedFileName, bool) { + // Sealed files are held in ascending index order, so the first one at or above minIndex is the smallest. + for _, info := range w.sealedFiles.Iterator() { + if info.index < minIndex { + continue + } + parsed := parsedFileName{ + index: info.index, + firstBlock: info.firstBlock, + lastBlock: info.lastBlock, + sealed: true, + } + return info.name, parsed, true + } + // The mutable file always has the highest index, so it is only a match once no sealed file qualifies. + if w.mutableFile.index >= minIndex { + return unsealedFileName(w.mutableFile.index), + parsedFileName{index: w.mutableFile.index, sealed: false}, true + } + return "", parsedFileName{}, false +} + // pinLowestReadableBlock records a read lease and returns the pinned block. An iterator reads blocks at or // above startBlock but never below the oldest block actually stored, so the lease is clamped up to that: a // stale low start must not pin files that no longer exist (or wedge pruning forever). @@ -625,12 +705,20 @@ func recoverOrphans(directory string) error { return nil } -// rollbackDirectory drops all data beyond rollbackThrough from every sealed file. Assumes orphans are sealed. +// rollbackDirectory drops all data beyond rollbackThrough from the sealed files. Assumes orphans are already +// sealed. Files are processed highest-index-first: the files entirely beyond the rollback point (a suffix of +// the index sequence) are removed one at a time, each removal made durable before the next, and finally the +// single file straddling the rollback point is truncated. This ordering guarantees that a crash mid-rollback +// always leaves a contiguous prefix of files — never a gap that scanSealedFiles would reject — mirroring the +// contiguous-suffix guarantee that pruning provides from the other end. func rollbackDirectory(directory string, rollbackThrough uint64) error { entries, err := os.ReadDir(directory) if err != nil { return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) } + + sealed := make([]parsedFileName, 0, len(entries)) + names := make(map[uint64]string, len(entries)) for _, entry := range entries { if entry.IsDir() { continue @@ -639,8 +727,26 @@ func rollbackDirectory(directory string, rollbackThrough uint64) error { if !ok || !parsed.sealed { continue } - if err := rollbackFile(directory, entry.Name(), rollbackThrough); err != nil { - return fmt.Errorf("failed to roll back %s: %w", entry.Name(), err) + sealed = append(sealed, parsed) + names[parsed.index] = entry.Name() + } + sort.Slice(sealed, func(i int, j int) bool { return sealed[i].index > sealed[j].index }) + + for _, parsed := range sealed { + if parsed.lastBlock <= rollbackThrough { + // This file lies entirely at or below the rollback point; so does every lower-indexed file. Done. + break + } + if parsed.firstBlock > rollbackThrough { + // Entirely beyond the rollback point: remove the whole file, durably, before the next-lower one. + if err := removeAndSyncDir(directory, names[parsed.index]); err != nil { + return fmt.Errorf("failed to roll back %s: %w", names[parsed.index], err) + } + continue + } + // Straddles the rollback point: truncate away the blocks beyond it. This is the last file to process. + if err := rollbackStraddlingFile(directory, names[parsed.index], rollbackThrough); err != nil { + return fmt.Errorf("failed to roll back %s: %w", names[parsed.index], err) } } return nil @@ -679,7 +785,8 @@ func scanSealedFiles(directory string) (*util.RandomAccessDeque[*sealedFileInfo] "WAL is corrupt: sealed file indices are not contiguous (gap between %d and %d)", parsed[i-1].index, p.index) } - sealedFiles.PushBack(&sealedFileInfo{name: names[p.index], firstBlock: p.firstBlock, lastBlock: p.lastBlock}) + sealedFiles.PushBack(&sealedFileInfo{ + index: p.index, name: names[p.index], firstBlock: p.firstBlock, lastBlock: p.lastBlock}) nextIndex = p.index + 1 } return sealedFiles, nextIndex, nil diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go index 4abd420d7e..87a1ce94ad 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go @@ -465,4 +465,46 @@ func TestRollbackConstructor(t *testing.T) { require.NoError(t, err) require.Equal(t, uint64(4), end) }) + + // After a rollback, a subsequent *normal* open (not another rollback) must observe exactly the rolled-back + // range. This is the path that would expose a name/content mismatch left by a non-crash-safe rollback: + // GetStoredRange is name-derived while iteration is content-bound, so the two agree only if the truncation + // and rename were applied consistently. Exercises both rollback shapes: whole-file removal and in-file + // truncation of the straddling file. + t.Run("rolled-back state is consistent under a normal reopen", func(t *testing.T) { + for _, tc := range []struct { + name string + targetSize uint + }{ + {"whole-file removal", 1}, // one block per file: rollback removes whole trailing files + {"in-file truncation", 64 * 1024 * 1024}, // all blocks in one file: rollback truncates it in place + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = tc.targetSize + + w := openWAL(t, cfg) + for block := uint64(1); block <= 6; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + w2, err := NewFlatKVWALWithRollback(cfg, 3) + require.NoError(t, err) + require.NoError(t, w2.Close()) + + // Reopen normally; the rollback must have durably and consistently reduced the range to [1,3]. + w3 := openWAL(t, cfg) + defer func() { require.NoError(t, w3.Close()) }() + + ok, start, end, err := w3.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(3), end) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w3, 1)) + }) + } + }) } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go index 0cd1f73f3a..830771b3b0 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go @@ -3,7 +3,6 @@ package wal import ( "fmt" "os" - "path/filepath" "sync" ) @@ -184,11 +183,12 @@ func (it *walIterator) nextRecord() (*FlatKVWalEntry, bool, error) { } } -// loadNextFile finds the next file at or beyond nextIndex, loads its records (filtered to complete blocks at -// or beyond start), and advances nextIndex. It returns false when no further file exists. A file entirely -// below start is skipped without being read; a file that yields no matching records leaves buffer empty. +// loadNextFile asks the writer for the next file at or beyond nextIndex (opened on the writer goroutine so it +// cannot be renamed or removed out from under the read), loads its records (filtered to complete blocks at or +// beyond start), and advances nextIndex. It returns false when no further file exists. A file entirely below +// start is skipped without being read; a file that yields no matching records leaves buffer empty. func (it *walIterator) loadNextFile() (bool, error) { - name, parsed, ok, err := findFileByMinIndex(it.wal.config.Path, it.nextIndex) + file, parsed, ok, err := it.openNextFile(it.nextIndex) if err != nil { return false, err } @@ -198,13 +198,13 @@ func (it *walIterator) loadNextFile() (bool, error) { it.nextIndex = parsed.index + 1 it.buffer = nil - if parsed.sealed && parsed.lastBlock < it.start { - return true, nil // entirely below the start block; skip without reading + if file == nil { + return true, nil // entirely below the start block; skipped without being opened } - contents, err := readWalFile(filepath.Join(it.wal.config.Path, name)) + contents, err := readWalFileFromHandle(file, parsed) if err != nil { - return false, fmt.Errorf("failed to read WAL file %s during iteration: %w", name, err) + return false, fmt.Errorf("failed to read WAL file (index %d) during iteration: %w", parsed.index, err) } if !contents.hasCompleteBlock { return true, nil @@ -218,29 +218,30 @@ func (it *walIterator) loadNextFile() (bool, error) { return true, nil } -// findFileByMinIndex returns the WAL file with the smallest index greater than or equal to minIndex. -func findFileByMinIndex(directory string, minIndex uint64) (string, parsedFileName, bool, error) { - entries, err := os.ReadDir(directory) - if err != nil { - return "", parsedFileName{}, false, fmt.Errorf("failed to read WAL directory %s: %w", directory, err) +// openNextFile asks the writer goroutine to resolve and open the WAL file with the smallest index >= minIndex. +// Both steps run on the writer, so the returned handle cannot be invalidated by a concurrent seal or prune. A +// nil handle with ok=true means the file lies entirely below start and should be skipped. +func (it *walIterator) openNextFile(minIndex uint64) (*os.File, parsedFileName, bool, error) { + reply := make(chan iteratorFileResponse, 1) + req := iteratorFileRequest{minIndex: minIndex, start: it.start, reply: reply} + if err := it.wal.sendToSerializer(req); err != nil { + return nil, parsedFileName{}, false, fmt.Errorf("failed to request WAL file for iteration: %w", err) } - - var bestName string - var best parsedFileName - found := false - for _, entry := range entries { - if entry.IsDir() { - continue - } - parsed, ok := parseFileName(entry.Name()) - if !ok || parsed.index < minIndex { - continue + select { + case resp := <-reply: + return resp.file, resp.parsed, resp.ok, resp.err + case <-it.wal.ctx.Done(): + // The WAL is shutting down. Drain any handle the writer may have already opened so it is not leaked. + select { + case resp := <-reply: + if resp.file != nil { + _ = resp.file.Close() + } + default: } - if !found || parsed.index < best.index { - best = parsed - bestName = entry.Name() - found = true + if err := it.wal.asyncError(); err != nil { + return nil, parsedFileName{}, false, fmt.Errorf("WAL failed during iteration: %w", err) } + return nil, parsedFileName{}, false, fmt.Errorf("WAL shut down during iteration: %w", it.wal.ctx.Err()) } - return bestName, best, found, nil } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go index e39cd7d40b..0b38d16c8c 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go @@ -1,6 +1,8 @@ package wal import ( + "fmt" + "sync" "testing" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -124,6 +126,89 @@ func TestIteratorYieldsChangesetContents(t *testing.T) { require.False(t, ok) } +// TestConcurrentIterationDuringRotation hammers the writer with rotate-on-every-block churn while several +// iterators read concurrently. File resolution and opening happen on the writer goroutine, so a file can never +// be renamed (sealed) out from under an in-flight read; every iteration must be error-free and gap-free. +func TestConcurrentIterationDuringRotation(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.TargetFileSize = 1 // rotate (rename) after every block, maximizing the seal/rename churn + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + const totalBlocks = 300 + const readers = 4 + const iterationsPerReader = 40 + + var wg sync.WaitGroup + + writeErr := make(chan error, 1) + wg.Add(1) + go func() { + defer wg.Done() + for block := uint64(1); block <= totalBlocks; block++ { + cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} + if err := w.Write(block, cs); err != nil { + writeErr <- err + return + } + if err := w.SignalEndOfBlock(); err != nil { + writeErr <- err + return + } + } + writeErr <- nil + }() + + readerErr := make(chan error, readers) + for r := 0; r < readers; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterationsPerReader; i++ { + if err := drainContiguousFrom(w, 1); err != nil { + readerErr <- err + return + } + } + readerErr <- nil + }() + } + + wg.Wait() + require.NoError(t, <-writeErr) + for r := 0; r < readers; r++ { + require.NoError(t, <-readerErr) + } +} + +// drainContiguousFrom fully consumes an iterator anchored at start, verifying the yielded blocks form a +// gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not have +// produced start yet). Returns the first error encountered. +func drainContiguousFrom(w FlatKVWAL, start uint64) error { + it, err := w.Iterator(start) + if err != nil { + return fmt.Errorf("create iterator: %w", err) + } + prev := start - 1 + for { + ok, err := it.Next() + if err != nil { + _ = it.Close() + return fmt.Errorf("next: %w", err) + } + if !ok { + break + } + b := it.Entry().BlockNumber + if b != prev+1 { + _ = it.Close() + return fmt.Errorf("non-contiguous iteration: got block %d after %d (start %d)", b, prev, start) + } + prev = b + } + return it.Close() +} + func TestIteratorCoalescesMultipleWritesInOrder(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() From d86fdc56cd6eddade4dd60caa3fc19c8eafe94fd Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 2 Jul 2026 15:41:00 -0500 Subject: [PATCH 04/44] bugfixes --- sei-db/state_db/sc/flatkv/wal/flatkv_wal.go | 3 +- .../state_db/sc/flatkv/wal/flatkv_wal_file.go | 6 +- .../state_db/sc/flatkv/wal/flatkv_wal_impl.go | 93 +++-------- .../sc/flatkv/wal/flatkv_wal_iterator.go | 147 +++++++++++------- .../sc/flatkv/wal/flatkv_wal_iterator_test.go | 5 +- 5 files changed, 120 insertions(+), 134 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go index b6de7573da..b6ea7ac31e 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go @@ -55,8 +55,7 @@ type FlatKVWAL interface { Prune(lowestBlockNumberToKeep uint64) error // Create an iterator over the WAL, starting at the given block number. Iterates all data passed to Write() - // before this call, but may also iterate over data after this call if the iterator is not fully consumed before - // more data is written. + // before this call. Data written after this call is not iterated. Iterator(startingBlockNumber uint64) (FlatKVWalIterator, error) // Close the WAL, flushing any pending writes and releasing resources. diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go index 8e14b0bb8c..c47d866be4 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go @@ -308,9 +308,9 @@ func readWalFile(path string) (*walFileContents, error) { } // readWalFileFromHandle reads and parses a WAL file from an already-open handle, then closes the handle. The -// handle is opened by the writer goroutine (see openIteratorFile) so that neither a rename nor a removal can -// occur between resolving the file and opening it; the heavy read happens here, on the iterator's reader -// goroutine. parsed carries the file-name components the handle was opened for. +// mutable file's handle is pre-opened on the writer goroutine (see startIterator) so a later rename cannot +// invalidate it; sealed files are opened lazily by name (see walIterator.openFile). Either way the heavy read +// happens here, on the iterator's reader goroutine. parsed carries the file-name components for error context. func readWalFileFromHandle(file *os.File, parsed parsedFileName) (*walFileContents, error) { defer func() { _ = file.Close() }() data, err := io.ReadAll(file) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go index e145c90476..38d71f2acc 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go @@ -56,8 +56,8 @@ type unpinRequest struct { } // iteratorStartRequest asks the writer to construct an iterator. The writer flushes the mutable file (so the -// iterator's independent file handles observe all prior writes), registers the read lease, and builds the -// iterator, all on its own goroutine so construction is serialized with rotation/seal/prune. +// iterator observes all prior writes), snapshots the current set of files, registers the read lease, and builds +// the iterator, all on its own goroutine so construction is serialized with rotation/seal/prune. type iteratorStartRequest struct { startBlock uint64 reply chan iteratorStartResponse @@ -69,26 +69,6 @@ type iteratorStartResponse struct { err error } -// iteratorFileRequest asks the writer to open the WAL file with the smallest index >= minIndex and hand the -// open file handle back to the iterator's reader goroutine. Resolving and opening on the writer goroutine makes -// it impossible for a file to be renamed (sealed) or removed (pruned) between resolution and open: those -// mutations run on the same goroutine, and an already-open handle survives a later rename or unlink. -type iteratorFileRequest struct { - minIndex uint64 - start uint64 - reply chan iteratorFileResponse -} - -// The open file handle (or an error) produced by the writer in response to an iteratorFileRequest. When ok is -// true but file is nil, the resolved file lies entirely below the iterator's start block: the reader should -// advance past it without reading. -type iteratorFileResponse struct { - file *os.File - parsed parsedFileName - ok bool - err error -} - // The block range reported by GetStoredRange. type storedRange struct { ok bool @@ -475,8 +455,6 @@ func (w *flatKVWalImpl) writerLoop() { } case iteratorStartRequest: m.reply <- w.startIterator(m.startBlock) - case iteratorFileRequest: - m.reply <- w.openIteratorFile(m.minIndex, m.start) case unpinRequest: w.releaseBlock(m.block) case closeRequest: @@ -555,64 +533,39 @@ func (w *flatKVWalImpl) pruneSealedFiles(pruneThrough uint64) error { return nil } -// startIterator builds an iterator on the writer goroutine. It flushes the mutable file so the iterator's -// independent file handles observe every previously scheduled write, registers the read lease, and constructs -// the iterator (which launches its reader goroutine). Running here serializes construction with rotation, seal, -// and prune, so the iterator's view of the on-disk files is consistent from its very first read. +// startIterator builds an iterator on the writer goroutine. It flushes the mutable file so the iterator's file +// handles observe every previously scheduled write, snapshots the current set of files in ascending block +// order, registers the read lease, and constructs the iterator (which launches its reader goroutine). Running +// here serializes construction with rotation, seal, and prune, so the snapshot is a consistent point-in-time +// view: sealed files have immutable names (opened lazily by the reader, protected from pruning by the lease), +// and the mutable file is pre-opened now so a later seal/rename cannot invalidate its path — an open handle +// survives a rename. func (w *flatKVWalImpl) startIterator(startBlock uint64) iteratorStartResponse { if err := w.mutableFile.flush(w.config.FsyncOnFlush); err != nil { return iteratorStartResponse{err: fmt.Errorf("failed to flush before creating iterator: %w", err)} } - pinned := w.pinLowestReadableBlock(startBlock) - it := newWalIterator(w, startBlock, pinned, w.config.IteratorPrefetchSize) - return iteratorStartResponse{iterator: it} -} -// openIteratorFile resolves the WAL file with the smallest index >= minIndex and opens it for an iterator's -// reader goroutine. Both the resolution (against the writer's in-memory bookkeeping) and the open run on the -// writer goroutine, so no rename (seal) or removal (prune) can occur between them; the returned handle stays -// valid for the reader even if the file is later sealed or pruned. The reader owns closing the handle. -func (w *flatKVWalImpl) openIteratorFile(minIndex uint64, start uint64) iteratorFileResponse { - name, parsed, ok := w.resolveFileByMinIndex(minIndex) - if !ok { - return iteratorFileResponse{ok: false} - } - // A sealed file whose highest block is below the start block holds nothing the iterator wants; report it so - // the reader can advance past it, but do not bother opening it. - if parsed.sealed && parsed.lastBlock < start { - return iteratorFileResponse{parsed: parsed, ok: true} - } - path := filepath.Join(w.config.Path, name) - file, err := os.Open(path) //nolint:gosec // path derived from the writer's own bookkeeping - if err != nil { - return iteratorFileResponse{err: fmt.Errorf("failed to open WAL file %s for iteration: %w", name, err)} - } - return iteratorFileResponse{file: file, parsed: parsed, ok: true} -} - -// resolveFileByMinIndex returns the WAL file with the smallest index >= minIndex, drawn from the writer's live -// bookkeeping (the sealed-file deque plus the mutable file) rather than a directory scan. Returns ok=false when -// no such file exists. Owned by the writer goroutine. -func (w *flatKVWalImpl) resolveFileByMinIndex(minIndex uint64) (string, parsedFileName, bool) { - // Sealed files are held in ascending index order, so the first one at or above minIndex is the smallest. + files := make([]iteratorFile, 0, w.sealedFiles.Size()+1) for _, info := range w.sealedFiles.Iterator() { - if info.index < minIndex { - continue - } - parsed := parsedFileName{ + files = append(files, iteratorFile{ index: info.index, + name: info.name, firstBlock: info.firstBlock, lastBlock: info.lastBlock, sealed: true, - } - return info.name, parsed, true + }) } - // The mutable file always has the highest index, so it is only a match once no sealed file qualifies. - if w.mutableFile.index >= minIndex { - return unsealedFileName(w.mutableFile.index), - parsedFileName{index: w.mutableFile.index, sealed: false}, true + + mutablePath := filepath.Join(w.config.Path, unsealedFileName(w.mutableFile.index)) + handle, err := os.Open(mutablePath) //nolint:gosec // path derived from the writer's own bookkeeping + if err != nil { + return iteratorStartResponse{err: fmt.Errorf("failed to open mutable WAL file for iteration: %w", err)} } - return "", parsedFileName{}, false + files = append(files, iteratorFile{index: w.mutableFile.index, sealed: false, handle: handle}) + + pinned := w.pinLowestReadableBlock(startBlock) + it := newWalIterator(w, startBlock, pinned, files, w.config.IteratorPrefetchSize) + return iteratorStartResponse{iterator: it} } // pinLowestReadableBlock records a read lease and returns the pinned block. An iterator reads blocks at or diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go index 830771b3b0..d39a908334 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go @@ -3,6 +3,7 @@ package wal import ( "fmt" "os" + "path/filepath" "sync" ) @@ -14,13 +15,28 @@ type iteratorResult struct { err error } +// iteratorFile is one entry in an iterator's file snapshot, captured on the writer goroutine when the iterator +// is created (see startIterator). Sealed files carry their immutable name and are opened lazily by the reader; +// the single mutable file carries a handle pre-opened on the writer goroutine so a later seal/rename cannot +// invalidate its path (an open handle survives a rename). +type iteratorFile struct { + index uint64 + name string + firstBlock uint64 + lastBlock uint64 + sealed bool + handle *os.File +} + // walIterator iterates the WAL a block at a time, in ascending block order. A dedicated reader goroutine reads // WAL files from disk, coalesces each block's records (one per Write call, plus its end-of-block marker) into a // single entry, and pushes it onto a buffered channel; Next simply dequeues. Decoupling disk reads from the // consumer keeps the reader busy while the consumer works, which matters for startup replay speed. The reader // loads one file at a time, so its memory use is bounded by a single WAL file plus the prefetch buffer. // -// A read lease (pinnedBlock) holds the files the reader needs against concurrent pruning; Close releases it. +// The set of files to read is snapshotted once at creation (files), so the reader walks it in O(n) without +// re-scanning the directory. A read lease (pinnedBlock) holds the files the reader needs against concurrent +// pruning; Close releases it. type walIterator struct { // The WAL this iterator reads from. wal *flatKVWalImpl @@ -51,8 +67,10 @@ type walIterator struct { // The following fields are owned exclusively by the reader goroutine. - // The smallest file index not yet consumed. - nextIndex uint64 + // The point-in-time snapshot of files to read, in ascending block order. Set once at construction. + files []iteratorFile + // The index into files of the next file to load. + filePos int // The records loaded from the current file, filtered to complete blocks at or beyond start. buffer []*FlatKVWalEntry // The position within buffer; -1 before the first record is read. @@ -60,9 +78,16 @@ type walIterator struct { } // newWalIterator creates an iterator over wal starting at startingBlockNumber and launches its reader -// goroutine. pinnedBlock is the read lease registered on the iterator's behalf, released by Close. -// prefetch is the number of blocks the reader may buffer ahead of the consumer. -func newWalIterator(wal *flatKVWalImpl, startingBlockNumber uint64, pinnedBlock uint64, prefetch uint) *walIterator { +// goroutine. pinnedBlock is the read lease registered on the iterator's behalf, released by Close. files is the +// snapshot of files to read (captured on the writer goroutine). prefetch is the number of blocks the reader may +// buffer ahead of the consumer. +func newWalIterator( + wal *flatKVWalImpl, + startingBlockNumber uint64, + pinnedBlock uint64, + files []iteratorFile, + prefetch uint, +) *walIterator { it := &walIterator{ wal: wal, start: startingBlockNumber, @@ -70,6 +95,7 @@ func newWalIterator(wal *flatKVWalImpl, startingBlockNumber uint64, pinnedBlock results: make(chan iteratorResult, prefetch), stop: make(chan struct{}), readerExited: make(chan struct{}), + files: files, pos: -1, } go it.read() @@ -111,6 +137,7 @@ func (it *walIterator) Close() error { // exhausted (then closes the channel), a read fails (then sends the error), or Close signals a stop. func (it *walIterator) read() { defer close(it.readerExited) + defer it.closeUnreadHandles() // runs before readerExited is signalled, so Close never races these handles for { block, ok, err := it.nextBlock() if err != nil { @@ -183,65 +210,71 @@ func (it *walIterator) nextRecord() (*FlatKVWalEntry, bool, error) { } } -// loadNextFile asks the writer for the next file at or beyond nextIndex (opened on the writer goroutine so it -// cannot be renamed or removed out from under the read), loads its records (filtered to complete blocks at or -// beyond start), and advances nextIndex. It returns false when no further file exists. A file entirely below -// start is skipped without being read; a file that yields no matching records leaves buffer empty. +// loadNextFile walks the file snapshot from filePos, loading the next file's records (filtered to complete +// blocks at or beyond start) into buffer and advancing filePos. It returns false when the snapshot is +// exhausted. Sealed files entirely below start are skipped without being opened; a file that yields no matching +// records leaves buffer empty (still reported as loaded). func (it *walIterator) loadNextFile() (bool, error) { - file, parsed, ok, err := it.openNextFile(it.nextIndex) - if err != nil { - return false, err - } - if !ok { - return false, nil - } - it.nextIndex = parsed.index + 1 - it.buffer = nil + for { + if it.filePos >= len(it.files) { + return false, nil + } + f := &it.files[it.filePos] + it.filePos++ + it.buffer = nil - if file == nil { - return true, nil // entirely below the start block; skipped without being opened - } + if f.sealed && f.lastBlock < it.start { + continue // entirely below the start block; skip without opening + } - contents, err := readWalFileFromHandle(file, parsed) - if err != nil { - return false, fmt.Errorf("failed to read WAL file (index %d) during iteration: %w", parsed.index, err) - } - if !contents.hasCompleteBlock { - return true, nil - } - for _, entry := range contents.entries { - if entry.BlockNumber < it.start || entry.BlockNumber > contents.lastCompleteBlock { - continue + handle, err := it.openFile(f) + if err != nil { + return false, err } - it.buffer = append(it.buffer, entry) + + parsed := parsedFileName{index: f.index, firstBlock: f.firstBlock, lastBlock: f.lastBlock, sealed: f.sealed} + contents, err := readWalFileFromHandle(handle, parsed) + if err != nil { + return false, fmt.Errorf("failed to read WAL file (index %d) during iteration: %w", f.index, err) + } + if !contents.hasCompleteBlock { + return true, nil + } + for _, entry := range contents.entries { + if entry.BlockNumber < it.start || entry.BlockNumber > contents.lastCompleteBlock { + continue + } + it.buffer = append(it.buffer, entry) + } + return true, nil } - return true, nil } -// openNextFile asks the writer goroutine to resolve and open the WAL file with the smallest index >= minIndex. -// Both steps run on the writer, so the returned handle cannot be invalidated by a concurrent seal or prune. A -// nil handle with ok=true means the file lies entirely below start and should be skipped. -func (it *walIterator) openNextFile(minIndex uint64) (*os.File, parsedFileName, bool, error) { - reply := make(chan iteratorFileResponse, 1) - req := iteratorFileRequest{minIndex: minIndex, start: it.start, reply: reply} - if err := it.wal.sendToSerializer(req); err != nil { - return nil, parsedFileName{}, false, fmt.Errorf("failed to request WAL file for iteration: %w", err) +// openFile returns a handle for a snapshot entry. The mutable file was pre-opened on the writer goroutine (its +// handle is consumed here, so it is not double-closed by closeUnreadHandles). Sealed files have immutable names +// and are opened lazily; the read lease keeps them alive against pruning, so the open cannot miss the file. +func (it *walIterator) openFile(f *iteratorFile) (*os.File, error) { + if f.handle != nil { + handle := f.handle + f.handle = nil + return handle, nil } - select { - case resp := <-reply: - return resp.file, resp.parsed, resp.ok, resp.err - case <-it.wal.ctx.Done(): - // The WAL is shutting down. Drain any handle the writer may have already opened so it is not leaked. - select { - case resp := <-reply: - if resp.file != nil { - _ = resp.file.Close() - } - default: - } - if err := it.wal.asyncError(); err != nil { - return nil, parsedFileName{}, false, fmt.Errorf("WAL failed during iteration: %w", err) + path := filepath.Join(it.wal.config.Path, f.name) + handle, err := os.Open(path) //nolint:gosec // path derived from the writer's file snapshot + if err != nil { + return nil, fmt.Errorf("failed to open WAL file %s during iteration: %w", f.name, err) + } + return handle, nil +} + +// closeUnreadHandles closes any pre-opened handles the reader never consumed (e.g. when Close stops iteration +// before the mutable file is reached, or a read fails first), so they are not leaked. Runs on the reader +// goroutine as it exits; consumed handles have already been nil'd, so none is closed twice. +func (it *walIterator) closeUnreadHandles() { + for i := range it.files { + if it.files[i].handle != nil { + _ = it.files[i].handle.Close() + it.files[i].handle = nil } - return nil, parsedFileName{}, false, fmt.Errorf("WAL shut down during iteration: %w", it.wal.ctx.Err()) } } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go index 0b38d16c8c..9a20a13d8d 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go @@ -127,8 +127,9 @@ func TestIteratorYieldsChangesetContents(t *testing.T) { } // TestConcurrentIterationDuringRotation hammers the writer with rotate-on-every-block churn while several -// iterators read concurrently. File resolution and opening happen on the writer goroutine, so a file can never -// be renamed (sealed) out from under an in-flight read; every iteration must be error-free and gap-free. +// iterators read concurrently. Each iterator snapshots its file set at creation on the writer goroutine (sealed +// files by immutable name, the mutable file by a pre-opened handle), so a file can never be renamed (sealed) +// out from under an in-flight read; every iteration must be error-free and gap-free. func TestConcurrentIterationDuringRotation(t *testing.T) { cfg := testConfig(t.TempDir()) cfg.TargetFileSize = 1 // rotate (rename) after every block, maximizing the seal/rename churn From c3207daefa153b07d27cf03f6a6408d431d1102a Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 2 Jul 2026 16:18:31 -0500 Subject: [PATCH 05/44] bugfixes --- .../state_db/sc/flatkv/wal/flatkv_wal_file.go | 41 +++++++++ .../state_db/sc/flatkv/wal/flatkv_wal_impl.go | 58 +++++++++---- .../sc/flatkv/wal/flatkv_wal_iterator.go | 39 ++------- .../sc/flatkv/wal/flatkv_wal_iterator_test.go | 84 ++++++++++++++++++- 4 files changed, 172 insertions(+), 50 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go index c47d866be4..713db7aae2 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go @@ -193,6 +193,47 @@ func (f *walFile) writeEntry(entry *FlatKVWalEntry) error { return f.writeRecord(frameRecord(payload), entry.BlockNumber, entry.EndOfBlock) } +// readIncompleteTail returns the raw framed bytes of the in-progress block — everything written past the +// last end-of-block marker — so a caller sealing the file for iteration can carry those records into a +// fresh file rather than losing them to the seal's truncation. Returns nil when the file already ends on a +// block boundary. Only meaningful when hasCompleteBlock is true (completeSize marks the last boundary). +func (f *walFile) readIncompleteTail() ([]byte, error) { + if f.size <= f.completeSize { + return nil, nil + } + if err := f.writer.Flush(); err != nil { + return nil, fmt.Errorf("failed to flush before reading incomplete tail: %w", err) + } + length := f.size - f.completeSize + buf := make([]byte, length) + n, err := f.file.ReadAt(buf, int64(f.completeSize)) //nolint:gosec // completeSize <= size + if err != nil && !(err == io.EOF && uint64(n) == length) { + return nil, fmt.Errorf("failed to read incomplete tail: %w", err) + } + if uint64(n) != length { + return nil, fmt.Errorf("short read of incomplete tail: read %d of %d bytes", n, length) + } + return buf, nil +} + +// appendIncompleteTail re-appends the raw framed bytes of an in-progress block (captured by +// readIncompleteTail from the file being sealed) to this fresh mutable file, restoring the block-tracking +// state so subsequent writes and the eventual end-of-block marker behave as if the block had been written +// here all along. block is the in-progress block's number (a single block, by the write-ordering contract). +func (f *walFile) appendIncompleteTail(tail []byte, block uint64) error { + if f.sealed { + return fmt.Errorf("cannot write to a sealed WAL file") + } + if _, err := f.writer.Write(tail); err != nil { + return fmt.Errorf("failed to write carried-forward block: %w", err) + } + f.size += uint64(len(tail)) + f.firstBlock = block + f.lastBlock = block + f.hasBlocks = true + return nil +} + // Flush buffered data to the OS. When fsync is true, also fsync the file so the data survives power loss. func (f *walFile) flush(fsync bool) error { if f.writer != nil { diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go index 38d71f2acc..1208c49e03 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go @@ -533,41 +533,63 @@ func (w *flatKVWalImpl) pruneSealedFiles(pruneThrough uint64) error { return nil } -// startIterator builds an iterator on the writer goroutine. It flushes the mutable file so the iterator's file -// handles observe every previously scheduled write, snapshots the current set of files in ascending block -// order, registers the read lease, and constructs the iterator (which launches its reader goroutine). Running -// here serializes construction with rotation, seal, and prune, so the snapshot is a consistent point-in-time -// view: sealed files have immutable names (opened lazily by the reader, protected from pruning by the lease), -// and the mutable file is pre-opened now so a later seal/rename cannot invalidate its path — an open handle -// survives a rename. +// startIterator builds an iterator on the writer goroutine. It first seals the mutable file (see +// sealForIterator) so every complete block written so far lives in an immutable sealed file, then snapshots +// the sealed files in ascending block order, registers the read lease, and constructs the iterator (which +// launches its reader goroutine). Running here serializes construction with rotation, seal, and prune, so the +// snapshot is a consistent point-in-time view: every file the iterator reads is sealed and immutable, opened +// lazily by name and protected from pruning by the lease, so its contents cannot change underneath the reader. func (w *flatKVWalImpl) startIterator(startBlock uint64) iteratorStartResponse { - if err := w.mutableFile.flush(w.config.FsyncOnFlush); err != nil { - return iteratorStartResponse{err: fmt.Errorf("failed to flush before creating iterator: %w", err)} + if err := w.sealForIterator(); err != nil { + return iteratorStartResponse{err: fmt.Errorf("failed to seal mutable file before creating iterator: %w", err)} } - files := make([]iteratorFile, 0, w.sealedFiles.Size()+1) + files := make([]iteratorFile, 0, w.sealedFiles.Size()) for _, info := range w.sealedFiles.Iterator() { files = append(files, iteratorFile{ index: info.index, name: info.name, firstBlock: info.firstBlock, lastBlock: info.lastBlock, - sealed: true, }) } - mutablePath := filepath.Join(w.config.Path, unsealedFileName(w.mutableFile.index)) - handle, err := os.Open(mutablePath) //nolint:gosec // path derived from the writer's own bookkeeping - if err != nil { - return iteratorStartResponse{err: fmt.Errorf("failed to open mutable WAL file for iteration: %w", err)} - } - files = append(files, iteratorFile{index: w.mutableFile.index, sealed: false, handle: handle}) - pinned := w.pinLowestReadableBlock(startBlock) it := newWalIterator(w, startBlock, pinned, files, w.config.IteratorPrefetchSize) return iteratorStartResponse{iterator: it} } +// sealForIterator seals the mutable file so a newly-created iterator sees a snapshot that cannot change +// underneath it: after this call every complete block lives in an immutable sealed file. Any in-progress +// (unended) block is carried forward into the fresh mutable file so no scheduled write is lost. It is a no-op +// when the mutable file holds no complete block — the iterator reads only sealed files and never yields an +// unended block, so the mutable file (and any in-progress block) is simply left in place. +func (w *flatKVWalImpl) sealForIterator() error { + if !w.mutableFile.hasCompleteBlock { + return nil + } + + // Capture any in-progress block (records past the last end-of-block marker) before the seal truncates + // it away, so it can be re-appended to the fresh mutable file. The write-ordering contract guarantees + // these records all belong to a single block, namely the mutable file's last block. + tail, err := w.mutableFile.readIncompleteTail() + if err != nil { + return fmt.Errorf("failed to capture in-progress block: %w", err) + } + tailBlock := w.mutableFile.lastBlock + + if err := w.rotate(); err != nil { + return fmt.Errorf("failed to seal mutable file: %w", err) + } + + if len(tail) > 0 { + if err := w.mutableFile.appendIncompleteTail(tail, tailBlock); err != nil { + return fmt.Errorf("failed to carry in-progress block forward: %w", err) + } + } + return nil +} + // pinLowestReadableBlock records a read lease and returns the pinned block. An iterator reads blocks at or // above startBlock but never below the oldest block actually stored, so the lease is clamped up to that: a // stale low start must not pin files that no longer exist (or wedge pruning forever). diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go index d39a908334..9c99db1d27 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go @@ -16,16 +16,13 @@ type iteratorResult struct { } // iteratorFile is one entry in an iterator's file snapshot, captured on the writer goroutine when the iterator -// is created (see startIterator). Sealed files carry their immutable name and are opened lazily by the reader; -// the single mutable file carries a handle pre-opened on the writer goroutine so a later seal/rename cannot -// invalidate its path (an open handle survives a rename). +// is created (see startIterator). Every snapshot file is sealed and immutable: it carries its immutable name +// and is opened lazily by the reader, held against pruning by the iterator's read lease. type iteratorFile struct { index uint64 name string firstBlock uint64 lastBlock uint64 - sealed bool - handle *os.File } // walIterator iterates the WAL a block at a time, in ascending block order. A dedicated reader goroutine reads @@ -35,8 +32,9 @@ type iteratorFile struct { // loads one file at a time, so its memory use is bounded by a single WAL file plus the prefetch buffer. // // The set of files to read is snapshotted once at creation (files), so the reader walks it in O(n) without -// re-scanning the directory. A read lease (pinnedBlock) holds the files the reader needs against concurrent -// pruning; Close releases it. +// re-scanning the directory. Every snapshot file is sealed and immutable (the mutable file is sealed at +// creation, see startIterator), so its contents cannot change under the reader. A read lease (pinnedBlock) +// holds the files the reader needs against concurrent pruning; Close releases it. type walIterator struct { // The WAL this iterator reads from. wal *flatKVWalImpl @@ -137,7 +135,6 @@ func (it *walIterator) Close() error { // exhausted (then closes the channel), a read fails (then sends the error), or Close signals a stop. func (it *walIterator) read() { defer close(it.readerExited) - defer it.closeUnreadHandles() // runs before readerExited is signalled, so Close never races these handles for { block, ok, err := it.nextBlock() if err != nil { @@ -223,7 +220,7 @@ func (it *walIterator) loadNextFile() (bool, error) { it.filePos++ it.buffer = nil - if f.sealed && f.lastBlock < it.start { + if f.lastBlock < it.start { continue // entirely below the start block; skip without opening } @@ -232,7 +229,7 @@ func (it *walIterator) loadNextFile() (bool, error) { return false, err } - parsed := parsedFileName{index: f.index, firstBlock: f.firstBlock, lastBlock: f.lastBlock, sealed: f.sealed} + parsed := parsedFileName{index: f.index, firstBlock: f.firstBlock, lastBlock: f.lastBlock, sealed: true} contents, err := readWalFileFromHandle(handle, parsed) if err != nil { return false, fmt.Errorf("failed to read WAL file (index %d) during iteration: %w", f.index, err) @@ -250,15 +247,9 @@ func (it *walIterator) loadNextFile() (bool, error) { } } -// openFile returns a handle for a snapshot entry. The mutable file was pre-opened on the writer goroutine (its -// handle is consumed here, so it is not double-closed by closeUnreadHandles). Sealed files have immutable names -// and are opened lazily; the read lease keeps them alive against pruning, so the open cannot miss the file. +// openFile opens a snapshot file by its immutable sealed name. The read lease keeps the file alive against +// pruning, so the open cannot miss it. readWalFileFromHandle closes the returned handle after reading. func (it *walIterator) openFile(f *iteratorFile) (*os.File, error) { - if f.handle != nil { - handle := f.handle - f.handle = nil - return handle, nil - } path := filepath.Join(it.wal.config.Path, f.name) handle, err := os.Open(path) //nolint:gosec // path derived from the writer's file snapshot if err != nil { @@ -266,15 +257,3 @@ func (it *walIterator) openFile(f *iteratorFile) (*os.File, error) { } return handle, nil } - -// closeUnreadHandles closes any pre-opened handles the reader never consumed (e.g. when Close stops iteration -// before the mutable file is reached, or a read fails first), so they are not leaked. Runs on the reader -// goroutine as it exits; consumed handles have already been nil'd, so none is closed twice. -func (it *walIterator) closeUnreadHandles() { - for i := range it.files { - if it.files[i].handle != nil { - _ = it.files[i].handle.Close() - it.files[i].handle = nil - } - } -} diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go index 9a20a13d8d..e560e6dad8 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go @@ -127,8 +127,8 @@ func TestIteratorYieldsChangesetContents(t *testing.T) { } // TestConcurrentIterationDuringRotation hammers the writer with rotate-on-every-block churn while several -// iterators read concurrently. Each iterator snapshots its file set at creation on the writer goroutine (sealed -// files by immutable name, the mutable file by a pre-opened handle), so a file can never be renamed (sealed) +// iterators read concurrently. Each iterator seals the mutable file and snapshots its file set at creation on +// the writer goroutine, so every file it reads is sealed and immutable and can never be renamed or rewritten // out from under an in-flight read; every iteration must be error-free and gap-free. func TestConcurrentIterationDuringRotation(t *testing.T) { cfg := testConfig(t.TempDir()) @@ -210,6 +210,86 @@ func drainContiguousFrom(w FlatKVWAL, start uint64) error { return it.Close() } +// TestIteratorDoesNotSeePostConstructionBlocks pins down the snapshot contract: an iterator yields only +// blocks that were complete when it was created, never blocks written afterward. The setup makes the check +// deterministic (no timing race): one block per file plus a prefetch of 1 means the reader blocks on the full +// results channel after the first block and cannot reach later files until the consumer drains, which happens +// only after block 4 is written. Because Iterator() now seals the mutable file at creation, block 4 lands in a +// fresh file outside the snapshot and must not appear. +func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.TargetFileSize = 1 + cfg.IteratorPrefetchSize = 1 + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + for block := uint64(1); block <= 3; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + // Written after the iterator exists, before draining: must not be observed. + require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{4}, []byte{4})})) + require.NoError(t, w.SignalEndOfBlock()) + require.NoError(t, w.Flush()) + + var got []uint64 + for { + ok, err := it.Next() + require.NoError(t, err) + if !ok { + break + } + got = append(got, it.Entry().BlockNumber) + } + require.Equal(t, []uint64{1, 2, 3}, got, "post-construction block 4 must not be iterated") +} + +// TestIteratorSealPreservesInProgressBlock verifies the correctness subtlety of sealing at iterator creation: +// when a block is only partially written (several Writes, no end-of-block marker yet), sealing must capture the +// completed blocks for the snapshot AND carry the in-progress block forward without dropping any changeset +// already accepted by Write. +func TestIteratorSealPreservesInProgressBlock(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) // large target: everything begins in one mutable file + defer func() { require.NoError(t, w.Close()) }() + + // Block 1 complete. + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) + require.NoError(t, w.SignalEndOfBlock()) + // Block 2 partially written: one changeset, no end-of-block marker yet. + require.NoError(t, w.Write(2, []*proto.NamedChangeSet{makeChangeSet("b", []byte("k2"), []byte("v2"))})) + + // Opening the iterator seals block 1 and carries block 2's in-progress record into a fresh mutable file. + // The incomplete block 2 must not be yielded. + require.Equal(t, []uint64{1}, collectBlocks(t, w, 1)) + + // Finish block 2 with a second changeset, then end it. + require.NoError(t, w.Write(2, []*proto.NamedChangeSet{makeChangeSet("c", []byte("k3"), []byte("v3"))})) + require.NoError(t, w.SignalEndOfBlock()) + require.NoError(t, w.Flush()) + + // Block 2 must contain BOTH changesets, in write order — nothing lost to the mid-block seal. + it, err := w.Iterator(2) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + entry := it.Entry() + require.Equal(t, uint64(2), entry.BlockNumber) + require.Len(t, entry.Changeset, 2) + require.Equal(t, "b", entry.Changeset[0].Name) + require.Equal(t, "c", entry.Changeset[1].Name) + + ok, err = it.Next() + require.NoError(t, err) + require.False(t, ok) +} + func TestIteratorCoalescesMultipleWritesInOrder(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() From cab813281eba0cb3d843975a40962a090c6e66a8 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 6 Jul 2026 09:16:49 -0500 Subject: [PATCH 06/44] fix recovery bug --- .../state_db/sc/flatkv/wal/flatkv_wal_file.go | 89 ++++++++++--- .../state_db/sc/flatkv/wal/flatkv_wal_impl.go | 9 ++ .../sc/flatkv/wal/flatkv_wal_impl_test.go | 123 ++++++++++++++++++ 3 files changed, 203 insertions(+), 18 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go index 713db7aae2..fde5e72196 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go @@ -207,11 +207,11 @@ func (f *walFile) readIncompleteTail() ([]byte, error) { length := f.size - f.completeSize buf := make([]byte, length) n, err := f.file.ReadAt(buf, int64(f.completeSize)) //nolint:gosec // completeSize <= size - if err != nil && !(err == io.EOF && uint64(n) == length) { + if err != nil && (err != io.EOF || n != len(buf)) { return nil, fmt.Errorf("failed to read incomplete tail: %w", err) } - if uint64(n) != length { - return nil, fmt.Errorf("short read of incomplete tail: read %d of %d bytes", n, length) + if n != len(buf) { + return nil, fmt.Errorf("short read of incomplete tail: read %d of %d bytes", n, len(buf)) } return buf, nil } @@ -487,18 +487,30 @@ func sealOrphanFile(directory string, name string) error { return nil } -// rollbackStraddlingFile handles the single sealed file that spans rollbackThrough: it truncates away every -// block beyond the rollback point and renames the file to reflect the reduced range. The truncation is fsynced -// before the rename (see truncateAndSync), so a crash can never leave the file's content holding blocks past -// the rollback point once the rename is durable — the iterator, which bounds sealed reads by content, would -// otherwise replay the discarded blocks. Files entirely beyond the rollback point are removed by the caller; -// this handles only the boundary file. +// rollbackStraddlingFile handles the single sealed file that spans rollbackThrough: it drops every block beyond +// the rollback point and reduces the file's range accordingly. The name of a sealed file encodes its block +// range, so the reduced content and the reduced name must become durable together — a crash must never leave a +// file whose name promises more blocks than its content holds (the iterator bounds sealed reads by content and +// would otherwise under-yield, while GetStoredRange trusts the name and would over-report). Because the reduced +// range means a different file name, this cannot be a single in-place rename: the kept prefix is written to a +// fresh correctly-named file via AtomicWrite (durable on its own), and only then is the old, larger-named file +// removed. A crash after the write but before the removal leaves both files under the same index; recovery's +// reconcileRollbackRemnants resolves that deterministically. Files entirely beyond the rollback point are +// removed by the caller; this handles only the boundary file. func rollbackStraddlingFile(directory string, name string, rollbackThrough uint64) error { path := filepath.Join(directory, name) - contents, err := readWalFile(path) + parsed, ok := parseFileName(name) + if !ok { + return fmt.Errorf("not a WAL file: %s", name) + } + data, err := os.ReadFile(path) //nolint:gosec // path derived from a scanned WAL directory entry if err != nil { return fmt.Errorf("failed to read WAL file %s during rollback: %w", path, err) } + contents, err := parseWalFileData(data, parsed, path) + if err != nil { + return fmt.Errorf("failed to parse WAL file %s during rollback: %w", path, err) + } truncateTo := int64(walHeaderSize) var lastKept uint64 @@ -520,16 +532,57 @@ func rollbackStraddlingFile(directory string, name string, rollbackThrough uint6 return nil // nothing beyond the rollback point; leave the file untouched } - if err := truncateAndSync(path, truncateTo); err != nil { - return fmt.Errorf("failed to truncate WAL file %s during rollback: %w", path, err) + // Write the kept prefix to a fresh file under its reduced name, made durable before the old file is removed. + newName := sealedFileName(parsed.index, contents.firstBlock, lastKept) + newPath := filepath.Join(directory, newName) + if err := util.AtomicWrite(newPath, data[:truncateTo], true); err != nil { + return fmt.Errorf("failed to write rolled-back WAL file %s: %w", newPath, err) } - newPath := filepath.Join(directory, - sealedFileName(contents.parsed.index, contents.firstBlock, lastKept)) - if newPath == path { - return nil + if err := removeAndSyncDir(directory, name); err != nil { + return fmt.Errorf("failed to remove old WAL file %s during rollback: %w", path, err) } - if err := util.AtomicRename(path, newPath, true); err != nil { - return fmt.Errorf("failed to rename WAL file %s during rollback: %w", path, err) + return nil +} + +// reconcileRollbackRemnants resolves the one crash window left by rollbackStraddlingFile: a crash after the +// reduced file was written but before the old, larger-named file was removed leaves two sealed files sharing an +// index. This can happen only from an interrupted rollback swap (healthy operation never assigns an index to +// two files), so the reduced file (the one with the smaller last block) is the intended survivor; the larger +// one is removed. Both files are internally name/content consistent, so the choice is made from names alone +// without reading contents. A no-op in the common case where every sealed index is unique. +func reconcileRollbackRemnants(directory string) error { + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + + // The name kept for each sealed index so far. A duplicate index means an interrupted rollback swap. + kept := make(map[uint64]parsedFileName) + for _, entry := range entries { + if entry.IsDir() { + continue + } + parsed, ok := parseFileName(entry.Name()) + if !ok || !parsed.sealed { + continue + } + prev, seen := kept[parsed.index] + if !seen { + kept[parsed.index] = parsed + continue + } + // Two sealed files share this index. Keep the one with the smaller last block (the rolled-back + // result) and remove the other. A sealed name is a deterministic function of its parsed fields, so + // each file's name is recoverable without tracking the raw directory entry. + keep, drop := parsed, prev + if prev.lastBlock <= parsed.lastBlock { + keep, drop = prev, parsed + } + dropName := sealedFileName(drop.index, drop.firstBlock, drop.lastBlock) + if err := removeAndSyncDir(directory, dropName); err != nil { + return fmt.Errorf("failed to remove rollback remnant %s: %w", dropName, err) + } + kept[parsed.index] = keep } return nil } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go index 1208c49e03..8b2086f408 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go @@ -165,6 +165,15 @@ func newFlatKVWal(config *FlatKVWALConfig, rollbackThrough *uint64) (FlatKVWAL, return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) } + // Clean up remnants of a rollback swap interrupted by a crash before scanning (see rollbackStraddlingFile): + // a leftover swap file from an unfinished AtomicWrite, or two sealed files sharing an index because the old + // file was not yet removed. This leaves a set where every sealed index is unique and name matches content. + if err := util.DeleteOrphanedSwapFiles(config.Path); err != nil { + return nil, fmt.Errorf("failed to delete orphaned swap files: %w", err) + } + if err := reconcileRollbackRemnants(config.Path); err != nil { + return nil, fmt.Errorf("failed to reconcile rollback remnants: %w", err) + } if err := recoverOrphans(config.Path); err != nil { return nil, fmt.Errorf("failed to recover orphaned WAL files: %w", err) } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go index 87a1ce94ad..d8bc4a08f8 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go @@ -508,3 +508,126 @@ func TestRollbackConstructor(t *testing.T) { } }) } + +// sealedFileNames returns the names of all sealed WAL files in dir, sorted for stable assertions. +func sealedFileNames(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + var names []string + for _, entry := range entries { + if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { + names = append(names, entry.Name()) + } + } + sort.Strings(names) + return names +} + +// blockPrefixBytes reads the sealed file at path and returns the raw bytes of the prefix ending just past the +// end-of-block marker for lastKeep — i.e. the exact content rollbackStraddlingFile's AtomicWrite would install +// for a rollback to lastKeep. It is the test's stand-in for "the truncated copy the rollback would produce". +func blockPrefixBytes(t *testing.T, path string, lastKeep uint64) []byte { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + contents, err := readWalFile(path) + require.NoError(t, err) + var truncateTo int64 + found := false + for _, b := range contents.blockBoundaries { + if b.block == lastKeep { + truncateTo = b.offset + found = true + break + } + } + require.True(t, found, "block %d has no end-of-block boundary in %s", lastKeep, path) + return data[:truncateTo] +} + +// TestRollbackCrashAfterSwapReconciledOnReopen simulates a crash in rollbackStraddlingFile after the reduced +// file was durably written (AtomicWrite) but before the old, larger-named file was removed. That leaves two +// sealed files sharing an index. A subsequent open must reconcile them — keeping the reduced file — so the +// name-derived GetStoredRange and the content-derived iterator agree on the rolled-back range. +func TestRollbackCrashAfterSwapReconciledOnReopen(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: all six blocks land in one file, index 0 + + w := openWAL(t, cfg) + for block := uint64(1); block <= 6; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + oldName := sealedFileName(0, 1, 6) + require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) + + // Reproduce the crash state: the reduced file [1,3] exists next to the untouched original [1,6]. + reducedName := sealedFileName(0, 1, 3) + prefix := blockPrefixBytes(t, filepath.Join(dir, oldName), 3) + require.NoError(t, os.WriteFile(filepath.Join(dir, reducedName), prefix, 0o600)) + require.Equal(t, []string{reducedName, oldName}, sealedFileNames(t, dir)) + + // A plain reopen must reconcile the duplicate index down to the rolled-back file. + w2 := openWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + + require.Equal(t, []string{reducedName}, sealedFileNames(t, dir)) + ok, start, end, err := w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(3), end) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) +} + +// TestRollbackCrashDuringSwapWindowRecovers simulates a crash mid-rollback in the earlier window: the +// AtomicWrite's swap file was created but not yet renamed into place, so only a leftover ".swap" exists beside +// the still-intact original. A reopen must drop the swap and leave the original range intact (the rollback +// simply did not take effect), and a subsequent rollback must then complete cleanly and durably. +func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: all six blocks in one file, index 0 + + w := openWAL(t, cfg) + for block := uint64(1); block <= 6; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + oldName := sealedFileName(0, 1, 6) + + // Reproduce the crash state: an unfinished AtomicWrite left a swap file for the reduced name, alongside + // the untouched original. util.AtomicWrite names its temp ".swap". + prefix := blockPrefixBytes(t, filepath.Join(dir, oldName), 3) + swapName := sealedFileName(0, 1, 3) + ".swap" + require.NoError(t, os.WriteFile(filepath.Join(dir, swapName), prefix, 0o600)) + + // A plain reopen drops the swap; the original range survives (rollback did not take effect). + w2 := openWAL(t, cfg) + require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) + _, err := os.Stat(filepath.Join(dir, swapName)) + require.True(t, os.IsNotExist(err), "leftover swap file should have been removed") + ok, start, end, err := w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(6), end) + require.NoError(t, w2.Close()) + + // The subsequent rollback completes cleanly, and a normal reopen sees the consistent rolled-back range. + w3, err := NewFlatKVWALWithRollback(cfg, 3) + require.NoError(t, err) + require.NoError(t, w3.Close()) + + w4 := openWAL(t, cfg) + defer func() { require.NoError(t, w4.Close()) }() + require.Equal(t, []string{sealedFileName(0, 1, 3)}, sealedFileNames(t, dir)) + ok, start, end, err = w4.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(3), end) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w4, 1)) +} From 12b943f97bc93e2bc5a97adfb035cd978279f25b Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 6 Jul 2026 09:27:23 -0500 Subject: [PATCH 07/44] fix bugs --- .../state_db/sc/flatkv/wal/flatkv_wal_impl.go | 40 +++++++++--- .../sc/flatkv/wal/flatkv_wal_impl_test.go | 64 +++++++++++++++++++ 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go index 8b2086f408..6d5aa83abb 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go @@ -518,16 +518,29 @@ func (w *flatKVWalImpl) rotate() error { // pruneSealedFiles deletes sealed files whose highest block is below pruneThrough. Files are removed // oldest-first (from the front of the deque) with a directory fsync after each removal, so a crash mid-prune // leaves a contiguous suffix of files rather than a gap in the block sequence. The mutable file is never -// pruned. Iteration stops at the first retained file: block ranges grow toward the back, so once a file is -// kept every later file is kept too. +// pruned. +// +// A live iterator holds a read lease at some block R and may still read every block from R onward, so no file +// whose range reaches R or higher may be removed. A file [first, last] is needed iff it overlaps [R, ∞), i.e. +// iff last >= R. Comparing the lowest live reservation against each file's last block (rather than testing +// whether the reservation falls inside a file's range) protects exactly the files an iterator can still open — +// even when the reservation lands in a gap between files or strictly inside a file's range. Because +// reservations never fall below the lowest stored block (see pinLowestReadableBlock), a file left below the +// lowest reservation is one the iterator has already moved past and can safely be dropped. +// +// Iteration stops at the first retained file: block ranges grow toward the back, so once a file is kept (by +// pruneThrough or by the lowest reservation) every later file is kept too. func (w *flatKVWalImpl) pruneSealedFiles(pruneThrough uint64) error { + // Reservations are mutated only on this (the writer) goroutine, so the lowest reservation is stable for the + // duration of this prune and can be computed once. + reservation, hasReservation := w.lowestReservation() for { front, ok := w.sealedFiles.TryPeekFront() if !ok || front.lastBlock >= pruneThrough { break } - if w.blockPinnedInRange(front.firstBlock, front.lastBlock) { - break // a live iterator still needs this file; leave it (and everything after) in place + if hasReservation && front.lastBlock >= reservation { + break // a live iterator may still read this file (or a later one); keep it and everything after } path := filepath.Join(w.config.Path, front.name) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { @@ -601,7 +614,9 @@ func (w *flatKVWalImpl) sealForIterator() error { // pinLowestReadableBlock records a read lease and returns the pinned block. An iterator reads blocks at or // above startBlock but never below the oldest block actually stored, so the lease is clamped up to that: a -// stale low start must not pin files that no longer exist (or wedge pruning forever). +// stale low start must not pin files that no longer exist (or wedge pruning forever). Clamping to the oldest +// stored block also establishes the invariant pruneSealedFiles relies on: a reservation never falls below the +// lowest stored block, so a file entirely below the lowest reservation is one every iterator has moved past. func (w *flatKVWalImpl) pinLowestReadableBlock(startBlock uint64) uint64 { pinned := startBlock if r := w.blockRange(); r.ok && r.start > pinned { @@ -620,14 +635,19 @@ func (w *flatKVWalImpl) releaseBlock(block uint64) { w.blockRefs[block]-- } -// blockPinnedInRange reports whether any live read lease falls within [firstBlock, lastBlock]. -func (w *flatKVWalImpl) blockPinnedInRange(firstBlock uint64, lastBlock uint64) bool { +// lowestReservation returns the smallest block number currently leased by a live iterator, and ok=false when no +// lease is held. A lease at block R means some iterator may still read blocks at or above R, so every sealed +// file whose range reaches R or higher must be retained by pruning. +func (w *flatKVWalImpl) lowestReservation() (uint64, bool) { + var lowest uint64 + found := false for block := range w.blockRefs { - if block >= firstBlock && block <= lastBlock { - return true + if !found || block < lowest { + lowest = block + found = true } } - return false + return lowest, found } // blockRange reports the range of complete blocks across all files. Complete blocks live in the sealed files diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go index d8bc4a08f8..7b5b765eb4 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go @@ -374,6 +374,70 @@ func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { require.Equal(t, uint64(5), start) } +// TestIteratorInGapBlocksPruningAcrossGap covers the block-number gap case: the WAL contract allows block +// numbers to jump, so an iterator's read lease can land in a gap between stored files. Pruning must still +// protect every file the iterator will read (those reaching the lease block or higher), even though no file's +// range contains the lease block itself. The directory is inspected directly rather than relying on iterator +// output, since the reader goroutine may have buffered the files into memory before an unsafe delete. +func TestIteratorInGapBlocksPruningAcrossGap(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one block per sealed file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + // Blocks 1,2,3 then a legal jump to 10,11,12. The lease block 5 falls in the gap (3, 10). + for _, block := range []uint64{1, 2, 3, 10, 11, 12} { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(5) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + // Prune(12) would remove every file with last block < 12, but the live lease at 5 must keep the files for + // blocks 10 and 11 (both >= 5). Only the files entirely below the lease (blocks 1,2,3) may be dropped. + require.NoError(t, w.Prune(12)) + _, _, _, err = w.GetStoredRange() // synchronous round-trip forces the async prune to complete + require.NoError(t, err) + + names := sealedFileNames(t, dir) + require.Contains(t, names, sealedFileName(3, 10, 10), "file for block 10 must survive while iterator(5) is live") + require.Contains(t, names, sealedFileName(4, 11, 11), "file for block 11 must survive while iterator(5) is live") + require.NotContains(t, names, sealedFileName(0, 1, 1), "file for block 1 is below the lease and should be pruned") + + require.Equal(t, []uint64{10, 11, 12}, collectBlocks(t, w, 5)) +} + +// TestIteratorLeaseInsideFileRangeBlocksPruning checks the boundary where the lease block sits within the kept +// window: an iterator anchored at 5 must keep blocks 5..10 even as pruning is asked to drop through a higher +// point, because those files reach the lease block or higher. +func TestIteratorLeaseInsideFileRangeBlocksPruning(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one block per sealed file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 10; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(5) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + require.NoError(t, w.Prune(8)) + ok, start, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), start, "lease at 5 must keep blocks from 5 onward") + require.Equal(t, uint64(10), end) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 5)) +} + func TestScanRejectsGapInSealedFiles(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) From 6ea0aabeaaf48fa3f153ab840c6c10743f7d390a Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 6 Jul 2026 09:54:10 -0500 Subject: [PATCH 08/44] bugfix --- .../state_db/sc/flatkv/wal/flatkv_wal_file.go | 5 +-- .../sc/flatkv/wal/flatkv_wal_iterator.go | 32 ++++++++++++-- .../sc/flatkv/wal/flatkv_wal_iterator_test.go | 44 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go index fde5e72196..c9135cf911 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go @@ -348,10 +348,7 @@ func readWalFile(path string) (*walFileContents, error) { return parseWalFileData(data, parsed, path) } -// readWalFileFromHandle reads and parses a WAL file from an already-open handle, then closes the handle. The -// mutable file's handle is pre-opened on the writer goroutine (see startIterator) so a later rename cannot -// invalidate it; sealed files are opened lazily by name (see walIterator.openFile). Either way the heavy read -// happens here, on the iterator's reader goroutine. parsed carries the file-name components for error context. +// readWalFileFromHandle reads and parses a WAL file from an already-open handle, then closes the handle. func readWalFileFromHandle(file *os.File, parsed parsedFileName) (*walFileContents, error) { defer func() { _ = file.Close() }() data, err := io.ReadAll(file) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go index 9c99db1d27..9d814db540 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go @@ -104,7 +104,27 @@ func (it *walIterator) Next() (bool, error) { if it.done { return false, nil } - result, ok := <-it.results + // Drain an already-available result first (non-blocking), so a clean EOF — the reader closing results + // with blocks still buffered — is never lost to a concurrent WAL shutdown in the select below. + select { + case result, ok := <-it.results: + return it.deliver(result, ok) + default: + } + // Otherwise wait for the next result, but don't block forever if the WAL is torn down. The reader + // goroutine watches the same context (see send) and stops producing when it fires, so the results + // channel would never advance again; surface the shutdown as an error rather than hang. + select { + case result, ok := <-it.results: + return it.deliver(result, ok) + case <-it.wal.ctx.Done(): + it.done = true + return false, fmt.Errorf("WAL shut down during iteration: %w", it.wal.ctx.Err()) + } +} + +// deliver turns a dequeued reader result (or a closed-channel signal) into a Next return value. +func (it *walIterator) deliver(result iteratorResult, ok bool) (bool, error) { if !ok { it.done = true return false, nil @@ -132,7 +152,8 @@ func (it *walIterator) Close() error { } // read is the reader goroutine: it produces coalesced blocks onto the results channel until the WAL is -// exhausted (then closes the channel), a read fails (then sends the error), or Close signals a stop. +// exhausted (then closes the channel), a read fails (then sends the error), Close signals a stop, or the WAL +// context is cancelled (see send). It never blocks indefinitely, so it cannot outlive the WAL as a zombie. func (it *walIterator) read() { defer close(it.readerExited) for { @@ -151,13 +172,18 @@ func (it *walIterator) read() { } } -// send pushes a result onto the channel, returning false if Close signalled a stop first. +// send pushes a result onto the channel, returning false if Close signalled a stop or the WAL was torn down +// first. Watching the WAL context here is what keeps the reader from becoming a zombie: if an iterator is +// orphaned (Iterator aborted via ctx.Done before the caller ever received it, so Close is never called) the +// prefetch buffer eventually fills and this send would otherwise block forever with no one to close stop. func (it *walIterator) send(result iteratorResult) bool { select { case it.results <- result: return true case <-it.stop: return false + case <-it.wal.ctx.Done(): + return false } } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go index e560e6dad8..20927a6646 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go +++ b/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go @@ -4,6 +4,7 @@ import ( "fmt" "sync" "testing" + "time" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/stretchr/testify/require" @@ -83,6 +84,49 @@ func TestIteratorCloseBeforeDrainDoesNotLeak(t *testing.T) { require.NoError(t, it.Close()) // idempotent } +func TestIteratorReaderExitsWhenWALTornDownWhileOrphaned(t *testing.T) { + // Regression: an iterator whose reader fills the prefetch buffer and is never consumed or Closed — as + // happens when Iterator() is aborted via ctx.Done() and the constructed iterator is returned to no one — + // must not leave its reader goroutine blocked on send() forever. Watching the WAL context lets the reader + // exit when the WAL is torn down, so it cannot become a zombie. + cfg := testConfig(t.TempDir()) + cfg.IteratorPrefetchSize = 1 + w := openWAL(t, cfg) + for block := uint64(1); block <= 20; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + iter := it.(*walIterator) + + // Do not consume the iterator: the reader fills the prefetch buffer (size 1) and blocks on send. Tear + // down the WAL context out from under it, as fail() or Close() would. + w.(*flatKVWalImpl).cancel() + + select { + case <-iter.readerExited: + case <-time.After(5 * time.Second): + t.Fatal("reader goroutine did not exit after the WAL context was cancelled") + } + + // A consumer that races the teardown drains whatever the reader had already buffered, then observes an + // error rather than a clean EOF: a truncated iteration must never masquerade as fully consumed. + var termErr error + for i := 0; i < 25; i++ { + ok, err := it.Next() + if err != nil { + termErr = err + break + } + if !ok { + break + } + } + require.Error(t, termErr, "truncated iteration must surface an error, not a clean EOF") +} + func TestIteratorStopsBeforeIncompleteTail(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() From 54dd10c46cc2a48c105cbc3b1eb0da3f077e365e Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 6 Jul 2026 12:35:22 -0500 Subject: [PATCH 09/44] rename --- .../sc/flatkv/wal => statewal}/metrics.go | 22 ++--- .../flatkv_wal.go => statewal/state_wal.go} | 16 ++-- .../state_wal_config.go} | 14 +-- .../state_wal_config_test.go} | 10 +- .../state_wal_entry.go} | 26 ++--- .../state_wal_entry_test.go} | 24 ++--- .../state_wal_file.go} | 26 ++--- .../state_wal_file_test.go} | 16 ++-- .../state_wal_impl.go} | 94 +++++++++---------- .../state_wal_impl_test.go} | 26 ++--- .../state_wal_iterator.go} | 24 ++--- .../state_wal_iterator_test.go} | 6 +- 12 files changed, 152 insertions(+), 152 deletions(-) rename sei-db/{state_db/sc/flatkv/wal => statewal}/metrics.go (60%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal.go => statewal/state_wal.go} (89%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_config.go => statewal/state_wal_config.go} (87%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_config_test.go => statewal/state_wal_config_test.go} (72%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_entry.go => statewal/state_wal_entry.go} (86%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_entry_test.go => statewal/state_wal_entry_test.go} (80%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_file.go => statewal/state_wal_file.go} (97%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_file_test.go => statewal/state_wal_file_test.go} (91%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_impl.go => statewal/state_wal_impl.go} (90%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_impl_test.go => statewal/state_wal_impl_test.go} (97%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_iterator.go => statewal/state_wal_iterator.go} (95%) rename sei-db/{state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go => statewal/state_wal_iterator_test.go} (99%) diff --git a/sei-db/state_db/sc/flatkv/wal/metrics.go b/sei-db/statewal/metrics.go similarity index 60% rename from sei-db/state_db/sc/flatkv/wal/metrics.go rename to sei-db/statewal/metrics.go index 569c9d7ba7..f63c5d23ad 100644 --- a/sei-db/state_db/sc/flatkv/wal/metrics.go +++ b/sei-db/statewal/metrics.go @@ -1,41 +1,41 @@ -package wal +package statewal import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/metric" ) -// The name of the OpenTelemetry meter for flatKV WAL metrics. -const walMeterName = "seidb_flatkv_wal" +// The name of the OpenTelemetry meter for state WAL metrics. +const walMeterName = "seidb_statewal" var ( walMeter = otel.Meter(walMeterName) // The number of blocks (end-of-block markers) written to the WAL. walBlocksWritten = must(walMeter.Int64Counter( - "flatkv_wal_blocks_written", - metric.WithDescription("Number of blocks written to the flatKV WAL"), + "state_wal_blocks_written", + metric.WithDescription("Number of blocks written to the state WAL"), metric.WithUnit("{count}"), )) // The number of record bytes appended to the WAL (including framing). walBytesWritten = must(walMeter.Int64Counter( - "flatkv_wal_bytes_written", - metric.WithDescription("Number of bytes written to the flatKV WAL"), + "state_wal_bytes_written", + metric.WithDescription("Number of bytes written to the state WAL"), metric.WithUnit("By"), )) // The number of WAL files sealed (rotated) after reaching the target size. walFilesSealed = must(walMeter.Int64Counter( - "flatkv_wal_files_sealed", - metric.WithDescription("Number of flatKV WAL files sealed on rotation"), + "state_wal_files_sealed", + metric.WithDescription("Number of state WAL files sealed on rotation"), metric.WithUnit("{count}"), )) // The number of sealed WAL files deleted by pruning. walFilesPruned = must(walMeter.Int64Counter( - "flatkv_wal_files_pruned", - metric.WithDescription("Number of flatKV WAL files removed by pruning"), + "state_wal_files_pruned", + metric.WithDescription("Number of state WAL files removed by pruning"), metric.WithUnit("{count}"), )) ) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go b/sei-db/statewal/state_wal.go similarity index 89% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal.go rename to sei-db/statewal/state_wal.go index b6ea7ac31e..fe504fbdd6 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal.go +++ b/sei-db/statewal/state_wal.go @@ -1,15 +1,15 @@ -package wal +package statewal import "github.com/sei-protocol/sei-chain/sei-db/proto" -// A WAL for flatKV. -type FlatKVWAL interface { +// A WAL for state. +type StateWAL interface { // Write a set of changes to the WAL. // // This method only schedules the write, it does not block until the write is complete. // - // The FlatKVWal rejects writes for blocks if provided out of order. To avoid errors, observe + // The StateWAL rejects writes for blocks if provided out of order. To avoid errors, observe // the following rules: // // - The block numbers passed to Write() may never decrease. @@ -56,17 +56,17 @@ type FlatKVWAL interface { // Create an iterator over the WAL, starting at the given block number. Iterates all data passed to Write() // before this call. Data written after this call is not iterated. - Iterator(startingBlockNumber uint64) (FlatKVWalIterator, error) + Iterator(startingBlockNumber uint64) (StateWALIterator, error) // Close the WAL, flushing any pending writes and releasing resources. Close() error } -// Iterates over data in a flatKV WAL, in ascending block order, yielding one entry per block. All changesets +// Iterates over data in a state WAL, in ascending block order, yielding one entry per block. All changesets // written for a block (across one or more Write calls) are coalesced, in write order, into that block's single // entry; the entry's EndOfBlock field is always false. Incomplete trailing blocks (those with no end-of-block // marker) are not yielded. -type FlatKVWalIterator interface { +type StateWALIterator interface { // Next advances the iterator to the next block. It returns false when iteration is complete (no more // blocks), and returns an error if advancing failed. After Next returns (false, nil), iteration is // complete; after it returns an error, the iterator must not be used further (other than Close). @@ -74,7 +74,7 @@ type FlatKVWalIterator interface { // Entry returns the coalesced entry for the block at the iterator's current position. It is only valid to // call Entry after Next has returned (true, nil). The returned entry must not be modified. - Entry() *FlatKVWalEntry + Entry() *Entry // Close releases the resources held by the iterator. Close() error diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go b/sei-db/statewal/state_wal_config.go similarity index 87% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go rename to sei-db/statewal/state_wal_config.go index 1261451cdd..40bcb9fa17 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config.go +++ b/sei-db/statewal/state_wal_config.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "fmt" @@ -6,8 +6,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/unit" ) -// Configuration for a flatKV WAL. -type FlatKVWALConfig struct { +// Configuration for a state WAL. +type Config struct { // The directory where the WAL writes its files. Path string @@ -32,9 +32,9 @@ type FlatKVWALConfig struct { IteratorPrefetchSize uint } -// Constructor for a default flatKV WAL configuration. -func DefaultFlatKVWALConfig(path string) *FlatKVWALConfig { - return &FlatKVWALConfig{ +// Constructor for a default state WAL configuration. +func DefaultConfig(path string) *Config { + return &Config{ Path: path, RequestBufferSize: 16, WriteBufferSize: 16, @@ -45,7 +45,7 @@ func DefaultFlatKVWALConfig(path string) *FlatKVWALConfig { } // Validate the configuration, returning nil if valid, or an error describing the problem if invalid. -func (c *FlatKVWALConfig) Validate() error { +func (c *Config) Validate() error { if c.Path == "" { return fmt.Errorf("path is required") } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go b/sei-db/statewal/state_wal_config_test.go similarity index 72% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go rename to sei-db/statewal/state_wal_config_test.go index 3ae2b575a7..e9966ced7c 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_config_test.go +++ b/sei-db/statewal/state_wal_config_test.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "testing" @@ -8,22 +8,22 @@ import ( func TestConfigValidate(t *testing.T) { t.Run("default config is valid", func(t *testing.T) { - require.NoError(t, DefaultFlatKVWALConfig("/tmp/wal").Validate()) + require.NoError(t, DefaultConfig("/tmp/wal").Validate()) }) t.Run("empty path is rejected", func(t *testing.T) { - cfg := DefaultFlatKVWALConfig("") + cfg := DefaultConfig("") require.Error(t, cfg.Validate()) }) t.Run("zero target file size is rejected", func(t *testing.T) { - cfg := DefaultFlatKVWALConfig("/tmp/wal") + cfg := DefaultConfig("/tmp/wal") cfg.TargetFileSize = 0 require.Error(t, cfg.Validate()) }) t.Run("zero iterator prefetch size is rejected", func(t *testing.T) { - cfg := DefaultFlatKVWALConfig("/tmp/wal") + cfg := DefaultConfig("/tmp/wal") cfg.IteratorPrefetchSize = 0 require.Error(t, cfg.Validate()) }) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry.go b/sei-db/statewal/state_wal_entry.go similarity index 86% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry.go rename to sei-db/statewal/state_wal_entry.go index f62ea861cb..da0aec1772 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry.go +++ b/sei-db/statewal/state_wal_entry.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "encoding/binary" @@ -18,12 +18,12 @@ const ( kindEndOfBlock entryKind = 2 ) -// A WAL entry for flatKV. +// A WAL entry for state. // // An entry is either a changeset record (EndOfBlock is false, Changeset holds the block's changes) or an // end-of-block marker (EndOfBlock is true, Changeset is nil). A single block may be described by several // changeset records followed by exactly one end-of-block marker. -type FlatKVWalEntry struct { +type Entry struct { // The block number associated with this entry. BlockNumber uint64 @@ -36,16 +36,16 @@ type FlatKVWalEntry struct { } // Constructor for a changeset entry. -func NewFlatKVWalEntry(blockNumber uint64, changeset []*proto.NamedChangeSet) *FlatKVWalEntry { - return &FlatKVWalEntry{ +func NewEntry(blockNumber uint64, changeset []*proto.NamedChangeSet) *Entry { + return &Entry{ BlockNumber: blockNumber, Changeset: changeset, } } // Constructor for an end-of-block marker entry. -func NewFlatKVEndOfBlockEntry(blockNumber uint64) *FlatKVWalEntry { - return &FlatKVWalEntry{ +func NewEndOfBlockEntry(blockNumber uint64) *Entry { + return &Entry{ BlockNumber: blockNumber, EndOfBlock: true, } @@ -59,7 +59,7 @@ func NewFlatKVEndOfBlockEntry(blockNumber uint64) *FlatKVWalEntry { // followed, for changeset records only, by: // // [uvarint changeset count]([uvarint marshaled length][marshaled NamedChangeSet])* -func (e *FlatKVWalEntry) Serialize() ([]byte, error) { +func (e *Entry) Serialize() ([]byte, error) { var buf []byte var scratch [binary.MaxVarintLen64]byte @@ -93,10 +93,10 @@ func (e *FlatKVWalEntry) Serialize() ([]byte, error) { return buf, nil } -// DeserializeFlatKVWalEntry parses a record payload previously produced by Serialize. -func DeserializeFlatKVWalEntry(data []byte) ( +// DeserializeEntry parses a record payload previously produced by Serialize. +func DeserializeEntry(data []byte) ( // The resulting WAL entry. - entry *FlatKVWalEntry, + entry *Entry, // If true, the WAL entry was successfully deserialized. // If false, the data was truncated or otherwise incomplete and entry is nil. ok bool, @@ -119,7 +119,7 @@ func DeserializeFlatKVWalEntry(data []byte) ( switch kind { case kindEndOfBlock: - return NewFlatKVEndOfBlockEntry(blockNumber), true, nil + return NewEndOfBlockEntry(blockNumber), true, nil case kindChangeset: count, n := binary.Uvarint(rest) if n <= 0 { @@ -144,7 +144,7 @@ func DeserializeFlatKVWalEntry(data []byte) ( rest = rest[length:] changeset = append(changeset, ncs) } - return NewFlatKVWalEntry(blockNumber, changeset), true, nil + return NewEntry(blockNumber, changeset), true, nil default: return nil, false, fmt.Errorf("unknown WAL entry kind %d", kind) } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry_test.go b/sei-db/statewal/state_wal_entry_test.go similarity index 80% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry_test.go rename to sei-db/statewal/state_wal_entry_test.go index 524fca0e2b..472bf22c8f 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_entry_test.go +++ b/sei-db/statewal/state_wal_entry_test.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "testing" @@ -18,7 +18,7 @@ func makeChangeSet(name string, key []byte, value []byte) *proto.NamedChangeSet func TestEntrySerializeRoundTrip(t *testing.T) { t.Run("changeset with multiple named change sets", func(t *testing.T) { - entry := NewFlatKVWalEntry(42, []*proto.NamedChangeSet{ + entry := NewEntry(42, []*proto.NamedChangeSet{ makeChangeSet("bank", []byte("a"), []byte("1")), makeChangeSet("evm", []byte("b"), []byte("2")), }) @@ -26,18 +26,18 @@ func TestEntrySerializeRoundTrip(t *testing.T) { data, err := entry.Serialize() require.NoError(t, err) - got, ok, err := DeserializeFlatKVWalEntry(data) + got, ok, err := DeserializeEntry(data) require.NoError(t, err) require.True(t, ok) require.Equal(t, entry, got) }) t.Run("empty (non-nil) changeset", func(t *testing.T) { - entry := NewFlatKVWalEntry(7, []*proto.NamedChangeSet{}) + entry := NewEntry(7, []*proto.NamedChangeSet{}) data, err := entry.Serialize() require.NoError(t, err) - got, ok, err := DeserializeFlatKVWalEntry(data) + got, ok, err := DeserializeEntry(data) require.NoError(t, err) require.True(t, ok) require.Equal(t, uint64(7), got.BlockNumber) @@ -46,11 +46,11 @@ func TestEntrySerializeRoundTrip(t *testing.T) { }) t.Run("end of block marker", func(t *testing.T) { - entry := NewFlatKVEndOfBlockEntry(99) + entry := NewEndOfBlockEntry(99) data, err := entry.Serialize() require.NoError(t, err) - got, ok, err := DeserializeFlatKVWalEntry(data) + got, ok, err := DeserializeEntry(data) require.NoError(t, err) require.True(t, ok) require.Equal(t, uint64(99), got.BlockNumber) @@ -60,7 +60,7 @@ func TestEntrySerializeRoundTrip(t *testing.T) { } func TestDeserializeTruncated(t *testing.T) { - entry := NewFlatKVWalEntry(42, []*proto.NamedChangeSet{ + entry := NewEntry(42, []*proto.NamedChangeSet{ makeChangeSet("bank", []byte("hello"), []byte("world")), }) data, err := entry.Serialize() @@ -69,7 +69,7 @@ func TestDeserializeTruncated(t *testing.T) { // Every strict prefix is either incomplete (ok=false) or, by chance, a shorter valid record; it must // never yield the original entry and never panic. for length := 0; length < len(data); length++ { - got, ok, err := DeserializeFlatKVWalEntry(data[:length]) + got, ok, err := DeserializeEntry(data[:length]) if ok { require.NotEqual(t, entry, got) } @@ -77,7 +77,7 @@ func TestDeserializeTruncated(t *testing.T) { } // Empty input is cleanly reported as incomplete. - got, ok, err := DeserializeFlatKVWalEntry(nil) + got, ok, err := DeserializeEntry(nil) require.NoError(t, err) require.False(t, ok) require.Nil(t, got) @@ -89,13 +89,13 @@ func TestDeserializeCorruptChangeset(t *testing.T) { // Layout: [kind=changeset][blockNumber=1][count=1][len=2][0x08 0xFF] where 0x08 is a varint field tag // (field 1, wire type 0) followed by a truncated varint, which the protobuf decoder rejects. payload := []byte{byte(kindChangeset), 0x01, 0x01, 0x02, 0x08, 0xFF} - _, ok, err := DeserializeFlatKVWalEntry(payload) + _, ok, err := DeserializeEntry(payload) require.Error(t, err) require.False(t, ok) } func TestDeserializeUnknownKind(t *testing.T) { - _, ok, err := DeserializeFlatKVWalEntry([]byte{0xFF, 0x01}) + _, ok, err := DeserializeEntry([]byte{0xFF, 0x01}) require.Error(t, err) require.False(t, ok) } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go b/sei-db/statewal/state_wal_file.go similarity index 97% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go rename to sei-db/statewal/state_wal_file.go index c9135cf911..ccd3d78cac 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file.go +++ b/sei-db/statewal/state_wal_file.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "bufio" @@ -15,31 +15,31 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" ) -// FlatKV WAL files use the following naming schema (mirroring the hashlog package): +// State WAL files use the following naming schema (mirroring the hashlog package): // -// For mutable files: {index}.fkvwal.u -// For sealed files: {index}-{first block}-{last block}.fkvwal +// For mutable files: {index}.swal.u +// For sealed files: {index}-{first block}-{last block}.swal // // The on-disk serialization version is recorded in each file's header rather than its name. const ( // The file extension for unsealed (mutable) WAL files. - walUnsealedExtension = ".fkvwal.u" + walUnsealedExtension = ".swal.u" // The file extension for sealed (immutable) WAL files. - walSealedExtension = ".fkvwal" + walSealedExtension = ".swal" // The serialization version written into each file's header. Bumped if the on-disk format changes. walFormatVersion = byte(1) ) // The magic prefix written at the start of every WAL file, followed by a single format-version byte. -var walFileMagic = []byte("FKVWAL") +var walFileMagic = []byte("STWAL") // The length of a WAL file header: magic prefix plus the format-version byte. -const walHeaderSize = 7 // len("FKVWAL") + 1 +const walHeaderSize = 6 // len("STWAL") + 1 var ( - unsealedFileRegex = regexp.MustCompile(`^(\d+)\.fkvwal\.u$`) - sealedFileRegex = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)\.fkvwal$`) + unsealedFileRegex = regexp.MustCompile(`^(\d+)\.swal\.u$`) + sealedFileRegex = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)\.swal$`) ) // The result of parsing a WAL file name. @@ -185,7 +185,7 @@ func (f *walFile) writeRecord(record []byte, blockNumber uint64, endOfBlock bool // Serialize, frame, and append a WAL entry to this file. A convenience wrapper over frameRecord and // writeRecord for callers (rollback rewrite, tests) that hold entries rather than pre-framed bytes. -func (f *walFile) writeEntry(entry *FlatKVWalEntry) error { +func (f *walFile) writeEntry(entry *Entry) error { payload, err := entry.Serialize() if err != nil { return fmt.Errorf("failed to serialize WAL entry: %w", err) @@ -304,7 +304,7 @@ type walFileContents struct { parsed parsedFileName // The intact entries read from the file, in order. Excludes any torn trailing record. - entries []*FlatKVWalEntry + entries []*Entry // The first and last block numbers across the intact entries, valid only when hasBlocks is true. firstBlock uint64 @@ -395,7 +395,7 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile break // torn or corrupt record } - entry, entryOK, err := DeserializeFlatKVWalEntry(payload) + entry, entryOK, err := DeserializeEntry(payload) if err != nil { return nil, fmt.Errorf("failed to deserialize record in %s: %w", name, err) } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file_test.go b/sei-db/statewal/state_wal_file_test.go similarity index 91% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_file_test.go rename to sei-db/statewal/state_wal_file_test.go index 62e458fd63..21684d39cf 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_file_test.go +++ b/sei-db/statewal/state_wal_file_test.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "os" @@ -10,14 +10,14 @@ import ( ) func TestFileNaming(t *testing.T) { - require.Equal(t, "3.fkvwal.u", unsealedFileName(3)) - require.Equal(t, "3-10-20.fkvwal", sealedFileName(3, 10, 20)) + require.Equal(t, "3.swal.u", unsealedFileName(3)) + require.Equal(t, "3-10-20.swal", sealedFileName(3, 10, 20)) - parsed, ok := parseFileName("3.fkvwal.u") + parsed, ok := parseFileName("3.swal.u") require.True(t, ok) require.Equal(t, parsedFileName{index: 3, sealed: false}, parsed) - parsed, ok = parseFileName("3-10-20.fkvwal") + parsed, ok = parseFileName("3-10-20.swal") require.True(t, ok) require.Equal(t, parsedFileName{index: 3, firstBlock: 10, lastBlock: 20, sealed: true}, parsed) @@ -40,8 +40,8 @@ func writeMutableFile(t *testing.T, dir string, fn func(f *walFile)) string { func writeCompleteBlock(t *testing.T, f *walFile, block uint64) { t.Helper() cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} - require.NoError(t, f.writeEntry(NewFlatKVWalEntry(block, cs))) - require.NoError(t, f.writeEntry(NewFlatKVEndOfBlockEntry(block))) + require.NoError(t, f.writeEntry(NewEntry(block, cs))) + require.NoError(t, f.writeEntry(NewEndOfBlockEntry(block))) } func TestReadWalFileCleanTail(t *testing.T) { @@ -65,7 +65,7 @@ func TestReadWalFileIncompleteTailBlock(t *testing.T) { writeCompleteBlock(t, f, 1) writeCompleteBlock(t, f, 2) // Block 3 changeset with no end-of-block marker. - require.NoError(t, f.writeEntry(NewFlatKVWalEntry(3, + require.NoError(t, f.writeEntry(NewEntry(3, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{3}, []byte{3})}))) }) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go b/sei-db/statewal/state_wal_impl.go similarity index 90% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go rename to sei-db/statewal/state_wal_impl.go index 6d5aa83abb..3cf6d601ea 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl.go +++ b/sei-db/statewal/state_wal_impl.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "context" @@ -14,13 +14,13 @@ import ( "github.com/sei-protocol/seilog" ) -var _ FlatKVWAL = (*flatKVWalImpl)(nil) +var _ StateWAL = (*stateWALImpl)(nil) -var logger = seilog.NewLogger("db", "state-db", "sc", "flatkv", "wal") +var logger = seilog.NewLogger("db", "state-db", "statewal") // dataToBeSerialized carries an entry from a caller to the serializer to be serialized. type dataToBeSerialized struct { - entry *FlatKVWalEntry + entry *Entry } // dataToBeWritten carries a framed record from the serializer to the writer to be appended. @@ -84,10 +84,10 @@ type sealedFileInfo struct { lastBlock uint64 } -// A standard flatKV WAL implementation. -type flatKVWalImpl struct { +// A standard state WAL implementation. +type stateWALImpl struct { // The configuration this WAL was opened with. Read-only after construction. - config *FlatKVWALConfig + config *Config // caller ──serializerChan──▶ serializer ──writerChan──▶ writer @@ -145,21 +145,21 @@ type flatKVWalImpl struct { blockRefs map[uint64]int } -// NewFlatKVWAL opens (or creates) a flatKV WAL in the configured directory, recovering any files left behind +// New opens (or creates) a state WAL in the configured directory, recovering any files left behind // by a previous session. -func NewFlatKVWAL(config *FlatKVWALConfig) (FlatKVWAL, error) { - return newFlatKVWal(config, nil) +func New(config *Config) (StateWAL, error) { + return newStateWAL(config, nil) } -// NewFlatKVWALWithRollback opens a flatKV WAL and deletes all data for blocks beyond rollbackBlockNumber +// NewWithRollback opens a state WAL and deletes all data for blocks beyond rollbackBlockNumber // before returning, so the WAL contains no block greater than rollbackBlockNumber. -func NewFlatKVWALWithRollback(config *FlatKVWALConfig, rollbackBlockNumber uint64) (FlatKVWAL, error) { - return newFlatKVWal(config, &rollbackBlockNumber) +func NewWithRollback(config *Config, rollbackBlockNumber uint64) (StateWAL, error) { + return newStateWAL(config, &rollbackBlockNumber) } -func newFlatKVWal(config *FlatKVWALConfig, rollbackThrough *uint64) (FlatKVWAL, error) { +func newStateWAL(config *Config, rollbackThrough *uint64) (StateWAL, error) { if err := config.Validate(); err != nil { - return nil, fmt.Errorf("invalid flatKV WAL config: %w", err) + return nil, fmt.Errorf("invalid state WAL config: %w", err) } if err := util.EnsureDirectoryExists(config.Path, true); err != nil { return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) @@ -196,7 +196,7 @@ func newFlatKVWal(config *FlatKVWALConfig, rollbackThrough *uint64) (FlatKVWAL, ctx, cancel := context.WithCancel(context.Background()) senderCtx, senderCancel := context.WithCancel(ctx) - w := &flatKVWalImpl{ + w := &stateWALImpl{ config: config, serializerChan: make(chan any, config.RequestBufferSize), writerChan: make(chan any, config.WriteBufferSize), @@ -224,23 +224,23 @@ func newFlatKVWal(config *FlatKVWALConfig, rollbackThrough *uint64) (FlatKVWAL, } // Write schedules a changeset record for the given block number. -func (w *flatKVWalImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { +func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { if w.closed.Load() { - return fmt.Errorf("flatKV WAL is closed") + return fmt.Errorf("state WAL is closed") } if err := w.enforceWriteOrdering(blockNumber); err != nil { return fmt.Errorf("write rejected: %w", err) } - if err := w.sendToSerializer(dataToBeSerialized{entry: NewFlatKVWalEntry(blockNumber, cs)}); err != nil { + if err := w.sendToSerializer(dataToBeSerialized{entry: NewEntry(blockNumber, cs)}); err != nil { return fmt.Errorf("failed to schedule write for block %d: %w", blockNumber, err) } return nil } // SignalEndOfBlock schedules an end-of-block marker for the current block. -func (w *flatKVWalImpl) SignalEndOfBlock() error { +func (w *stateWALImpl) SignalEndOfBlock() error { if w.closed.Load() { - return fmt.Errorf("flatKV WAL is closed") + return fmt.Errorf("state WAL is closed") } w.mu.Lock() @@ -252,7 +252,7 @@ func (w *flatKVWalImpl) SignalEndOfBlock() error { w.currentBlockEnded = true w.mu.Unlock() - if err := w.sendToSerializer(dataToBeSerialized{entry: NewFlatKVEndOfBlockEntry(blockNumber)}); err != nil { + if err := w.sendToSerializer(dataToBeSerialized{entry: NewEndOfBlockEntry(blockNumber)}); err != nil { return fmt.Errorf("failed to schedule end-of-block for block %d: %w", blockNumber, err) } return nil @@ -260,7 +260,7 @@ func (w *flatKVWalImpl) SignalEndOfBlock() error { // enforceWriteOrdering rejects a Write that violates the block-ordering rules (no decreasing block numbers; no // advancing to a new block before the current one is ended) and records the new position when it is allowed. -func (w *flatKVWalImpl) enforceWriteOrdering(blockNumber uint64) error { +func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { w.mu.Lock() defer w.mu.Unlock() @@ -290,7 +290,7 @@ func (w *flatKVWalImpl) enforceWriteOrdering(blockNumber uint64) error { } // Flush blocks until all previously scheduled writes are durable. -func (w *flatKVWalImpl) Flush() error { +func (w *stateWALImpl) Flush() error { done := make(chan error, 1) if err := w.sendToSerializer(flushRequest{done: done}); err != nil { return fmt.Errorf("failed to schedule flush: %w", err) @@ -307,7 +307,7 @@ func (w *flatKVWalImpl) Flush() error { } // GetStoredRange reports the range of complete blocks stored in the WAL. -func (w *flatKVWalImpl) GetStoredRange() (bool, uint64, uint64, error) { +func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { reply := make(chan storedRange, 1) if err := w.sendToSerializer(rangeQuery{reply: reply}); err != nil { return false, 0, 0, fmt.Errorf("failed to schedule stored-range query: %w", err) @@ -324,7 +324,7 @@ func (w *flatKVWalImpl) GetStoredRange() (bool, uint64, uint64, error) { } // Prune schedules removal of whole sealed files below lowestBlockNumberToKeep. It does not block on completion. -func (w *flatKVWalImpl) Prune(lowestBlockNumberToKeep uint64) error { +func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { if err := w.sendToSerializer(pruneRequest{through: lowestBlockNumberToKeep}); err != nil { return fmt.Errorf("failed to schedule prune below block %d: %w", lowestBlockNumberToKeep, err) } @@ -335,7 +335,7 @@ func (w *flatKVWalImpl) Prune(lowestBlockNumberToKeep uint64) error { // goroutine (see iteratorStartRequest): the writer flushes so all previously scheduled writes are visible, // registers a read lease so pruning cannot delete files out from under the iterator, and builds the iterator. // The lease is released by the iterator's Close. -func (w *flatKVWalImpl) Iterator(startingBlockNumber uint64) (FlatKVWalIterator, error) { +func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (StateWALIterator, error) { reply := make(chan iteratorStartResponse, 1) if err := w.sendToSerializer(iteratorStartRequest{startBlock: startingBlockNumber, reply: reply}); err != nil { return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) @@ -355,12 +355,12 @@ func (w *flatKVWalImpl) Iterator(startingBlockNumber uint64) (FlatKVWalIterator, } // unpinBlock releases a read lease. Best-effort: if the WAL is already shutting down the lease is moot. -func (w *flatKVWalImpl) unpinBlock(block uint64) { +func (w *stateWALImpl) unpinBlock(block uint64) { _ = w.sendToSerializer(unpinRequest{block: block}) } // Close flushes pending writes, seals the mutable file, and releases resources. -func (w *flatKVWalImpl) Close() error { +func (w *stateWALImpl) Close() error { var closeErr error w.closeOnce.Do(func() { w.closed.Store(true) @@ -375,28 +375,28 @@ func (w *flatKVWalImpl) Close() error { w.cancel() }) if err := w.asyncError(); err != nil { - return fmt.Errorf("flatKV WAL closed with error: %w", err) + return fmt.Errorf("state WAL closed with error: %w", err) } return closeErr // already wrapped by the writer, or nil on a clean seal } // sendToSerializer enqueues a message onto the serializer's input channel, aborting if the WAL is // shutting down or has failed. -func (w *flatKVWalImpl) sendToSerializer(msg any) error { +func (w *stateWALImpl) sendToSerializer(msg any) error { select { case w.serializerChan <- msg: return nil case <-w.senderCtx.Done(): if err := w.asyncError(); err != nil { - return fmt.Errorf("flatKV WAL failed: %w", err) + return fmt.Errorf("state WAL failed: %w", err) } - return fmt.Errorf("flatKV WAL is closed") + return fmt.Errorf("state WAL is closed") } } // serializerLoop turns dataToBeSerialized messages into dataToBeWritten messages and forwards every message to // the writer in FIFO order. Runs on its own goroutine until close or a fatal error. -func (w *flatKVWalImpl) serializerLoop() { +func (w *stateWALImpl) serializerLoop() { defer w.wg.Done() for { var msg any @@ -437,7 +437,7 @@ func (w *flatKVWalImpl) serializerLoop() { // writerLoop consumes forwarded messages, appending records to the mutable file and handling control messages. // It owns all file bookkeeping and runs on its own goroutine until close or a fatal error. -func (w *flatKVWalImpl) writerLoop() { +func (w *stateWALImpl) writerLoop() { defer w.wg.Done() for { var msg any @@ -476,7 +476,7 @@ func (w *flatKVWalImpl) writerLoop() { // appendRecord appends a record to the mutable file, updates bookkeeping, and rotates on block boundaries once // the file exceeds the target size. -func (w *flatKVWalImpl) appendRecord(m dataToBeWritten) error { +func (w *stateWALImpl) appendRecord(m dataToBeWritten) error { if err := w.mutableFile.writeRecord(m.record, m.blockNumber, m.endOfBlock); err != nil { return fmt.Errorf("failed to append record for block %d: %w", m.blockNumber, err) } @@ -495,7 +495,7 @@ func (w *flatKVWalImpl) appendRecord(m dataToBeWritten) error { // rotate seals the current mutable file, records its bookkeeping, and opens a fresh mutable file. It is only // called immediately after an end-of-block marker, so the mutable file ends on a block boundary. -func (w *flatKVWalImpl) rotate() error { +func (w *stateWALImpl) rotate() error { index := w.mutableFile.index first := w.mutableFile.firstBlock last := w.mutableFile.lastCompleteBlock @@ -530,7 +530,7 @@ func (w *flatKVWalImpl) rotate() error { // // Iteration stops at the first retained file: block ranges grow toward the back, so once a file is kept (by // pruneThrough or by the lowest reservation) every later file is kept too. -func (w *flatKVWalImpl) pruneSealedFiles(pruneThrough uint64) error { +func (w *stateWALImpl) pruneSealedFiles(pruneThrough uint64) error { // Reservations are mutated only on this (the writer) goroutine, so the lowest reservation is stable for the // duration of this prune and can be computed once. reservation, hasReservation := w.lowestReservation() @@ -561,7 +561,7 @@ func (w *flatKVWalImpl) pruneSealedFiles(pruneThrough uint64) error { // launches its reader goroutine). Running here serializes construction with rotation, seal, and prune, so the // snapshot is a consistent point-in-time view: every file the iterator reads is sealed and immutable, opened // lazily by name and protected from pruning by the lease, so its contents cannot change underneath the reader. -func (w *flatKVWalImpl) startIterator(startBlock uint64) iteratorStartResponse { +func (w *stateWALImpl) startIterator(startBlock uint64) iteratorStartResponse { if err := w.sealForIterator(); err != nil { return iteratorStartResponse{err: fmt.Errorf("failed to seal mutable file before creating iterator: %w", err)} } @@ -586,7 +586,7 @@ func (w *flatKVWalImpl) startIterator(startBlock uint64) iteratorStartResponse { // (unended) block is carried forward into the fresh mutable file so no scheduled write is lost. It is a no-op // when the mutable file holds no complete block — the iterator reads only sealed files and never yields an // unended block, so the mutable file (and any in-progress block) is simply left in place. -func (w *flatKVWalImpl) sealForIterator() error { +func (w *stateWALImpl) sealForIterator() error { if !w.mutableFile.hasCompleteBlock { return nil } @@ -617,7 +617,7 @@ func (w *flatKVWalImpl) sealForIterator() error { // stale low start must not pin files that no longer exist (or wedge pruning forever). Clamping to the oldest // stored block also establishes the invariant pruneSealedFiles relies on: a reservation never falls below the // lowest stored block, so a file entirely below the lowest reservation is one every iterator has moved past. -func (w *flatKVWalImpl) pinLowestReadableBlock(startBlock uint64) uint64 { +func (w *stateWALImpl) pinLowestReadableBlock(startBlock uint64) uint64 { pinned := startBlock if r := w.blockRange(); r.ok && r.start > pinned { pinned = r.start @@ -627,7 +627,7 @@ func (w *flatKVWalImpl) pinLowestReadableBlock(startBlock uint64) uint64 { } // releaseBlock drops one reference to a leased block, forgetting it once the count reaches zero. -func (w *flatKVWalImpl) releaseBlock(block uint64) { +func (w *stateWALImpl) releaseBlock(block uint64) { if w.blockRefs[block] <= 1 { delete(w.blockRefs, block) return @@ -638,7 +638,7 @@ func (w *flatKVWalImpl) releaseBlock(block uint64) { // lowestReservation returns the smallest block number currently leased by a live iterator, and ok=false when no // lease is held. A lease at block R means some iterator may still read blocks at or above R, so every sealed // file whose range reaches R or higher must be retained by pruning. -func (w *flatKVWalImpl) lowestReservation() (uint64, bool) { +func (w *stateWALImpl) lowestReservation() (uint64, bool) { var lowest uint64 found := false for block := range w.blockRefs { @@ -652,7 +652,7 @@ func (w *flatKVWalImpl) lowestReservation() (uint64, bool) { // blockRange reports the range of complete blocks across all files. Complete blocks live in the sealed files // (all complete) and in the mutable file up to its last end-of-block marker. Owned by the writer goroutine. -func (w *flatKVWalImpl) blockRange() storedRange { +func (w *stateWALImpl) blockRange() storedRange { var r storedRange // The highest complete block is in the mutable file if it has one, otherwise in the newest sealed file. @@ -674,14 +674,14 @@ func (w *flatKVWalImpl) blockRange() storedRange { } // fail records the first fatal background error and triggers shutdown of the pipeline. -func (w *flatKVWalImpl) fail(err error) { +func (w *stateWALImpl) fail(err error) { w.asyncErr.CompareAndSwap(nil, &err) w.cancel() - logger.Error("flatKV WAL encountered a fatal error", "err", err) + logger.Error("state WAL encountered a fatal error", "err", err) } // asyncError returns the first fatal background error, or nil if none occurred. -func (w *flatKVWalImpl) asyncError() error { +func (w *stateWALImpl) asyncError() error { if p := w.asyncErr.Load(); p != nil { return *p } diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go b/sei-db/statewal/state_wal_impl_test.go similarity index 97% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go rename to sei-db/statewal/state_wal_impl_test.go index 7b5b765eb4..69ebc3fec8 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_impl_test.go +++ b/sei-db/statewal/state_wal_impl_test.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "os" @@ -10,19 +10,19 @@ import ( "github.com/stretchr/testify/require" ) -func testConfig(dir string) *FlatKVWALConfig { - return DefaultFlatKVWALConfig(dir) +func testConfig(dir string) *Config { + return DefaultConfig(dir) } -func openWAL(t *testing.T, cfg *FlatKVWALConfig) FlatKVWAL { +func openWAL(t *testing.T, cfg *Config) StateWAL { t.Helper() - w, err := NewFlatKVWAL(cfg) + w, err := New(cfg) require.NoError(t, err) return w } // writeBlock writes a single changeset for the block and signals end of block. -func writeBlock(t *testing.T, w FlatKVWAL, block uint64) { +func writeBlock(t *testing.T, w StateWAL, block uint64) { t.Helper() cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} require.NoError(t, w.Write(block, cs)) @@ -31,7 +31,7 @@ func writeBlock(t *testing.T, w FlatKVWAL, block uint64) { // collectBlocks iterates from start and returns the block number of each coalesced block entry, verifying // that entries are strictly increasing and never carry an end-of-block marker. -func collectBlocks(t *testing.T, w FlatKVWAL, start uint64) []uint64 { +func collectBlocks(t *testing.T, w StateWAL, start uint64) []uint64 { t.Helper() it, err := w.Iterator(start) require.NoError(t, err) @@ -165,7 +165,7 @@ func TestOrphanFileRecovery(t *testing.T) { writeCompleteBlock(t, f, 1) writeCompleteBlock(t, f, 2) cs := []*proto.NamedChangeSet{makeChangeSet("a", []byte{3}, []byte{3})} - require.NoError(t, f.writeEntry(NewFlatKVWalEntry(3, cs))) // no end-of-block marker: block 3 is incomplete + require.NoError(t, f.writeEntry(NewEntry(3, cs))) // no end-of-block marker: block 3 is incomplete require.NoError(t, f.flush(true)) require.NoError(t, f.file.Close()) @@ -463,7 +463,7 @@ func TestScanRejectsGapInSealedFiles(t *testing.T) { victim := sealed[len(sealed)/2] require.NoError(t, os.Remove(filepath.Join(dir, sealedFileName(victim.index, victim.firstBlock, victim.lastBlock)))) - _, err = NewFlatKVWAL(cfg) + _, err = New(cfg) require.Error(t, err) require.Contains(t, err.Error(), "not contiguous") } @@ -489,7 +489,7 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewFlatKVWALWithRollback(cfg, 3) + w2, err := NewWithRollback(cfg, 3) require.NoError(t, err) defer func() { require.NoError(t, w2.Close()) }() @@ -511,7 +511,7 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewFlatKVWALWithRollback(cfg, 3) + w2, err := NewWithRollback(cfg, 3) require.NoError(t, err) defer func() { require.NoError(t, w2.Close()) }() @@ -554,7 +554,7 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewFlatKVWALWithRollback(cfg, 3) + w2, err := NewWithRollback(cfg, 3) require.NoError(t, err) require.NoError(t, w2.Close()) @@ -681,7 +681,7 @@ func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { require.NoError(t, w2.Close()) // The subsequent rollback completes cleanly, and a normal reopen sees the consistent rolled-back range. - w3, err := NewFlatKVWALWithRollback(cfg, 3) + w3, err := NewWithRollback(cfg, 3) require.NoError(t, err) require.NoError(t, w3.Close()) diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go b/sei-db/statewal/state_wal_iterator.go similarity index 95% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go rename to sei-db/statewal/state_wal_iterator.go index 9d814db540..96c6aeb3a6 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator.go +++ b/sei-db/statewal/state_wal_iterator.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "fmt" @@ -7,11 +7,11 @@ import ( "sync" ) -var _ FlatKVWalIterator = (*walIterator)(nil) +var _ StateWALIterator = (*walIterator)(nil) // A block produced by the reader goroutine, or a terminal error. type iteratorResult struct { - entry *FlatKVWalEntry + entry *Entry err error } @@ -37,7 +37,7 @@ type iteratorFile struct { // holds the files the reader needs against concurrent pruning; Close releases it. type walIterator struct { // The WAL this iterator reads from. - wal *flatKVWalImpl + wal *stateWALImpl // The lowest block the consumer asked for; blocks below it are skipped. start uint64 @@ -58,7 +58,7 @@ type walIterator struct { closeOnce sync.Once // The entry returned by Entry, set by the most recent successful Next. Consumer-owned. - result *FlatKVWalEntry + result *Entry // Set once iteration is complete. Consumer-owned. done bool @@ -70,7 +70,7 @@ type walIterator struct { // The index into files of the next file to load. filePos int // The records loaded from the current file, filtered to complete blocks at or beyond start. - buffer []*FlatKVWalEntry + buffer []*Entry // The position within buffer; -1 before the first record is read. pos int } @@ -80,7 +80,7 @@ type walIterator struct { // snapshot of files to read (captured on the writer goroutine). prefetch is the number of blocks the reader may // buffer ahead of the consumer. func newWalIterator( - wal *flatKVWalImpl, + wal *stateWALImpl, startingBlockNumber uint64, pinnedBlock uint64, files []iteratorFile, @@ -137,7 +137,7 @@ func (it *walIterator) deliver(result iteratorResult, ok bool) (bool, error) { return true, nil } -func (it *walIterator) Entry() *FlatKVWalEntry { +func (it *walIterator) Entry() *Entry { return it.result } @@ -189,8 +189,8 @@ func (it *walIterator) send(result iteratorResult) bool { // nextBlock assembles the next block by draining records until it consumes that block's end-of-block marker. // Returns ok=false once no records remain. -func (it *walIterator) nextBlock() (*FlatKVWalEntry, bool, error) { - var block *FlatKVWalEntry +func (it *walIterator) nextBlock() (*Entry, bool, error) { + var block *Entry for { record, ok, err := it.nextRecord() if err != nil { @@ -205,7 +205,7 @@ func (it *walIterator) nextBlock() (*FlatKVWalEntry, bool, error) { return nil, false, nil } if block == nil { - block = &FlatKVWalEntry{BlockNumber: record.BlockNumber} + block = &Entry{BlockNumber: record.BlockNumber} } if record.EndOfBlock { return block, true, nil @@ -216,7 +216,7 @@ func (it *walIterator) nextBlock() (*FlatKVWalEntry, bool, error) { // nextRecord returns the next individual record (changeset or end-of-block marker) in ascending order, // advancing across files as needed. It returns ok=false once no further records remain. -func (it *walIterator) nextRecord() (*FlatKVWalEntry, bool, error) { +func (it *walIterator) nextRecord() (*Entry, bool, error) { for { it.pos++ if it.pos < len(it.buffer) { diff --git a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go b/sei-db/statewal/state_wal_iterator_test.go similarity index 99% rename from sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go rename to sei-db/statewal/state_wal_iterator_test.go index 20927a6646..8f73b3484f 100644 --- a/sei-db/state_db/sc/flatkv/wal/flatkv_wal_iterator_test.go +++ b/sei-db/statewal/state_wal_iterator_test.go @@ -1,4 +1,4 @@ -package wal +package statewal import ( "fmt" @@ -103,7 +103,7 @@ func TestIteratorReaderExitsWhenWALTornDownWhileOrphaned(t *testing.T) { // Do not consume the iterator: the reader fills the prefetch buffer (size 1) and blocks on send. Tear // down the WAL context out from under it, as fail() or Close() would. - w.(*flatKVWalImpl).cancel() + w.(*stateWALImpl).cancel() select { case <-iter.readerExited: @@ -229,7 +229,7 @@ func TestConcurrentIterationDuringRotation(t *testing.T) { // drainContiguousFrom fully consumes an iterator anchored at start, verifying the yielded blocks form a // gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not have // produced start yet). Returns the first error encountered. -func drainContiguousFrom(w FlatKVWAL, start uint64) error { +func drainContiguousFrom(w StateWAL, start uint64) error { it, err := w.Iterator(start) if err != nil { return fmt.Errorf("create iterator: %w", err) From 3fbc79adf163f84d7cfa9b2508a8b086889eebc0 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 7 Jul 2026 10:30:37 -0500 Subject: [PATCH 10/44] made suggested changes --- sei-db/statewal/state_wal.go | 16 +++++++++++++--- sei-db/statewal/state_wal_entry.go | 7 +++++++ sei-db/statewal/state_wal_entry_test.go | 23 +++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/sei-db/statewal/state_wal.go b/sei-db/statewal/state_wal.go index fe504fbdd6..bea08ce977 100644 --- a/sei-db/statewal/state_wal.go +++ b/sei-db/statewal/state_wal.go @@ -9,6 +9,9 @@ type StateWAL interface { // // This method only schedules the write, it does not block until the write is complete. // + // cs, and every byte slice reachable through it (changeset keys and values), must not be modified after + // this call. Callers that need to modify those buffers must copy them first. + // // The StateWAL rejects writes for blocks if provided out of order. To avoid errors, observe // the following rules: // @@ -54,8 +57,11 @@ type StateWAL interface { // called again or for a higher block number. Prune(lowestBlockNumberToKeep uint64) error - // Create an iterator over the WAL, starting at the given block number. Iterates all data passed to Write() - // before this call. Data written after this call is not iterated. + // Create an iterator over the WAL, starting at the given block number. + // + // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the + // start and the return of this call. Data written before that instant is included; data written after it + // is not. For data written concurrently with this call, whether it is included is unspecified. Iterator(startingBlockNumber uint64) (StateWALIterator, error) // Close the WAL, flushing any pending writes and releasing resources. @@ -73,7 +79,11 @@ type StateWALIterator interface { Next() (bool, error) // Entry returns the coalesced entry for the block at the iterator's current position. It is only valid to - // call Entry after Next has returned (true, nil). The returned entry must not be modified. + // call Entry after Next has returned (true, nil). + // + // The returned entry, and every byte slice reachable through it (changeset keys and values), must be + // treated as read-only and must not be modified. Callers that need to retain or mutate the data must + // copy it first. Entry() *Entry // Close releases the resources held by the iterator. diff --git a/sei-db/statewal/state_wal_entry.go b/sei-db/statewal/state_wal_entry.go index da0aec1772..f8b17cf2e6 100644 --- a/sei-db/statewal/state_wal_entry.go +++ b/sei-db/statewal/state_wal_entry.go @@ -127,6 +127,13 @@ func DeserializeEntry(data []byte) ( } rest = rest[n:] + // Each changeset entry occupies at least one byte in rest (its length prefix), so a count larger + // than the remaining bytes cannot be valid. Reject it before allocating, to avoid a panic/OOM on a + // corrupt payload that survived the CRC32 check. Mirrors the length bound in the loop below. + if count > uint64(len(rest)) { + return nil, false, nil + } + changeset := make([]*proto.NamedChangeSet, 0, count) for i := uint64(0); i < count; i++ { length, n := binary.Uvarint(rest) diff --git a/sei-db/statewal/state_wal_entry_test.go b/sei-db/statewal/state_wal_entry_test.go index 472bf22c8f..a1576a421f 100644 --- a/sei-db/statewal/state_wal_entry_test.go +++ b/sei-db/statewal/state_wal_entry_test.go @@ -99,3 +99,26 @@ func TestDeserializeUnknownKind(t *testing.T) { require.Error(t, err) require.False(t, ok) } + +func TestDeserializeOversizedCount(t *testing.T) { + // A changeset count larger than the remaining bytes cannot be valid (each entry needs at least a + // one-byte length prefix). It must be rejected as incomplete before allocating, never panicking with + // "makeslice: cap out of range" or OOMing on a corrupt payload that slipped past the CRC32 check. + t.Run("count is MaxUint64", func(t *testing.T) { + // Layout: [kind=changeset][blockNumber=1][count=MaxUint64 as a 10-byte uvarint]. + payload := []byte{byte(kindChangeset), 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01} + got, ok, err := DeserializeEntry(payload) + require.NoError(t, err) + require.False(t, ok) + require.Nil(t, got) + }) + + t.Run("count exceeds remaining bytes", func(t *testing.T) { + // Layout: [kind=changeset][blockNumber=1][count=5], with no changeset bytes following. + payload := []byte{byte(kindChangeset), 0x01, 0x05} + got, ok, err := DeserializeEntry(payload) + require.NoError(t, err) + require.False(t, ok) + require.Nil(t, got) + }) +} From 101e586441a24abdf53a452dce9766c6cf1a0c81 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 7 Jul 2026 10:34:19 -0500 Subject: [PATCH 11/44] move statewal to requested location --- sei-db/{ => state_db}/statewal/metrics.go | 0 sei-db/{ => state_db}/statewal/state_wal.go | 0 sei-db/{ => state_db}/statewal/state_wal_config.go | 0 sei-db/{ => state_db}/statewal/state_wal_config_test.go | 0 sei-db/{ => state_db}/statewal/state_wal_entry.go | 0 sei-db/{ => state_db}/statewal/state_wal_entry_test.go | 0 sei-db/{ => state_db}/statewal/state_wal_file.go | 0 sei-db/{ => state_db}/statewal/state_wal_file_test.go | 0 sei-db/{ => state_db}/statewal/state_wal_impl.go | 0 sei-db/{ => state_db}/statewal/state_wal_impl_test.go | 0 sei-db/{ => state_db}/statewal/state_wal_iterator.go | 0 sei-db/{ => state_db}/statewal/state_wal_iterator_test.go | 0 12 files changed, 0 insertions(+), 0 deletions(-) rename sei-db/{ => state_db}/statewal/metrics.go (100%) rename sei-db/{ => state_db}/statewal/state_wal.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_config.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_config_test.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_entry.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_entry_test.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_file.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_file_test.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_impl.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_impl_test.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_iterator.go (100%) rename sei-db/{ => state_db}/statewal/state_wal_iterator_test.go (100%) diff --git a/sei-db/statewal/metrics.go b/sei-db/state_db/statewal/metrics.go similarity index 100% rename from sei-db/statewal/metrics.go rename to sei-db/state_db/statewal/metrics.go diff --git a/sei-db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go similarity index 100% rename from sei-db/statewal/state_wal.go rename to sei-db/state_db/statewal/state_wal.go diff --git a/sei-db/statewal/state_wal_config.go b/sei-db/state_db/statewal/state_wal_config.go similarity index 100% rename from sei-db/statewal/state_wal_config.go rename to sei-db/state_db/statewal/state_wal_config.go diff --git a/sei-db/statewal/state_wal_config_test.go b/sei-db/state_db/statewal/state_wal_config_test.go similarity index 100% rename from sei-db/statewal/state_wal_config_test.go rename to sei-db/state_db/statewal/state_wal_config_test.go diff --git a/sei-db/statewal/state_wal_entry.go b/sei-db/state_db/statewal/state_wal_entry.go similarity index 100% rename from sei-db/statewal/state_wal_entry.go rename to sei-db/state_db/statewal/state_wal_entry.go diff --git a/sei-db/statewal/state_wal_entry_test.go b/sei-db/state_db/statewal/state_wal_entry_test.go similarity index 100% rename from sei-db/statewal/state_wal_entry_test.go rename to sei-db/state_db/statewal/state_wal_entry_test.go diff --git a/sei-db/statewal/state_wal_file.go b/sei-db/state_db/statewal/state_wal_file.go similarity index 100% rename from sei-db/statewal/state_wal_file.go rename to sei-db/state_db/statewal/state_wal_file.go diff --git a/sei-db/statewal/state_wal_file_test.go b/sei-db/state_db/statewal/state_wal_file_test.go similarity index 100% rename from sei-db/statewal/state_wal_file_test.go rename to sei-db/state_db/statewal/state_wal_file_test.go diff --git a/sei-db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go similarity index 100% rename from sei-db/statewal/state_wal_impl.go rename to sei-db/state_db/statewal/state_wal_impl.go diff --git a/sei-db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go similarity index 100% rename from sei-db/statewal/state_wal_impl_test.go rename to sei-db/state_db/statewal/state_wal_impl_test.go diff --git a/sei-db/statewal/state_wal_iterator.go b/sei-db/state_db/statewal/state_wal_iterator.go similarity index 100% rename from sei-db/statewal/state_wal_iterator.go rename to sei-db/state_db/statewal/state_wal_iterator.go diff --git a/sei-db/statewal/state_wal_iterator_test.go b/sei-db/state_db/statewal/state_wal_iterator_test.go similarity index 100% rename from sei-db/statewal/state_wal_iterator_test.go rename to sei-db/state_db/statewal/state_wal_iterator_test.go From 691a2171a9ac685cfd160bfb2191222e09b0354a Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 8 Jul 2026 08:11:43 -0500 Subject: [PATCH 12/44] split into abstract utility --- .../{state_db/statewal => seiwal}/metrics.go | 26 +- sei-db/seiwal/seiwal.go | 85 +++ sei-db/seiwal/seiwal_config.go | 57 ++ sei-db/seiwal/seiwal_config_test.go | 30 + .../seiwal_file.go} | 342 ++++----- sei-db/seiwal/seiwal_file_test.go | 153 ++++ sei-db/seiwal/seiwal_impl.go | 668 ++++++++++++++++++ sei-db/seiwal/seiwal_impl_test.go | 665 +++++++++++++++++ sei-db/seiwal/seiwal_iterator.go | 258 +++++++ sei-db/seiwal/seiwal_iterator_test.go | 263 +++++++ sei-db/state_db/statewal/state_wal.go | 7 +- sei-db/state_db/statewal/state_wal_config.go | 41 +- sei-db/state_db/statewal/state_wal_entry.go | 159 ++--- .../state_db/statewal/state_wal_entry_test.go | 109 +-- .../state_db/statewal/state_wal_file_test.go | 150 ---- sei-db/state_db/statewal/state_wal_impl.go | 637 ++++------------- .../state_db/statewal/state_wal_impl_test.go | 559 ++------------- .../state_db/statewal/state_wal_iterator.go | 282 +------- .../statewal/state_wal_iterator_test.go | 312 ++------ 19 files changed, 2667 insertions(+), 2136 deletions(-) rename sei-db/{state_db/statewal => seiwal}/metrics.go (51%) create mode 100644 sei-db/seiwal/seiwal.go create mode 100644 sei-db/seiwal/seiwal_config.go create mode 100644 sei-db/seiwal/seiwal_config_test.go rename sei-db/{state_db/statewal/state_wal_file.go => seiwal/seiwal_file.go} (51%) create mode 100644 sei-db/seiwal/seiwal_file_test.go create mode 100644 sei-db/seiwal/seiwal_impl.go create mode 100644 sei-db/seiwal/seiwal_impl_test.go create mode 100644 sei-db/seiwal/seiwal_iterator.go create mode 100644 sei-db/seiwal/seiwal_iterator_test.go delete mode 100644 sei-db/state_db/statewal/state_wal_file_test.go diff --git a/sei-db/state_db/statewal/metrics.go b/sei-db/seiwal/metrics.go similarity index 51% rename from sei-db/state_db/statewal/metrics.go rename to sei-db/seiwal/metrics.go index f63c5d23ad..90048b7bc8 100644 --- a/sei-db/state_db/statewal/metrics.go +++ b/sei-db/seiwal/metrics.go @@ -1,41 +1,41 @@ -package statewal +package seiwal import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/metric" ) -// The name of the OpenTelemetry meter for state WAL metrics. -const walMeterName = "seidb_statewal" +// The name of the OpenTelemetry meter for WAL metrics. +const walMeterName = "seidb_seiwal" var ( walMeter = otel.Meter(walMeterName) - // The number of blocks (end-of-block markers) written to the WAL. - walBlocksWritten = must(walMeter.Int64Counter( - "state_wal_blocks_written", - metric.WithDescription("Number of blocks written to the state WAL"), + // The number of records appended to the WAL. + walRecordsWritten = must(walMeter.Int64Counter( + "seiwal_records_written", + metric.WithDescription("Number of records appended to the WAL"), metric.WithUnit("{count}"), )) // The number of record bytes appended to the WAL (including framing). walBytesWritten = must(walMeter.Int64Counter( - "state_wal_bytes_written", - metric.WithDescription("Number of bytes written to the state WAL"), + "seiwal_bytes_written", + metric.WithDescription("Number of bytes written to the WAL"), metric.WithUnit("By"), )) // The number of WAL files sealed (rotated) after reaching the target size. walFilesSealed = must(walMeter.Int64Counter( - "state_wal_files_sealed", - metric.WithDescription("Number of state WAL files sealed on rotation"), + "seiwal_files_sealed", + metric.WithDescription("Number of WAL files sealed on rotation"), metric.WithUnit("{count}"), )) // The number of sealed WAL files deleted by pruning. walFilesPruned = must(walMeter.Int64Counter( - "state_wal_files_pruned", - metric.WithDescription("Number of state WAL files removed by pruning"), + "seiwal_files_pruned", + metric.WithDescription("Number of WAL files removed by pruning"), metric.WithUnit("{count}"), )) ) diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go new file mode 100644 index 0000000000..be18add286 --- /dev/null +++ b/sei-db/seiwal/seiwal.go @@ -0,0 +1,85 @@ +package seiwal + +// WAL is a generic, index-keyed, append-only write-ahead log over opaque byte payloads. +// +// Each record is tagged with a caller-provided monotonic index. The index is what makes garbage +// collection ("drop everything below N"), iteration ("start at N"), and rollback ("drop everything +// above N") expressible without the WAL ever interpreting a payload. The WAL never inspects the bytes +// it stores; callers own all serialization. +type WAL interface { + + // Append a record with the given index and payload. + // + // The index must be strictly greater than the index of the most recently appended record (indices + // need not be contiguous, but they must strictly increase). data may be empty; it is copied into the + // WAL's framing before this call returns, so the caller may reuse the buffer immediately. + // + // This method only schedules the append; it does not block until the record is durable. Durability is + // achieved by a subsequent Flush. + Append(index uint64, data []byte) error + + // Flush blocks until all previously scheduled appends are durable. + Flush() error + + // Bounds reports the range of record indices currently stored in the WAL. + Bounds() ( + // If true, there is at least one record in the WAL and first/last are valid. If false, the WAL is + // empty and first/last are undefined. + ok bool, + // The lowest stored record index, inclusive. Only valid if ok is true. + first uint64, + // The highest stored record index, inclusive. Only valid if ok is true. + last uint64, + // Any error encountered while retrieving the range. + err error, + ) + + // Prune removes all records with an index less than lowestIndexToKeep. + // + // This method merely schedules the prune; it does not block until the prune is complete. Pruning is + // async and lazy, and implementations are free to delay it arbitrarily long. Pruning removes whole + // sealed files only, so records may survive above the requested threshold until their containing file + // is fully below it. + Prune(lowestIndexToKeep uint64) error + + // Iterator returns an iterator over the WAL starting at the given index. + // + // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the + // start and the return of this call. Records appended before that instant are included; records + // appended after it are not. For records appended concurrently with this call, whether they are + // included is unspecified. + Iterator(startIndex uint64) (Iterator, error) + + // Close flushes pending appends, seals the current file, and releases resources. + Close() error +} + +// Iterator iterates over the records of a WAL in ascending index order. +type Iterator interface { + // Next advances the iterator to the next record. It returns false when iteration is complete (no more + // records), and returns an error if advancing failed. After Next returns (false, nil), iteration is + // complete; after it returns an error, the iterator must not be used further (other than Close). + Next() (bool, error) + + // Entry returns the index and payload of the record at the iterator's current position. It is only + // valid to call Entry after Next has returned (true, nil). + // + // The returned payload must be treated as read-only and must not be modified. Callers that need to + // retain or mutate the data must copy it first. + Entry() (index uint64, data []byte) + + // Close releases the resources held by the iterator. + Close() error +} + +// New opens (or creates) a WAL in the configured directory, recovering any files left behind by a +// previous session. +func New(config *Config) (WAL, error) { + return newWAL(config, nil) +} + +// NewWithRollback opens a WAL and deletes all records with an index greater than rollbackIndex before +// returning, so the WAL contains no record with an index greater than rollbackIndex. +func NewWithRollback(config *Config, rollbackIndex uint64) (WAL, error) { + return newWAL(config, &rollbackIndex) +} diff --git a/sei-db/seiwal/seiwal_config.go b/sei-db/seiwal/seiwal_config.go new file mode 100644 index 0000000000..60e9d85a22 --- /dev/null +++ b/sei-db/seiwal/seiwal_config.go @@ -0,0 +1,57 @@ +package seiwal + +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/common/unit" +) + +// Config configures a WAL. +type Config struct { + // The directory where the WAL writes its files. + Path string + + // The size of the channel used to send framed records and control messages to the writer goroutine. + WriteBufferSize uint + + // The size a WAL file may reach before it is sealed and a fresh one is opened. Rotation happens after a + // record is appended, so a file may exceed this by the size of a single record — and because a record + // is written atomically to a single file, a record larger than this threshold produces a file that + // overshoots it by that record's size. Must be greater than 0. + TargetFileSize uint + + // When true, Flush calls fsync on the underlying file so that flushed data survives a power loss, not + // merely a process crash. When false, Flush only flushes the in-process buffer to the OS. + FsyncOnFlush bool + + // The number of records an iterator's reader thread may prefetch ahead of the consumer. A larger value + // keeps the reader busy while the consumer processes records, which matters for startup replay speed. + // Must be greater than 0. + IteratorPrefetchSize uint +} + +// DefaultConfig returns a default WAL configuration. +func DefaultConfig(path string) *Config { + return &Config{ + Path: path, + WriteBufferSize: 16, + TargetFileSize: 64 * unit.MB, + FsyncOnFlush: true, + IteratorPrefetchSize: 32, + } +} + +// Validate the configuration, returning nil if valid, or an error describing the problem if invalid. +func (c *Config) Validate() error { + if c.Path == "" { + return fmt.Errorf("path is required") + } + if c.TargetFileSize == 0 { + // A zero target would seal and rotate a fresh file after every single record. + return fmt.Errorf("target file size must be greater than 0") + } + if c.IteratorPrefetchSize == 0 { + return fmt.Errorf("iterator prefetch size must be greater than 0") + } + return nil +} diff --git a/sei-db/seiwal/seiwal_config_test.go b/sei-db/seiwal/seiwal_config_test.go new file mode 100644 index 0000000000..cd426f640f --- /dev/null +++ b/sei-db/seiwal/seiwal_config_test.go @@ -0,0 +1,30 @@ +package seiwal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigValidate(t *testing.T) { + t.Run("default config is valid", func(t *testing.T) { + require.NoError(t, DefaultConfig("/tmp/wal").Validate()) + }) + + t.Run("empty path is rejected", func(t *testing.T) { + cfg := DefaultConfig("") + require.Error(t, cfg.Validate()) + }) + + t.Run("zero target file size is rejected", func(t *testing.T) { + cfg := DefaultConfig("/tmp/wal") + cfg.TargetFileSize = 0 + require.Error(t, cfg.Validate()) + }) + + t.Run("zero iterator prefetch size is rejected", func(t *testing.T) { + cfg := DefaultConfig("/tmp/wal") + cfg.IteratorPrefetchSize = 0 + require.Error(t, cfg.Validate()) + }) +} diff --git a/sei-db/state_db/statewal/state_wal_file.go b/sei-db/seiwal/seiwal_file.go similarity index 51% rename from sei-db/state_db/statewal/state_wal_file.go rename to sei-db/seiwal/seiwal_file.go index ccd3d78cac..817d88bcc0 100644 --- a/sei-db/state_db/statewal/state_wal_file.go +++ b/sei-db/seiwal/seiwal_file.go @@ -1,4 +1,4 @@ -package statewal +package seiwal import ( "bufio" @@ -15,70 +15,72 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" ) -// State WAL files use the following naming schema (mirroring the hashlog package): +// WAL files use the following naming schema: // -// For mutable files: {index}.swal.u -// For sealed files: {index}-{first block}-{last block}.swal +// For mutable files: {file sequence}.wal.u +// For sealed files: {file sequence}-{first index}-{last index}.wal // -// The on-disk serialization version is recorded in each file's header rather than its name. +// The file sequence is a unique, monotonically increasing counter identifying the file; the first and last +// index are the record indices the sealed file spans. The on-disk serialization version is recorded in each +// file's header rather than its name. const ( // The file extension for unsealed (mutable) WAL files. - walUnsealedExtension = ".swal.u" + walUnsealedExtension = ".wal.u" // The file extension for sealed (immutable) WAL files. - walSealedExtension = ".swal" + walSealedExtension = ".wal" // The serialization version written into each file's header. Bumped if the on-disk format changes. walFormatVersion = byte(1) ) // The magic prefix written at the start of every WAL file, followed by a single format-version byte. -var walFileMagic = []byte("STWAL") +var walFileMagic = []byte("SEIWL") // The length of a WAL file header: magic prefix plus the format-version byte. -const walHeaderSize = 6 // len("STWAL") + 1 +const walHeaderSize = 6 // len("SEIWL") + 1 var ( - unsealedFileRegex = regexp.MustCompile(`^(\d+)\.swal\.u$`) - sealedFileRegex = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)\.swal$`) + unsealedFileRegex = regexp.MustCompile(`^(\d+)\.wal\.u$`) + sealedFileRegex = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)\.wal$`) ) // The result of parsing a WAL file name. type parsedFileName struct { - index uint64 - firstBlock uint64 - lastBlock uint64 + fileSeq uint64 + firstIndex uint64 + lastIndex uint64 sealed bool } // Parse a WAL file name into its components. Returns false if the name is not a WAL file name. func parseFileName(fileName string) (parsedFileName, bool) { if m := sealedFileRegex.FindStringSubmatch(fileName); m != nil { - index, err1 := strconv.ParseUint(m[1], 10, 64) + seq, err1 := strconv.ParseUint(m[1], 10, 64) first, err2 := strconv.ParseUint(m[2], 10, 64) last, err3 := strconv.ParseUint(m[3], 10, 64) if err1 != nil || err2 != nil || err3 != nil { return parsedFileName{}, false } - return parsedFileName{index: index, firstBlock: first, lastBlock: last, sealed: true}, true + return parsedFileName{fileSeq: seq, firstIndex: first, lastIndex: last, sealed: true}, true } if m := unsealedFileRegex.FindStringSubmatch(fileName); m != nil { - index, err := strconv.ParseUint(m[1], 10, 64) + seq, err := strconv.ParseUint(m[1], 10, 64) if err != nil { return parsedFileName{}, false } - return parsedFileName{index: index, sealed: false}, true + return parsedFileName{fileSeq: seq, sealed: false}, true } return parsedFileName{}, false } // Build the name of an unsealed (mutable) WAL file. -func unsealedFileName(index uint64) string { - return fmt.Sprintf("%d%s", index, walUnsealedExtension) +func unsealedFileName(fileSeq uint64) string { + return fmt.Sprintf("%d%s", fileSeq, walUnsealedExtension) } // Build the name of a sealed WAL file. -func sealedFileName(index uint64, firstBlock uint64, lastBlock uint64) string { - return fmt.Sprintf("%d-%d-%d%s", index, firstBlock, lastBlock, walSealedExtension) +func sealedFileName(fileSeq uint64, firstIndex uint64, lastIndex uint64) string { + return fmt.Sprintf("%d-%d-%d%s", fileSeq, firstIndex, lastIndex, walSealedExtension) } // A single WAL file on disk, either the current mutable file being appended to or a sealed file. @@ -90,31 +92,24 @@ type walFile struct { file *os.File writer *bufio.Writer - // The unique, monotonically increasing index of this file. - index uint64 + // The unique, monotonically increasing sequence number of this file. + fileSeq uint64 // If true, this file is sealed and rejects writes. sealed bool - // The first and last block numbers that appear in this file, valid only when hasBlocks is true. - firstBlock uint64 - lastBlock uint64 - hasBlocks bool - - // The highest block number in this file terminated by an end-of-block marker, and the file size at that - // marker. Valid only when hasCompleteBlock is true. On seal, any records past completeSize (an incomplete - // trailing block) are truncated so the sealed file ends cleanly on a block boundary. - lastCompleteBlock uint64 - completeSize uint64 - hasCompleteBlock bool + // The first and last record indices that appear in this file, valid only when hasRecords is true. + firstIndex uint64 + lastIndex uint64 + hasRecords bool // The number of bytes written to this file so far, including the header. size uint64 } // Create a new mutable WAL file on disk, writing its header, ready to accept records. -func newWalFile(directory string, index uint64) (*walFile, error) { - path := filepath.Join(directory, unsealedFileName(index)) +func newWalFile(directory string, fileSeq uint64) (*walFile, error) { + path := filepath.Join(directory, unsealedFileName(fileSeq)) file, err := os.Create(path) //nolint:gosec // path derived from a validated directory if err != nil { return nil, fmt.Errorf("failed to create WAL file %s: %w", path, err) @@ -139,29 +134,33 @@ func newWalFile(directory string, index uint64) (*walFile, error) { directory: directory, file: file, writer: writer, - index: index, + fileSeq: fileSeq, size: walHeaderSize, }, nil } -// frameRecord wraps a serialized payload in its on-disk framing: -// [uvarint payload length][payload][uint32 CRC32(payload)]. -func frameRecord(payload []byte) []byte { +// frameRecord wraps a payload in its on-disk framing: +// [uvarint index][uvarint payload length][payload][uint32 CRC32(index+length+payload)]. +// The checksum covers the index and length prefixes as well as the payload, so a torn or corrupt index is +// detected on recovery exactly as a torn payload is. +func frameRecord(index uint64, payload []byte) []byte { + var idxBuf [binary.MaxVarintLen64]byte + idxN := binary.PutUvarint(idxBuf[:], index) var lenBuf [binary.MaxVarintLen64]byte lenN := binary.PutUvarint(lenBuf[:], uint64(len(payload))) - record := make([]byte, 0, lenN+len(payload)+4) + record := make([]byte, 0, idxN+lenN+len(payload)+4) + record = append(record, idxBuf[:idxN]...) record = append(record, lenBuf[:lenN]...) record = append(record, payload...) var crcBuf [4]byte - binary.BigEndian.PutUint32(crcBuf[:], crc32.ChecksumIEEE(payload)) + binary.BigEndian.PutUint32(crcBuf[:], crc32.ChecksumIEEE(record)) record = append(record, crcBuf[:]...) return record } -// Append a pre-framed record (see frameRecord) for the given block number to this file. endOfBlock marks the -// record as an end-of-block marker, which advances the file's completed-block boundary. -func (f *walFile) writeRecord(record []byte, blockNumber uint64, endOfBlock bool) error { +// Append a pre-framed record (see frameRecord) with the given index to this file. +func (f *walFile) writeRecord(record []byte, index uint64) error { if f.sealed { return fmt.Errorf("cannot write to a sealed WAL file") } @@ -170,67 +169,11 @@ func (f *walFile) writeRecord(record []byte, blockNumber uint64, endOfBlock bool } f.size += uint64(len(record)) - if !f.hasBlocks { - f.firstBlock = blockNumber - f.hasBlocks = true - } - f.lastBlock = blockNumber - if endOfBlock { - f.lastCompleteBlock = blockNumber - f.completeSize = f.size - f.hasCompleteBlock = true - } - return nil -} - -// Serialize, frame, and append a WAL entry to this file. A convenience wrapper over frameRecord and -// writeRecord for callers (rollback rewrite, tests) that hold entries rather than pre-framed bytes. -func (f *walFile) writeEntry(entry *Entry) error { - payload, err := entry.Serialize() - if err != nil { - return fmt.Errorf("failed to serialize WAL entry: %w", err) - } - return f.writeRecord(frameRecord(payload), entry.BlockNumber, entry.EndOfBlock) -} - -// readIncompleteTail returns the raw framed bytes of the in-progress block — everything written past the -// last end-of-block marker — so a caller sealing the file for iteration can carry those records into a -// fresh file rather than losing them to the seal's truncation. Returns nil when the file already ends on a -// block boundary. Only meaningful when hasCompleteBlock is true (completeSize marks the last boundary). -func (f *walFile) readIncompleteTail() ([]byte, error) { - if f.size <= f.completeSize { - return nil, nil - } - if err := f.writer.Flush(); err != nil { - return nil, fmt.Errorf("failed to flush before reading incomplete tail: %w", err) - } - length := f.size - f.completeSize - buf := make([]byte, length) - n, err := f.file.ReadAt(buf, int64(f.completeSize)) //nolint:gosec // completeSize <= size - if err != nil && (err != io.EOF || n != len(buf)) { - return nil, fmt.Errorf("failed to read incomplete tail: %w", err) - } - if n != len(buf) { - return nil, fmt.Errorf("short read of incomplete tail: read %d of %d bytes", n, len(buf)) - } - return buf, nil -} - -// appendIncompleteTail re-appends the raw framed bytes of an in-progress block (captured by -// readIncompleteTail from the file being sealed) to this fresh mutable file, restoring the block-tracking -// state so subsequent writes and the eventual end-of-block marker behave as if the block had been written -// here all along. block is the in-progress block's number (a single block, by the write-ordering contract). -func (f *walFile) appendIncompleteTail(tail []byte, block uint64) error { - if f.sealed { - return fmt.Errorf("cannot write to a sealed WAL file") - } - if _, err := f.writer.Write(tail); err != nil { - return fmt.Errorf("failed to write carried-forward block: %w", err) + if !f.hasRecords { + f.firstIndex = index + f.hasRecords = true } - f.size += uint64(len(tail)) - f.firstBlock = block - f.lastBlock = block - f.hasBlocks = true + f.lastIndex = index return nil } @@ -249,9 +192,10 @@ func (f *walFile) flush(fsync bool) error { return nil } -// Seal this file: flush it, truncate away any incomplete trailing block, then atomically rename it to its -// sealed name. A file with no complete blocks (including one that never received a record) is removed rather -// than sealed. Idempotent. Returns the sealed file name, or "" if the file was removed. +// Seal this file: flush it, then atomically rename it to its sealed name. Every record written in-process is +// whole (records are framed and written atomically), so there is nothing to truncate. A file with no records +// (including one that never received a record) is removed rather than sealed. Idempotent. Returns the sealed +// file name, or "" if the file was removed. func (f *walFile) seal() (string, error) { if f.sealed { return "", nil @@ -260,8 +204,8 @@ func (f *walFile) seal() (string, error) { return "", fmt.Errorf("failed to flush before sealing: %w", err) } - unsealedPath := filepath.Join(f.directory, unsealedFileName(f.index)) - if !f.hasCompleteBlock { + unsealedPath := filepath.Join(f.directory, unsealedFileName(f.fileSeq)) + if !f.hasRecords { if f.file != nil { if err := f.file.Close(); err != nil { return "", fmt.Errorf("failed to close WAL file: %w", err) @@ -275,21 +219,12 @@ func (f *walFile) seal() (string, error) { } if f.file != nil { - // Drop any records past the last end-of-block marker so the sealed file ends on a block boundary. - if f.size > f.completeSize { - if err := f.file.Truncate(int64(f.completeSize)); err != nil { //nolint:gosec // completeSize <= size - return "", fmt.Errorf("failed to truncate incomplete trailing block: %w", err) - } - if err := f.file.Sync(); err != nil { - return "", fmt.Errorf("failed to fsync WAL file after truncation: %w", err) - } - } if err := f.file.Close(); err != nil { return "", fmt.Errorf("failed to close WAL file: %w", err) } } - sealedName := sealedFileName(f.index, f.firstBlock, f.lastCompleteBlock) + sealedName := sealedFileName(f.fileSeq, f.firstIndex, f.lastIndex) sealedPath := filepath.Join(f.directory, sealedName) if err := util.AtomicRename(unsealedPath, sealedPath, true); err != nil { return "", fmt.Errorf("failed to seal WAL file: %w", err) @@ -298,41 +233,35 @@ func (f *walFile) seal() (string, error) { return sealedName, nil } +// A single record read from a WAL file: its index, its payload (a sub-slice of the file's bytes), and the +// byte offset just past its framing. +type walRecord struct { + index uint64 + payload []byte + end int64 +} + // The result of reading a WAL file from disk. type walFileContents struct { // The parsed file name components. parsed parsedFileName - // The intact entries read from the file, in order. Excludes any torn trailing record. - entries []*Entry - - // The first and last block numbers across the intact entries, valid only when hasBlocks is true. - firstBlock uint64 - lastBlock uint64 - hasBlocks bool - - // The byte offset just past the last record terminated by an end-of-block marker. Data beyond this offset - // belongs to an incomplete (uncommitted) block, or is a torn trailing record, and is discarded on recovery. - lastCompleteBlockOffset int64 + // The intact records read from the file, in order. Excludes any torn trailing record. + records []walRecord - // The highest block number terminated by an end-of-block marker, valid only when hasCompleteBlock is true. - lastCompleteBlock uint64 - hasCompleteBlock bool + // The first and last record indices across the intact records, valid only when hasRecords is true. + firstIndex uint64 + lastIndex uint64 + hasRecords bool - // One entry per end-of-block marker, recording the marker's block number and the byte offset just past its - // record. Ordered by ascending offset. Used to truncate the file at a block boundary (e.g. for rollback). - blockBoundaries []blockBoundary -} - -// The byte offset just past an end-of-block marker for a given block number. -type blockBoundary struct { - block uint64 - offset int64 + // The byte offset just past the last intact record. Data beyond this offset is a torn trailing record and + // is discarded on recovery. + validEnd int64 } // Read a WAL file from disk, tolerating a torn trailing record (a crash mid-write can leave a final record -// whose length prefix, payload, or checksum is incomplete). Any bytes past the last intact record are -// discarded; the last intact record's boundaries are reported so callers can recover incomplete tail blocks. +// whose index prefix, length prefix, payload, or checksum is incomplete). Any bytes past the last intact +// record are discarded; the last intact record's boundary is reported so callers can truncate the torn tail. func readWalFile(path string) (*walFileContents, error) { name := filepath.Base(path) parsed, ok := parseFileName(name) @@ -358,7 +287,7 @@ func readWalFileFromHandle(file *os.File, parsed parsedFileName) (*walFileConten return parseWalFileData(data, parsed, file.Name()) } -// parseWalFileData parses the raw bytes of a WAL file (already read into memory) into its intact entries, +// parseWalFileData parses the raw bytes of a WAL file (already read into memory) into its intact records, // tolerating a torn trailing record. name is used only for error messages. It is shared by readWalFile (which // reads by path) and the iterator (which reads through a file handle opened on the writer goroutine). func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFileContents, error) { @@ -374,48 +303,40 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile if version := data[len(walFileMagic)]; version != walFormatVersion { return nil, fmt.Errorf("WAL file %s has unsupported format version %d", name, version) } - contents.lastCompleteBlockOffset = walHeaderSize + contents.validEnd = walHeaderSize offset := walHeaderSize for offset < len(data) { - length, lenN := binary.Uvarint(data[offset:]) + index, idxN := binary.Uvarint(data[offset:]) + if idxN <= 0 { + break // torn or incomplete index prefix + } + lenStart := offset + idxN + length, lenN := binary.Uvarint(data[lenStart:]) if lenN <= 0 { break // torn or incomplete length prefix } - payloadStart := offset + lenN + payloadStart := lenStart + lenN remaining := uint64(len(data) - payloadStart) //nolint:gosec // payloadStart <= len(data), so non-negative if remaining < 4 || length > remaining-4 { break // torn record: payload or checksum truncated (4 trailing bytes are the CRC32) } payloadLen := int(length) //nolint:gosec // bounded above by remaining-4, which is <= len(data) - payload := data[payloadStart : payloadStart+payloadLen] - recordEnd := payloadStart + payloadLen + 4 - gotCRC := binary.BigEndian.Uint32(data[payloadStart+payloadLen : recordEnd]) - if gotCRC != crc32.ChecksumIEEE(payload) { + payloadEnd := payloadStart + payloadLen + recordEnd := payloadEnd + 4 + gotCRC := binary.BigEndian.Uint32(data[payloadEnd:recordEnd]) + if gotCRC != crc32.ChecksumIEEE(data[offset:payloadEnd]) { break // torn or corrupt record } - entry, entryOK, err := DeserializeEntry(payload) - if err != nil { - return nil, fmt.Errorf("failed to deserialize record in %s: %w", name, err) - } - if !entryOK { - break // torn payload - } - - contents.entries = append(contents.entries, entry) - if !contents.hasBlocks { - contents.firstBlock = entry.BlockNumber - contents.hasBlocks = true - } - contents.lastBlock = entry.BlockNumber - if entry.EndOfBlock { - contents.lastCompleteBlockOffset = int64(recordEnd) - contents.lastCompleteBlock = entry.BlockNumber - contents.hasCompleteBlock = true - contents.blockBoundaries = append(contents.blockBoundaries, - blockBoundary{block: entry.BlockNumber, offset: int64(recordEnd)}) + contents.records = append(contents.records, + walRecord{index: index, payload: data[payloadStart:payloadEnd], end: int64(recordEnd)}) + if !contents.hasRecords { + contents.firstIndex = index + contents.hasRecords = true } + contents.lastIndex = index + contents.validEnd = int64(recordEnd) offset = recordEnd } @@ -425,7 +346,7 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile // truncateAndSync truncates the file at path to size and fsyncs it, so the shorter length is durable on its // own — before any subsequent rename. Without the fsync, a crash could persist a rename while losing the -// truncation, leaving a file whose name promises fewer blocks than its content actually holds. +// truncation, leaving a file whose name promises more records than its content actually holds. func truncateAndSync(path string, size int64) error { file, err := os.OpenFile(path, os.O_RDWR, 0) //nolint:gosec // caller-supplied path if err != nil { @@ -446,7 +367,7 @@ func truncateAndSync(path string, size int64) error { } // removeAndSyncDir removes the named file and fsyncs its parent directory, so the removal is durable before the -// caller proceeds. Callers rely on this to keep the sealed-file index sequence gap-free across a crash. +// caller proceeds. Callers rely on this to keep the sealed-file sequence gap-free across a crash. func removeAndSyncDir(directory string, name string) error { path := filepath.Join(directory, name) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { @@ -459,9 +380,8 @@ func removeAndSyncDir(directory string, name string) error { } // Seal an orphaned mutable file discovered on disk at startup (left behind by a crash before it could be -// sealed). Any incomplete trailing block (records not terminated by an end-of-block marker) or torn trailing -// record is truncated away first, so the sealed file ends cleanly on a block boundary. A file left with no -// complete blocks is removed. +// sealed). Any torn trailing record is truncated away first, so the sealed file ends cleanly on a record +// boundary. A file left with no records is removed. func sealOrphanFile(directory string, name string) error { path := filepath.Join(directory, name) contents, err := readWalFile(path) @@ -469,31 +389,31 @@ func sealOrphanFile(directory string, name string) error { return fmt.Errorf("failed to read orphaned WAL file %s: %w", path, err) } - if !contents.hasCompleteBlock { + if !contents.hasRecords { return removeAndSyncDir(directory, name) } - if err := truncateAndSync(path, contents.lastCompleteBlockOffset); err != nil { + if err := truncateAndSync(path, contents.validEnd); err != nil { return fmt.Errorf("failed to truncate orphaned WAL file %s: %w", path, err) } sealedPath := filepath.Join(directory, - sealedFileName(contents.parsed.index, contents.firstBlock, contents.lastCompleteBlock)) + sealedFileName(contents.parsed.fileSeq, contents.firstIndex, contents.lastIndex)) if err := util.AtomicRename(path, sealedPath, true); err != nil { return fmt.Errorf("failed to seal orphaned WAL file %s: %w", path, err) } return nil } -// rollbackStraddlingFile handles the single sealed file that spans rollbackThrough: it drops every block beyond -// the rollback point and reduces the file's range accordingly. The name of a sealed file encodes its block -// range, so the reduced content and the reduced name must become durable together — a crash must never leave a -// file whose name promises more blocks than its content holds (the iterator bounds sealed reads by content and -// would otherwise under-yield, while GetStoredRange trusts the name and would over-report). Because the reduced -// range means a different file name, this cannot be a single in-place rename: the kept prefix is written to a -// fresh correctly-named file via AtomicWrite (durable on its own), and only then is the old, larger-named file -// removed. A crash after the write but before the removal leaves both files under the same index; recovery's -// reconcileRollbackRemnants resolves that deterministically. Files entirely beyond the rollback point are -// removed by the caller; this handles only the boundary file. +// rollbackStraddlingFile handles the single sealed file that spans rollbackThrough: it drops every record +// beyond the rollback point and reduces the file's range accordingly. The name of a sealed file encodes its +// index range, so the reduced content and the reduced name must become durable together — a crash must never +// leave a file whose name promises more records than its content holds (the iterator bounds sealed reads by +// content and would otherwise under-yield, while Bounds trusts the name and would over-report). Because the +// reduced range means a different file name, this cannot be a single in-place rename: the kept prefix is +// written to a fresh correctly-named file via AtomicWrite (durable on its own), and only then is the old, +// larger-named file removed. A crash after the write but before the removal leaves both files under the same +// file sequence; recovery's reconcileRollbackRemnants resolves that deterministically. Files entirely beyond +// the rollback point are removed by the caller; this handles only the boundary file. func rollbackStraddlingFile(directory string, name string, rollbackThrough uint64) error { path := filepath.Join(directory, name) parsed, ok := parseFileName(name) @@ -512,25 +432,25 @@ func rollbackStraddlingFile(directory string, name string, rollbackThrough uint6 truncateTo := int64(walHeaderSize) var lastKept uint64 kept := false - for _, boundary := range contents.blockBoundaries { - if boundary.block > rollbackThrough { + for _, record := range contents.records { + if record.index > rollbackThrough { break } - truncateTo = boundary.offset - lastKept = boundary.block + truncateTo = record.end + lastKept = record.index kept = true } if !kept { - // The content holds no complete block at or below the rollback point after all; drop the whole file. + // The content holds no record at or below the rollback point after all; drop the whole file. return removeAndSyncDir(directory, name) } - if lastKept == contents.lastBlock { + if lastKept == contents.lastIndex { return nil // nothing beyond the rollback point; leave the file untouched } // Write the kept prefix to a fresh file under its reduced name, made durable before the old file is removed. - newName := sealedFileName(parsed.index, contents.firstBlock, lastKept) + newName := sealedFileName(parsed.fileSeq, contents.firstIndex, lastKept) newPath := filepath.Join(directory, newName) if err := util.AtomicWrite(newPath, data[:truncateTo], true); err != nil { return fmt.Errorf("failed to write rolled-back WAL file %s: %w", newPath, err) @@ -542,18 +462,18 @@ func rollbackStraddlingFile(directory string, name string, rollbackThrough uint6 } // reconcileRollbackRemnants resolves the one crash window left by rollbackStraddlingFile: a crash after the -// reduced file was written but before the old, larger-named file was removed leaves two sealed files sharing an -// index. This can happen only from an interrupted rollback swap (healthy operation never assigns an index to -// two files), so the reduced file (the one with the smaller last block) is the intended survivor; the larger -// one is removed. Both files are internally name/content consistent, so the choice is made from names alone -// without reading contents. A no-op in the common case where every sealed index is unique. +// reduced file was written but before the old, larger-named file was removed leaves two sealed files sharing a +// file sequence. This can happen only from an interrupted rollback swap (healthy operation never assigns a +// sequence to two files), so the reduced file (the one with the smaller last index) is the intended survivor; +// the larger one is removed. Both files are internally name/content consistent, so the choice is made from +// names alone without reading contents. A no-op in the common case where every sealed sequence is unique. func reconcileRollbackRemnants(directory string) error { entries, err := os.ReadDir(directory) if err != nil { return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) } - // The name kept for each sealed index so far. A duplicate index means an interrupted rollback swap. + // The name kept for each sealed sequence so far. A duplicate sequence means an interrupted rollback swap. kept := make(map[uint64]parsedFileName) for _, entry := range entries { if entry.IsDir() { @@ -563,23 +483,23 @@ func reconcileRollbackRemnants(directory string) error { if !ok || !parsed.sealed { continue } - prev, seen := kept[parsed.index] + prev, seen := kept[parsed.fileSeq] if !seen { - kept[parsed.index] = parsed + kept[parsed.fileSeq] = parsed continue } - // Two sealed files share this index. Keep the one with the smaller last block (the rolled-back + // Two sealed files share this sequence. Keep the one with the smaller last index (the rolled-back // result) and remove the other. A sealed name is a deterministic function of its parsed fields, so // each file's name is recoverable without tracking the raw directory entry. keep, drop := parsed, prev - if prev.lastBlock <= parsed.lastBlock { + if prev.lastIndex <= parsed.lastIndex { keep, drop = prev, parsed } - dropName := sealedFileName(drop.index, drop.firstBlock, drop.lastBlock) + dropName := sealedFileName(drop.fileSeq, drop.firstIndex, drop.lastIndex) if err := removeAndSyncDir(directory, dropName); err != nil { return fmt.Errorf("failed to remove rollback remnant %s: %w", dropName, err) } - kept[parsed.index] = keep + kept[parsed.fileSeq] = keep } return nil } diff --git a/sei-db/seiwal/seiwal_file_test.go b/sei-db/seiwal/seiwal_file_test.go new file mode 100644 index 0000000000..c7a515f641 --- /dev/null +++ b/sei-db/seiwal/seiwal_file_test.go @@ -0,0 +1,153 @@ +package seiwal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFileNaming(t *testing.T) { + require.Equal(t, "3.wal.u", unsealedFileName(3)) + require.Equal(t, "3-10-20.wal", sealedFileName(3, 10, 20)) + + parsed, ok := parseFileName("3.wal.u") + require.True(t, ok) + require.Equal(t, parsedFileName{fileSeq: 3, sealed: false}, parsed) + + parsed, ok = parseFileName("3-10-20.wal") + require.True(t, ok) + require.Equal(t, parsedFileName{fileSeq: 3, firstIndex: 10, lastIndex: 20, sealed: true}, parsed) + + _, ok = parseFileName("not-a-wal-file.txt") + require.False(t, ok) +} + +// writeMutableFile creates a mutable file at sequence 0, applies fn to it, then flushes and closes the +// underlying handle without sealing, leaving an unsealed file on disk. It returns the file path. +func writeMutableFile(t *testing.T, dir string, fn func(f *walFile)) string { + t.Helper() + f, err := newWalFile(dir, 0) + require.NoError(t, err) + fn(f) + require.NoError(t, f.flush(true)) + require.NoError(t, f.file.Close()) + return filepath.Join(dir, unsealedFileName(0)) +} + +// writeRecordTo frames and appends a record for the given index to f. +func writeRecordTo(t *testing.T, f *walFile, index uint64, payload []byte) { + t.Helper() + require.NoError(t, f.writeRecord(frameRecord(index, payload), index)) +} + +func TestReadWalFileCleanTail(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeRecordTo(t, f, 1, []byte("one")) + writeRecordTo(t, f, 2, []byte("two")) + }) + + contents, err := readWalFile(path) + require.NoError(t, err) + require.True(t, contents.hasRecords) + require.Equal(t, uint64(1), contents.firstIndex) + require.Equal(t, uint64(2), contents.lastIndex) + require.Len(t, contents.records, 2) +} + +func TestReadWalFileTornTrailingRecord(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeRecordTo(t, f, 1, []byte("one")) + writeRecordTo(t, f, 2, []byte("two")) + // A third record whose framing is truncated mid-payload, as a torn write would leave. + frame := frameRecord(3, []byte("three")) + require.NoError(t, f.flush(false)) + _, err := f.writer.Write(frame[:len(frame)-3]) + require.NoError(t, err) + }) + + contents, err := readWalFile(path) + require.NoError(t, err) + // The torn record 3 is dropped; the last intact record is 2. + require.True(t, contents.hasRecords) + require.Equal(t, uint64(2), contents.lastIndex) + require.Len(t, contents.records, 2) +} + +func TestReadWalFilePartialLengthPrefix(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeRecordTo(t, f, 1, []byte("one")) + }) + + // Append a lone 0x80 byte: an incomplete uvarint prefix, as a torn write would leave. + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + _, err = f.Write([]byte{0x80}) + require.NoError(t, err) + require.NoError(t, f.Close()) + + contents, err := readWalFile(path) + require.NoError(t, err) + require.True(t, contents.hasRecords) + require.Equal(t, uint64(1), contents.lastIndex) + require.Len(t, contents.records, 1) +} + +func TestReadWalFileMidRecordTruncation(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeRecordTo(t, f, 1, []byte("one")) + writeRecordTo(t, f, 2, []byte("two")) + }) + + info, err := os.Stat(path) + require.NoError(t, err) + // Lop a few bytes off the end, tearing record 2. + require.NoError(t, os.Truncate(path, info.Size()-3)) + + contents, err := readWalFile(path) + require.NoError(t, err) + require.True(t, contents.hasRecords) + require.Equal(t, uint64(1), contents.lastIndex) + require.Len(t, contents.records, 1) +} + +func TestReadWalFileChecksumMismatch(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeRecordTo(t, f, 1, []byte("one")) + writeRecordTo(t, f, 2, []byte("two")) + }) + + // Flip the final byte (part of record 2's CRC), so that record fails its checksum. + data, err := os.ReadFile(path) + require.NoError(t, err) + data[len(data)-1] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + + contents, err := readWalFile(path) + require.NoError(t, err) + // Record 1 survives; the corrupt record 2 is dropped. + require.True(t, contents.hasRecords) + require.Equal(t, uint64(1), contents.lastIndex) + require.Len(t, contents.records, 1) +} + +func TestReadWalFileBadMagic(t *testing.T) { + dir := t.TempDir() + path := writeMutableFile(t, dir, func(f *walFile) { + writeRecordTo(t, f, 1, []byte("one")) + }) + + data, err := os.ReadFile(path) + require.NoError(t, err) + data[0] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + + _, err = readWalFile(path) + require.Error(t, err) +} diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go new file mode 100644 index 0000000000..0cd25abfe3 --- /dev/null +++ b/sei-db/seiwal/seiwal_impl.go @@ -0,0 +1,668 @@ +package seiwal + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + + "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" + "github.com/sei-protocol/seilog" +) + +var _ WAL = (*walImpl)(nil) + +var logger = seilog.NewLogger("db", "seiwal") + +// dataToBeWritten carries a framed record from a caller to the writer to be appended. +type dataToBeWritten struct { + record []byte + index uint64 +} + +// flushRequest asks the writer to flush (and optionally fsync) the mutable file, signaling done when durable. +type flushRequest struct { + done chan error +} + +// rangeQuery asks the writer to report the stored index range. +type rangeQuery struct { + reply chan storedRange +} + +// pruneRequest asks the writer to drop whole sealed files below `through`. +type pruneRequest struct { + through uint64 +} + +// closeRequest asks the writer to seal the mutable file and shut down, signaling done when sealed. +type closeRequest struct { + done chan error +} + +// unpinRequest releases a read lease previously registered when an iterator was created. +type unpinRequest struct { + index uint64 +} + +// iteratorStartRequest asks the writer to construct an iterator. The writer flushes the mutable file (so the +// iterator observes all prior appends), snapshots the current set of files, registers the read lease, and +// builds the iterator, all on its own goroutine so construction is serialized with rotation/seal/prune. +type iteratorStartRequest struct { + startIndex uint64 + reply chan iteratorStartResponse +} + +// The iterator (or an error) produced by the writer in response to an iteratorStartRequest. +type iteratorStartResponse struct { + iterator *walIterator + err error +} + +// The index range reported by Bounds. +type storedRange struct { + ok bool + first uint64 + last uint64 +} + +// Bookkeeping for a sealed WAL file, owned by the writer goroutine. +type sealedFileInfo struct { + fileSeq uint64 + name string + firstIndex uint64 + lastIndex uint64 +} + +// A generic write-ahead log implementation. +type walImpl struct { + // The configuration this WAL was opened with. Read-only after construction. + config *Config + + // Callers funnel framed records and control messages through writerChan as a single ordered stream to + // the writer goroutine. + writerChan chan any + + // The hard-stop context the writer watches. Cancelled by fail() on a fatal error and by Close() once + // everything has drained. + ctx context.Context + cancel context.CancelFunc + + // A child of ctx that the writerChan producers watch, cancelled once the writer stops reading so an + // in-flight or future push aborts rather than deadlocking. + senderCtx context.Context + senderCancel context.CancelFunc + + // Tracks the writer goroutine so Close() can wait for it to exit. + wg sync.WaitGroup + + // Guarantees the Close() shutdown sequence runs at most once. + closeOnce sync.Once + + // Set by Close() so subsequent scheduling calls fail fast. + closed atomic.Bool + + // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). + asyncErr atomic.Pointer[error] + + // Guards the append-ordering state below, which is read/written synchronously in Append (not on the + // writer goroutine). + appendMu sync.Mutex + // The index of the most recently appended record. + lastAppendIndex uint64 + // Whether any record has been appended (this session or recovered from disk). + hasAppended bool + + // The following fields are owned exclusively by the writer goroutine. + + // The current mutable file accepting records. + mutableFile *walFile + + // The sequence number to assign the next mutable file. + nextFileSeq uint64 + + // Sealed files in ascending index order. Rotation appends to the back; pruning removes from the front. + sealedFiles *util.RandomAccessDeque[*sealedFileInfo] + + // Read leases held by live iterators: record index -> reference count. Pruning will not delete a file + // whose index range contains a leased index. Mutated only by the writer goroutine. + indexRefs map[uint64]int +} + +func newWAL(config *Config, rollbackThrough *uint64) (WAL, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid WAL config: %w", err) + } + if err := util.EnsureDirectoryExists(config.Path, true); err != nil { + return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) + } + + // Clean up remnants of a rollback swap interrupted by a crash before scanning (see rollbackStraddlingFile): + // a leftover swap file from an unfinished AtomicWrite, or two sealed files sharing a sequence because the old + // file was not yet removed. This leaves a set where every sealed sequence is unique and name matches content. + if err := util.DeleteOrphanedSwapFiles(config.Path); err != nil { + return nil, fmt.Errorf("failed to delete orphaned swap files: %w", err) + } + if err := reconcileRollbackRemnants(config.Path); err != nil { + return nil, fmt.Errorf("failed to reconcile rollback remnants: %w", err) + } + if err := recoverOrphans(config.Path); err != nil { + return nil, fmt.Errorf("failed to recover orphaned WAL files: %w", err) + } + if rollbackThrough != nil { + if err := rollbackDirectory(config.Path, *rollbackThrough); err != nil { + return nil, fmt.Errorf("failed to roll back WAL beyond index %d: %w", *rollbackThrough, err) + } + } + + sealedFiles, nextFileSeq, err := scanSealedFiles(config.Path) + if err != nil { + return nil, fmt.Errorf("failed to scan sealed WAL files: %w", err) + } + + mutable, err := newWalFile(config.Path, nextFileSeq) + if err != nil { + return nil, fmt.Errorf("failed to open mutable WAL file: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + senderCtx, senderCancel := context.WithCancel(ctx) + + w := &walImpl{ + config: config, + writerChan: make(chan any, config.WriteBufferSize), + ctx: ctx, + cancel: cancel, + senderCtx: senderCtx, + senderCancel: senderCancel, + mutableFile: mutable, + nextFileSeq: nextFileSeq + 1, + sealedFiles: sealedFiles, + indexRefs: make(map[uint64]int), + } + // Recover the append-ordering position from the highest index already on disk. + if r := w.bounds(); r.ok { + w.lastAppendIndex = r.last + w.hasAppended = true + } + + w.wg.Add(1) + go w.writerLoop() + + return w, nil +} + +// Append frames a record and schedules it for the writer, after enforcing that indices strictly increase. +func (w *walImpl) Append(index uint64, data []byte) error { + if w.closed.Load() { + return fmt.Errorf("WAL is closed") + } + + w.appendMu.Lock() + if w.hasAppended && index <= w.lastAppendIndex { + last := w.lastAppendIndex + w.appendMu.Unlock() + return fmt.Errorf("append rejected: index %d is not greater than last appended index %d", index, last) + } + w.lastAppendIndex = index + w.hasAppended = true + w.appendMu.Unlock() + + record := frameRecord(index, data) + if err := w.sendToWriter(dataToBeWritten{record: record, index: index}); err != nil { + return fmt.Errorf("failed to schedule append for index %d: %w", index, err) + } + return nil +} + +// Flush blocks until all previously scheduled appends are durable. +func (w *walImpl) Flush() error { + done := make(chan error, 1) + if err := w.sendToWriter(flushRequest{done: done}); err != nil { + return fmt.Errorf("failed to schedule flush: %w", err) + } + select { + case err := <-done: + return err // already wrapped by the writer, or nil on success + case <-w.ctx.Done(): + if err := w.asyncError(); err != nil { + return fmt.Errorf("flush aborted: %w", err) + } + return fmt.Errorf("flush aborted: %w", w.ctx.Err()) + } +} + +// Bounds reports the range of record indices stored in the WAL. +func (w *walImpl) Bounds() (bool, uint64, uint64, error) { + reply := make(chan storedRange, 1) + if err := w.sendToWriter(rangeQuery{reply: reply}); err != nil { + return false, 0, 0, fmt.Errorf("failed to schedule bounds query: %w", err) + } + select { + case r := <-reply: + return r.ok, r.first, r.last, nil + case <-w.ctx.Done(): + if err := w.asyncError(); err != nil { + return false, 0, 0, fmt.Errorf("bounds query aborted: %w", err) + } + return false, 0, 0, fmt.Errorf("bounds query aborted: %w", w.ctx.Err()) + } +} + +// Prune schedules removal of whole sealed files below lowestIndexToKeep. It does not block on completion. +func (w *walImpl) Prune(lowestIndexToKeep uint64) error { + if err := w.sendToWriter(pruneRequest{through: lowestIndexToKeep}); err != nil { + return fmt.Errorf("failed to schedule prune below index %d: %w", lowestIndexToKeep, err) + } + return nil +} + +// Iterator returns an iterator over the WAL starting at startIndex. Construction runs on the writer goroutine +// (see iteratorStartRequest): the writer flushes so all previously scheduled appends are visible, registers a +// read lease so pruning cannot delete files out from under the iterator, and builds the iterator. The lease is +// released by the iterator's Close. +func (w *walImpl) Iterator(startIndex uint64) (Iterator, error) { + reply := make(chan iteratorStartResponse, 1) + if err := w.sendToWriter(iteratorStartRequest{startIndex: startIndex, reply: reply}); err != nil { + return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) + } + select { + case resp := <-reply: + if resp.err != nil { + return nil, fmt.Errorf("failed to create iterator: %w", resp.err) + } + return resp.iterator, nil + case <-w.ctx.Done(): + if err := w.asyncError(); err != nil { + return nil, fmt.Errorf("iterator creation aborted: %w", err) + } + return nil, fmt.Errorf("iterator creation aborted: %w", w.ctx.Err()) + } +} + +// unpinIndex releases a read lease. Best-effort: if the WAL is already shutting down the lease is moot. +func (w *walImpl) unpinIndex(index uint64) { + _ = w.sendToWriter(unpinRequest{index: index}) +} + +// Close flushes pending appends, seals the mutable file, and releases resources. +func (w *walImpl) Close() error { + var closeErr error + w.closeOnce.Do(func() { + w.closed.Store(true) + done := make(chan error, 1) + if err := w.sendToWriter(closeRequest{done: done}); err == nil { + select { + case closeErr = <-done: + case <-w.ctx.Done(): + } + } + w.wg.Wait() + w.cancel() + }) + if err := w.asyncError(); err != nil { + return fmt.Errorf("WAL closed with error: %w", err) + } + return closeErr // already wrapped by the writer, or nil on a clean seal +} + +// sendToWriter enqueues a message onto the writer's input channel, aborting if the WAL is shutting down or has +// failed. +func (w *walImpl) sendToWriter(msg any) error { + select { + case w.writerChan <- msg: + return nil + case <-w.senderCtx.Done(): + if err := w.asyncError(); err != nil { + return fmt.Errorf("WAL failed: %w", err) + } + return fmt.Errorf("WAL is closed") + } +} + +// writerLoop consumes messages, appending records to the mutable file and handling control messages. It owns +// all file bookkeeping and runs on its own goroutine until close or a fatal error. +func (w *walImpl) writerLoop() { + defer w.wg.Done() + for { + var msg any + select { + case <-w.ctx.Done(): + return + case msg = <-w.writerChan: + } + + switch m := msg.(type) { + case dataToBeWritten: + if err := w.appendRecord(m); err != nil { + w.fail(err) + return + } + case flushRequest: + m.done <- w.mutableFile.flush(w.config.FsyncOnFlush) + case rangeQuery: + m.reply <- w.bounds() + case pruneRequest: + if err := w.pruneSealedFiles(m.through); err != nil { + w.fail(err) + return + } + case iteratorStartRequest: + m.reply <- w.startIterator(m.startIndex) + case unpinRequest: + w.releaseIndex(m.index) + case closeRequest: + _, err := w.mutableFile.seal() + m.done <- err + // FIFO guarantees every prior append has been processed. Forbid further pushes so any + // racing/future schedule aborts instead of deadlocking against the now-exiting writer. + w.senderCancel() + return + } + } +} + +// appendRecord appends a record to the mutable file, updates bookkeeping, and rotates once the file exceeds +// the target size. Every record is complete, so any record is a valid rotation boundary. +func (w *walImpl) appendRecord(m dataToBeWritten) error { + if err := w.mutableFile.writeRecord(m.record, m.index); err != nil { + return fmt.Errorf("failed to append record for index %d: %w", m.index, err) + } + walBytesWritten.Add(w.ctx, int64(len(m.record))) + walRecordsWritten.Add(w.ctx, 1) + + if w.mutableFile.size >= uint64(w.config.TargetFileSize) { + if err := w.rotate(); err != nil { + return fmt.Errorf("failed to rotate after index %d: %w", m.index, err) + } + } + return nil +} + +// rotate seals the current mutable file, records its bookkeeping, and opens a fresh mutable file. It is only +// called when the mutable file holds at least one record (immediately after an append, or from sealForIterator +// when it has records), so the seal always produces a sealed file rather than removing an empty one. +func (w *walImpl) rotate() error { + fileSeq := w.mutableFile.fileSeq + first := w.mutableFile.firstIndex + last := w.mutableFile.lastIndex + sealedName, err := w.mutableFile.seal() + if err != nil { + return fmt.Errorf("failed to seal WAL file during rotation: %w", err) + } + w.sealedFiles.PushBack(&sealedFileInfo{fileSeq: fileSeq, name: sealedName, firstIndex: first, lastIndex: last}) + walFilesSealed.Add(w.ctx, 1) + + mutable, err := newWalFile(w.config.Path, w.nextFileSeq) + if err != nil { + return fmt.Errorf("failed to open new mutable WAL file during rotation: %w", err) + } + w.mutableFile = mutable + w.nextFileSeq++ + return nil +} + +// pruneSealedFiles deletes sealed files whose highest index is below pruneThrough. Files are removed +// oldest-first (from the front of the deque) with a directory fsync after each removal, so a crash mid-prune +// leaves a contiguous suffix of files rather than a gap in the sequence. The mutable file is never pruned. +// +// A live iterator holds a read lease at some index R and may still read every record from R onward, so no file +// whose range reaches R or higher may be removed. A file [first, last] is needed iff it overlaps [R, ∞), i.e. +// iff last >= R. Comparing the lowest live reservation against each file's last index (rather than testing +// whether the reservation falls inside a file's range) protects exactly the files an iterator can still open — +// even when the reservation lands in a gap between files or strictly inside a file's range. Because +// reservations never fall below the lowest stored index (see pinLowestReadableIndex), a file left below the +// lowest reservation is one the iterator has already moved past and can safely be dropped. +// +// Iteration stops at the first retained file: index ranges grow toward the back, so once a file is kept (by +// pruneThrough or by the lowest reservation) every later file is kept too. +func (w *walImpl) pruneSealedFiles(pruneThrough uint64) error { + // Reservations are mutated only on this (the writer) goroutine, so the lowest reservation is stable for the + // duration of this prune and can be computed once. + reservation, hasReservation := w.lowestReservation() + for { + front, ok := w.sealedFiles.TryPeekFront() + if !ok || front.lastIndex >= pruneThrough { + break + } + if hasReservation && front.lastIndex >= reservation { + break // a live iterator may still read this file (or a later one); keep it and everything after + } + path := filepath.Join(w.config.Path, front.name) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to prune WAL file %s: %w", path, err) + } + if err := util.SyncParentPath(path); err != nil { + return fmt.Errorf("failed to fsync directory after pruning %s: %w", path, err) + } + w.sealedFiles.PopFront() + walFilesPruned.Add(w.ctx, 1) + } + return nil +} + +// startIterator builds an iterator on the writer goroutine. It first seals the mutable file (see +// sealForIterator) so every record written so far lives in an immutable sealed file, then snapshots the sealed +// files in ascending index order, registers the read lease, and constructs the iterator (which launches its +// reader goroutine). Running here serializes construction with rotation, seal, and prune, so the snapshot is a +// consistent point-in-time view: every file the iterator reads is sealed and immutable, opened lazily by name +// and protected from pruning by the lease, so its contents cannot change underneath the reader. +func (w *walImpl) startIterator(startIndex uint64) iteratorStartResponse { + if err := w.sealForIterator(); err != nil { + return iteratorStartResponse{err: fmt.Errorf("failed to seal mutable file before creating iterator: %w", err)} + } + + files := make([]iteratorFile, 0, w.sealedFiles.Size()) + for _, info := range w.sealedFiles.Iterator() { + files = append(files, iteratorFile{ + fileSeq: info.fileSeq, + name: info.name, + firstIndex: info.firstIndex, + lastIndex: info.lastIndex, + }) + } + + pinned := w.pinLowestReadableIndex(startIndex) + it := newWalIterator(w, startIndex, pinned, files, w.config.IteratorPrefetchSize) + return iteratorStartResponse{iterator: it} +} + +// sealForIterator seals the mutable file so a newly-created iterator sees a snapshot that cannot change +// underneath it: after this call every record lives in an immutable sealed file. It is a no-op when the +// mutable file holds no records — the iterator reads only sealed files, so an empty mutable file is simply +// left in place. +func (w *walImpl) sealForIterator() error { + if !w.mutableFile.hasRecords { + return nil + } + if err := w.rotate(); err != nil { + return fmt.Errorf("failed to seal mutable file: %w", err) + } + return nil +} + +// pinLowestReadableIndex records a read lease and returns the pinned index. An iterator reads records at or +// above startIndex but never below the oldest record actually stored, so the lease is clamped up to that: a +// stale low start must not pin files that no longer exist (or wedge pruning forever). Clamping to the oldest +// stored index also establishes the invariant pruneSealedFiles relies on: a reservation never falls below the +// lowest stored index, so a file entirely below the lowest reservation is one every iterator has moved past. +func (w *walImpl) pinLowestReadableIndex(startIndex uint64) uint64 { + pinned := startIndex + if r := w.bounds(); r.ok && r.first > pinned { + pinned = r.first + } + w.indexRefs[pinned]++ + return pinned +} + +// releaseIndex drops one reference to a leased index, forgetting it once the count reaches zero. +func (w *walImpl) releaseIndex(index uint64) { + if w.indexRefs[index] <= 1 { + delete(w.indexRefs, index) + return + } + w.indexRefs[index]-- +} + +// lowestReservation returns the smallest index currently leased by a live iterator, and ok=false when no lease +// is held. A lease at index R means some iterator may still read records at or above R, so every sealed file +// whose range reaches R or higher must be retained by pruning. +func (w *walImpl) lowestReservation() (uint64, bool) { + var lowest uint64 + found := false + for index := range w.indexRefs { + if !found || index < lowest { + lowest = index + found = true + } + } + return lowest, found +} + +// bounds reports the range of record indices across all files. Owned by the writer goroutine. +func (w *walImpl) bounds() storedRange { + var r storedRange + + // The highest index is in the mutable file if it has records, otherwise in the newest sealed file. + if w.mutableFile.hasRecords { + r = storedRange{ok: true, last: w.mutableFile.lastIndex} + } else if back, ok := w.sealedFiles.TryPeekBack(); ok { + r = storedRange{ok: true, last: back.lastIndex} + } else { + return storedRange{} // nothing stored yet + } + + // The lowest index is in the oldest sealed file if any, otherwise in the mutable file. + if front, ok := w.sealedFiles.TryPeekFront(); ok { + r.first = front.firstIndex + } else { + r.first = w.mutableFile.firstIndex + } + return r +} + +// fail records the first fatal background error and triggers shutdown of the pipeline. +func (w *walImpl) fail(err error) { + w.asyncErr.CompareAndSwap(nil, &err) + w.cancel() + logger.Error("WAL encountered a fatal error", "err", err) +} + +// asyncError returns the first fatal background error, or nil if none occurred. +func (w *walImpl) asyncError() error { + if p := w.asyncErr.Load(); p != nil { + return *p + } + return nil +} + +// recoverOrphans seals any unsealed WAL files left behind by a crash. +func recoverOrphans(directory string) error { + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + parsed, ok := parseFileName(entry.Name()) + if !ok || parsed.sealed { + continue + } + if err := sealOrphanFile(directory, entry.Name()); err != nil { + return fmt.Errorf("failed to seal orphan %s: %w", entry.Name(), err) + } + } + return nil +} + +// rollbackDirectory drops all records beyond rollbackThrough from the sealed files. Assumes orphans are already +// sealed. Files are processed highest-sequence-first: the files entirely beyond the rollback point (a suffix of +// the sequence) are removed one at a time, each removal made durable before the next, and finally the single +// file straddling the rollback point is truncated. This ordering guarantees that a crash mid-rollback always +// leaves a contiguous prefix of files — never a gap that scanSealedFiles would reject — mirroring the +// contiguous-suffix guarantee that pruning provides from the other end. +func rollbackDirectory(directory string, rollbackThrough uint64) error { + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + + sealed := make([]parsedFileName, 0, len(entries)) + names := make(map[uint64]string, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + parsed, ok := parseFileName(entry.Name()) + if !ok || !parsed.sealed { + continue + } + sealed = append(sealed, parsed) + names[parsed.fileSeq] = entry.Name() + } + sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq > sealed[j].fileSeq }) + + for _, parsed := range sealed { + if parsed.lastIndex <= rollbackThrough { + // This file lies entirely at or below the rollback point; so does every lower-sequence file. Done. + break + } + if parsed.firstIndex > rollbackThrough { + // Entirely beyond the rollback point: remove the whole file, durably, before the next-lower one. + if err := removeAndSyncDir(directory, names[parsed.fileSeq]); err != nil { + return fmt.Errorf("failed to roll back %s: %w", names[parsed.fileSeq], err) + } + continue + } + // Straddles the rollback point: truncate away the records beyond it. This is the last file to process. + if err := rollbackStraddlingFile(directory, names[parsed.fileSeq], rollbackThrough); err != nil { + return fmt.Errorf("failed to roll back %s: %w", names[parsed.fileSeq], err) + } + } + return nil +} + +// scanSealedFiles loads the sealed files in a directory into an ascending-order deque and returns the sequence +// to assign the next mutable file (one past the highest sealed sequence, or 0 when there are none). File +// sequences must be contiguous: a gap means a sealed file went missing, which is unrecoverable corruption, so +// this fails with an informative error rather than silently leaving a hole in the index sequence. +func scanSealedFiles(directory string) (*util.RandomAccessDeque[*sealedFileInfo], uint64, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, 0, fmt.Errorf("failed to read WAL directory %s: %w", directory, err) + } + + parsed := make([]parsedFileName, 0, len(entries)) + names := make(map[uint64]string, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + p, ok := parseFileName(entry.Name()) + if !ok || !p.sealed { + continue + } + parsed = append(parsed, p) + names[p.fileSeq] = entry.Name() + } + sort.Slice(parsed, func(i int, j int) bool { return parsed[i].fileSeq < parsed[j].fileSeq }) + + sealedFiles := util.NewRandomAccessDeque[*sealedFileInfo](uint64(len(parsed))) + var nextFileSeq uint64 + for i, p := range parsed { + if i > 0 && p.fileSeq != parsed[i-1].fileSeq+1 { + return nil, 0, fmt.Errorf( + "WAL is corrupt: sealed file sequences are not contiguous (gap between %d and %d)", + parsed[i-1].fileSeq, p.fileSeq) + } + sealedFiles.PushBack(&sealedFileInfo{ + fileSeq: p.fileSeq, name: names[p.fileSeq], firstIndex: p.firstIndex, lastIndex: p.lastIndex}) + nextFileSeq = p.fileSeq + 1 + } + return sealedFiles, nextFileSeq, nil +} diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go new file mode 100644 index 0000000000..581b38a054 --- /dev/null +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -0,0 +1,665 @@ +package seiwal + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" +) + +func testConfig(dir string) *Config { + return DefaultConfig(dir) +} + +func openWAL(t *testing.T, cfg *Config) WAL { + t.Helper() + w, err := New(cfg) + require.NoError(t, err) + return w +} + +// recordPayload returns a deterministic payload for a record index. +func recordPayload(index uint64) []byte { + return []byte(fmt.Sprintf("payload-%d", index)) +} + +// appendRecord appends a record with recordPayload(index) at the given index. +func appendRecord(t *testing.T, w WAL, index uint64) { + t.Helper() + require.NoError(t, w.Append(index, recordPayload(index))) +} + +// collectIndices iterates from start and returns the index of each record, verifying that indices are +// strictly increasing and never below start. +func collectIndices(t *testing.T, w WAL, start uint64) []uint64 { + t.Helper() + it, err := w.Iterator(start) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + var indices []uint64 + for { + ok, err := it.Next() + require.NoError(t, err) + if !ok { + break + } + index, _ := it.Entry() + require.GreaterOrEqual(t, index, start) + if len(indices) > 0 { + require.Greater(t, index, indices[len(indices)-1]) + } + indices = append(indices, index) + } + return indices +} + +func countSealedFiles(t *testing.T, dir string) int { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + count := 0 + for _, entry := range entries { + if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { + count++ + } + } + return count +} + +// sealedFileNames returns the names of all sealed WAL files in dir, sorted for stable assertions. +func sealedFileNames(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + var names []string + for _, entry := range entries { + if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { + names = append(names, entry.Name()) + } + } + sort.Strings(names) + return names +} + +func TestAppendFlushReopenBounds(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(5), last) + require.NoError(t, w.Close()) + + w2 := openWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + + ok, first, last, err = w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(5), last) + + require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectIndices(t, w2, 1)) +} + +func TestAppendOrdering(t *testing.T) { + t.Run("index must strictly increase", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + require.NoError(t, w.Append(5, recordPayload(5))) + require.Error(t, w.Append(4, recordPayload(4))) + require.Error(t, w.Append(5, recordPayload(5))) + }) + + t.Run("non-contiguous indices are allowed", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + require.NoError(t, w.Append(1, recordPayload(1))) + require.NoError(t, w.Append(3, recordPayload(3))) + require.NoError(t, w.Append(100, recordPayload(100))) + require.NoError(t, w.Flush()) + require.Equal(t, []uint64{1, 3, 100}, collectIndices(t, w, 0)) + }) + + t.Run("empty payload is allowed", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + require.NoError(t, w.Append(1, nil)) + require.NoError(t, w.Append(2, []byte{})) + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + index, data := it.Entry() + require.Equal(t, uint64(1), index) + require.Empty(t, data) + }) +} + +func TestOrphanFileRecovery(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + // Fabricate an orphaned unsealed file: records 1 and 2 intact, a torn record 3, left unsealed as if the + // process crashed before it could seal. + f, err := newWalFile(dir, 0) + require.NoError(t, err) + writeRecordTo(t, f, 1, recordPayload(1)) + writeRecordTo(t, f, 2, recordPayload(2)) + frame := frameRecord(3, recordPayload(3)) + require.NoError(t, f.flush(false)) + _, err = f.writer.Write(frame[:len(frame)-3]) // torn record 3 + require.NoError(t, err) + require.NoError(t, f.flush(true)) + require.NoError(t, f.file.Close()) + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(2), last) + require.Equal(t, []uint64{1, 2}, collectIndices(t, w, 1)) +} + +func TestRotationProducesContiguousSealedFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // rotate after every record + + w := openWAL(t, cfg) + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(6), last) + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectIndices(t, w, 1)) + require.NoError(t, w.Close()) + + // Every record should have produced its own sealed file with a clean [k,k] range. + var sealed []parsedFileName + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, entry := range entries { + if parsed, okName := parseFileName(entry.Name()); okName && parsed.sealed { + sealed = append(sealed, parsed) + require.Equal(t, parsed.firstIndex, parsed.lastIndex) + } + } + require.Len(t, sealed, 6) +} + +func TestRecordNeverSplitAcrossFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 128 // tiny, so a single record dwarfs the rotation threshold + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + // Two records, each far larger than TargetFileSize. + big1 := make([]byte, 4096) + big2 := make([]byte, 4096) + for i := range big1 { + big1[i] = byte(i) + big2[i] = byte(i + 1) + } + require.NoError(t, w.Append(1, big1)) + require.NoError(t, w.Append(2, big2)) + require.NoError(t, w.Flush()) + + // Each oversized record rotated into its own file, intact — never split across files. + require.Equal(t, 2, countSealedFiles(t, dir)) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + index, data := it.Entry() + require.Equal(t, uint64(1), index) + require.Equal(t, big1, data) + + ok, err = it.Next() + require.NoError(t, err) + require.True(t, ok) + index, data = it.Entry() + require.Equal(t, uint64(2), index) + require.Equal(t, big2, data) + + ok, err = it.Next() + require.NoError(t, err) + require.False(t, ok) +} + +func TestPruneDropsWholeFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per file, so pruning can drop whole files + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 10; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + require.NoError(t, w.Prune(5)) + + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), first) + require.Equal(t, uint64(10), last) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0)) +} + +func TestPrunePastAllRecordsEmptiesRange(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per file so every record sits in a prunable sealed file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + require.NoError(t, w.Prune(100)) + + ok, _, _, err := w.Bounds() + require.NoError(t, err) + require.False(t, ok) +} + +func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per sealed file, so pruning works file-by-file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 10; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + // Hold an iterator anchored at index 1 (the oldest). Its read lease must keep index 1's file alive. + it, err := w.Iterator(1) + require.NoError(t, err) + + require.NoError(t, w.Prune(5)) + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first, "index 1 must survive pruning while a live iterator pins it") + require.Equal(t, uint64(10), last) + + // The iterator still sees the full, intact sequence. + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, collectIndices(t, w, 1)) + + // Releasing the lease lets the same prune make progress. + require.NoError(t, it.Close()) + require.NoError(t, w.Prune(5)) + ok, first, _, err = w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), first) +} + +func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 10; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + // An iterator anchored at index 8 does not need records below 5, so pruning to 5 proceeds. + it, err := w.Iterator(8) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + require.NoError(t, w.Prune(5)) + ok, first, _, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), first) +} + +// TestIteratorInGapBlocksPruningAcrossGap covers the index gap case: indices may jump, so an iterator's read +// lease can land in a gap between stored files. Pruning must still protect every file the iterator will read +// (those reaching the lease index or higher), even though no file's range contains the lease index itself. +// The directory is inspected directly rather than relying on iterator output, since the reader goroutine may +// have buffered the files into memory before an unsafe delete. +func TestIteratorInGapBlocksPruningAcrossGap(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per sealed file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + // Indices 1,2,3 then a legal jump to 10,11,12. The lease index 5 falls in the gap (3, 10). + for _, index := range []uint64{1, 2, 3, 10, 11, 12} { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(5) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + // Prune(12) would remove every file with last index < 12, but the live lease at 5 must keep the files for + // indices 10 and 11 (both >= 5). Only the files entirely below the lease (indices 1,2,3) may be dropped. + require.NoError(t, w.Prune(12)) + _, _, _, err = w.Bounds() // synchronous round-trip forces the async prune to complete + require.NoError(t, err) + + names := sealedFileNames(t, dir) + require.Contains(t, names, sealedFileName(3, 10, 10), "file for index 10 must survive while iterator(5) is live") + require.Contains(t, names, sealedFileName(4, 11, 11), "file for index 11 must survive while iterator(5) is live") + require.NotContains(t, names, sealedFileName(0, 1, 1), "file for index 1 is below the lease and should be pruned") + + require.Equal(t, []uint64{10, 11, 12}, collectIndices(t, w, 5)) +} + +// TestIteratorLeaseInsideFileRangeBlocksPruning checks the boundary where the lease index sits within the kept +// window: an iterator anchored at 5 must keep indices 5..10 even as pruning is asked to drop through a higher +// point, because those files reach the lease index or higher. +func TestIteratorLeaseInsideFileRangeBlocksPruning(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per sealed file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 10; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(5) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + require.NoError(t, w.Prune(8)) + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), first, "lease at 5 must keep records from 5 onward") + require.Equal(t, uint64(10), last) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 5)) +} + +func TestScanRejectsGapInSealedFiles(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per sealed file + + w := openWAL(t, cfg) + for index := uint64(1); index <= 4; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + // Delete a middle sealed file to punch a gap in the sequence, simulating corruption. + var sealed []parsedFileName + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, entry := range entries { + if p, ok := parseFileName(entry.Name()); ok && p.sealed { + sealed = append(sealed, p) + } + } + require.GreaterOrEqual(t, len(sealed), 3) + sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq < sealed[j].fileSeq }) + victim := sealed[len(sealed)/2] + require.NoError(t, os.Remove(filepath.Join(dir, sealedFileName(victim.fileSeq, victim.firstIndex, victim.lastIndex)))) + + _, err = New(cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "not contiguous") +} + +func TestBoundsEmpty(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + ok, _, _, err := w.Bounds() + require.NoError(t, err) + require.False(t, ok) +} + +func TestRollbackConstructor(t *testing.T) { + t.Run("drops whole files beyond the rollback point", func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per file + + w := openWAL(t, cfg) + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + w2, err := NewWithRollback(cfg, 3) + require.NoError(t, err) + defer func() { require.NoError(t, w2.Close()) }() + + ok, first, last, err := w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1)) + }) + + t.Run("truncates within a file at the rollback point", func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: all records land in one file + + w := openWAL(t, cfg) + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + w2, err := NewWithRollback(cfg, 3) + require.NoError(t, err) + defer func() { require.NoError(t, w2.Close()) }() + + ok, first, last, err := w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1)) + + // Appending continues cleanly after the rollback point. + appendRecord(t, w2, 4) + require.NoError(t, w2.Flush()) + _, _, last, err = w2.Bounds() + require.NoError(t, err) + require.Equal(t, uint64(4), last) + }) + + // After a rollback, a subsequent *normal* open (not another rollback) must observe exactly the rolled-back + // range. This is the path that would expose a name/content mismatch left by a non-crash-safe rollback: + // Bounds is name-derived while iteration is content-bound, so the two agree only if the truncation and + // rename were applied consistently. Exercises both rollback shapes: whole-file removal and in-file + // truncation of the straddling file. + t.Run("rolled-back state is consistent under a normal reopen", func(t *testing.T) { + for _, tc := range []struct { + name string + targetSize uint + }{ + {"whole-file removal", 1}, // one record per file: rollback removes whole trailing files + {"in-file truncation", 64 * 1024 * 1024}, // all records in one file: rollback truncates it in place + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = tc.targetSize + + w := openWAL(t, cfg) + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + w2, err := NewWithRollback(cfg, 3) + require.NoError(t, err) + require.NoError(t, w2.Close()) + + // Reopen normally; the rollback must have durably and consistently reduced the range to [1,3]. + w3 := openWAL(t, cfg) + defer func() { require.NoError(t, w3.Close()) }() + + ok, first, last, err := w3.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w3, 1)) + }) + } + }) +} + +// recordPrefixBytes reads the sealed file at path and returns the raw bytes of the prefix ending just past the +// record for lastKeep — i.e. the exact content rollbackStraddlingFile's AtomicWrite would install for a +// rollback to lastKeep. It is the test's stand-in for "the truncated copy the rollback would produce". +func recordPrefixBytes(t *testing.T, path string, lastKeep uint64) []byte { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + contents, err := readWalFile(path) + require.NoError(t, err) + var truncateTo int64 + found := false + for _, r := range contents.records { + if r.index == lastKeep { + truncateTo = r.end + found = true + break + } + } + require.True(t, found, "index %d has no record boundary in %s", lastKeep, path) + return data[:truncateTo] +} + +// TestRollbackCrashAfterSwapReconciledOnReopen simulates a crash in rollbackStraddlingFile after the reduced +// file was durably written (AtomicWrite) but before the old, larger-named file was removed. That leaves two +// sealed files sharing a sequence. A subsequent open must reconcile them — keeping the reduced file — so the +// name-derived Bounds and the content-derived iterator agree on the rolled-back range. +func TestRollbackCrashAfterSwapReconciledOnReopen(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: all six records land in one file, sequence 0 + + w := openWAL(t, cfg) + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + oldName := sealedFileName(0, 1, 6) + require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) + + // Reproduce the crash state: the reduced file [1,3] exists next to the untouched original [1,6]. + reducedName := sealedFileName(0, 1, 3) + prefix := recordPrefixBytes(t, filepath.Join(dir, oldName), 3) + require.NoError(t, os.WriteFile(filepath.Join(dir, reducedName), prefix, 0o600)) + require.Equal(t, []string{reducedName, oldName}, sealedFileNames(t, dir)) + + // A plain reopen must reconcile the duplicate sequence down to the rolled-back file. + w2 := openWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + + require.Equal(t, []string{reducedName}, sealedFileNames(t, dir)) + ok, first, last, err := w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1)) +} + +// TestRollbackCrashDuringSwapWindowRecovers simulates a crash mid-rollback in the earlier window: the +// AtomicWrite's swap file was created but not yet renamed into place, so only a leftover ".swap" exists beside +// the still-intact original. A reopen must drop the swap and leave the original range intact (the rollback +// simply did not take effect), and a subsequent rollback must then complete cleanly and durably. +func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: all six records in one file, sequence 0 + + w := openWAL(t, cfg) + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + oldName := sealedFileName(0, 1, 6) + + // Reproduce the crash state: an unfinished AtomicWrite left a swap file for the reduced name, alongside + // the untouched original. util.AtomicWrite names its temp ".swap". + prefix := recordPrefixBytes(t, filepath.Join(dir, oldName), 3) + swapName := sealedFileName(0, 1, 3) + ".swap" + require.NoError(t, os.WriteFile(filepath.Join(dir, swapName), prefix, 0o600)) + + // A plain reopen drops the swap; the original range survives (rollback did not take effect). + w2 := openWAL(t, cfg) + require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) + _, err := os.Stat(filepath.Join(dir, swapName)) + require.True(t, os.IsNotExist(err), "leftover swap file should have been removed") + ok, first, last, err := w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(6), last) + require.NoError(t, w2.Close()) + + // The subsequent rollback completes cleanly, and a normal reopen sees the consistent rolled-back range. + w3, err := NewWithRollback(cfg, 3) + require.NoError(t, err) + require.NoError(t, w3.Close()) + + w4 := openWAL(t, cfg) + defer func() { require.NoError(t, w4.Close()) }() + require.Equal(t, []string{sealedFileName(0, 1, 3)}, sealedFileNames(t, dir)) + ok, first, last, err = w4.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w4, 1)) +} diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go new file mode 100644 index 0000000000..1ad3762095 --- /dev/null +++ b/sei-db/seiwal/seiwal_iterator.go @@ -0,0 +1,258 @@ +package seiwal + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +var _ Iterator = (*walIterator)(nil) + +// A record produced by the reader goroutine, or a terminal error. +type iteratorResult struct { + index uint64 + payload []byte + err error +} + +// iteratorFile is one entry in an iterator's file snapshot, captured on the writer goroutine when the iterator +// is created (see startIterator). Every snapshot file is sealed and immutable: it carries its immutable name +// and is opened lazily by the reader, held against pruning by the iterator's read lease. +type iteratorFile struct { + fileSeq uint64 + name string + firstIndex uint64 + lastIndex uint64 +} + +// walIterator iterates the WAL a record at a time, in ascending index order. A dedicated reader goroutine +// reads WAL files from disk and pushes each record onto a buffered channel; Next simply dequeues. Decoupling +// disk reads from the consumer keeps the reader busy while the consumer works, which matters for startup +// replay speed. The reader loads one file at a time, so its memory use is bounded by a single WAL file plus +// the prefetch buffer. +// +// The set of files to read is snapshotted once at creation (files), so the reader walks it in O(n) without +// re-scanning the directory. Every snapshot file is sealed and immutable (the mutable file is sealed at +// creation, see startIterator), so its contents cannot change under the reader. A read lease (pinnedIndex) +// holds the files the reader needs against concurrent pruning; Close releases it. +type walIterator struct { + // The WAL this iterator reads from. + wal *walImpl + + // The lowest index the consumer asked for; records below it are skipped. + start uint64 + + // The index pinned as this iterator's read lease, released on Close. + pinnedIndex uint64 + + // Records produced by the reader goroutine. Closed by the reader on clean EOF. + results chan iteratorResult + + // Closed by Close to tell the reader goroutine to stop early. + stop chan struct{} + + // Closed by the reader goroutine when it exits, so Close can wait for it. + readerExited chan struct{} + + // Ensures the shutdown sequence runs at most once. + closeOnce sync.Once + + // The index and payload returned by Entry, set by the most recent successful Next. Consumer-owned. + resultIndex uint64 + resultPayload []byte + + // Set once iteration is complete. Consumer-owned. + done bool + + // The following fields are owned exclusively by the reader goroutine. + + // The point-in-time snapshot of files to read, in ascending index order. Set once at construction. + files []iteratorFile + // The position into files of the next file to load. + filePos int + // The records loaded from the current file, filtered to indices at or beyond start. + buffer []walRecord + // The position within buffer; -1 before the first record is read. + pos int +} + +// newWalIterator creates an iterator over wal starting at startIndex and launches its reader goroutine. +// pinnedIndex is the read lease registered on the iterator's behalf, released by Close. files is the snapshot +// of files to read (captured on the writer goroutine). prefetch is the number of records the reader may buffer +// ahead of the consumer. +func newWalIterator( + wal *walImpl, + startIndex uint64, + pinnedIndex uint64, + files []iteratorFile, + prefetch uint, +) *walIterator { + it := &walIterator{ + wal: wal, + start: startIndex, + pinnedIndex: pinnedIndex, + results: make(chan iteratorResult, prefetch), + stop: make(chan struct{}), + readerExited: make(chan struct{}), + files: files, + pos: -1, + } + go it.read() + return it +} + +func (it *walIterator) Next() (bool, error) { + if it.done { + return false, nil + } + // Drain an already-available result first (non-blocking), so a clean EOF — the reader closing results + // with records still buffered — is never lost to a concurrent WAL shutdown in the select below. + select { + case result, ok := <-it.results: + return it.deliver(result, ok) + default: + } + // Otherwise wait for the next result, but don't block forever if the WAL is torn down. The reader + // goroutine watches the same context (see send) and stops producing when it fires, so the results + // channel would never advance again; surface the shutdown as an error rather than hang. + select { + case result, ok := <-it.results: + return it.deliver(result, ok) + case <-it.wal.ctx.Done(): + it.done = true + return false, fmt.Errorf("WAL shut down during iteration: %w", it.wal.ctx.Err()) + } +} + +// deliver turns a dequeued reader result (or a closed-channel signal) into a Next return value. +func (it *walIterator) deliver(result iteratorResult, ok bool) (bool, error) { + if !ok { + it.done = true + return false, nil + } + if result.err != nil { + it.done = true + return false, result.err + } + it.resultIndex = result.index + it.resultPayload = result.payload + return true, nil +} + +func (it *walIterator) Entry() (uint64, []byte) { + return it.resultIndex, it.resultPayload +} + +func (it *walIterator) Close() error { + it.closeOnce.Do(func() { + close(it.stop) // tell the reader to stop if it is mid-read + <-it.readerExited // wait for it to actually exit before releasing resources + it.wal.unpinIndex(it.pinnedIndex) + }) + it.done = true + return nil +} + +// read is the reader goroutine: it produces records onto the results channel until the WAL is exhausted (then +// closes the channel), a read fails (then sends the error), Close signals a stop, or the WAL context is +// cancelled (see send). It never blocks indefinitely, so it cannot outlive the WAL as a zombie. +func (it *walIterator) read() { + defer close(it.readerExited) + for { + record, ok, err := it.nextRecord() + if err != nil { + it.send(iteratorResult{err: err}) + return + } + if !ok { + close(it.results) + return + } + if !it.send(iteratorResult{index: record.index, payload: record.payload}) { + return // Close signalled a stop + } + } +} + +// send pushes a result onto the channel, returning false if Close signalled a stop or the WAL was torn down +// first. Watching the WAL context here is what keeps the reader from becoming a zombie: if an iterator is +// orphaned (Iterator aborted via ctx.Done before the caller ever received it, so Close is never called) the +// prefetch buffer eventually fills and this send would otherwise block forever with no one to close stop. +func (it *walIterator) send(result iteratorResult) bool { + select { + case it.results <- result: + return true + case <-it.stop: + return false + case <-it.wal.ctx.Done(): + return false + } +} + +// nextRecord returns the next record in ascending order, advancing across files as needed. It returns +// ok=false once no further records remain. +func (it *walIterator) nextRecord() (walRecord, bool, error) { + for { + it.pos++ + if it.pos < len(it.buffer) { + return it.buffer[it.pos], true, nil + } + loaded, err := it.loadNextFile() + if err != nil { + return walRecord{}, false, err + } + if !loaded { + return walRecord{}, false, nil + } + it.pos = -1 + } +} + +// loadNextFile walks the file snapshot from filePos, loading the next file's records (filtered to indices at +// or beyond start) into buffer and advancing filePos. It returns false when the snapshot is exhausted. Sealed +// files entirely below start are skipped without being opened; a file that yields no matching records leaves +// buffer empty (still reported as loaded). +func (it *walIterator) loadNextFile() (bool, error) { + for { + if it.filePos >= len(it.files) { + return false, nil + } + f := &it.files[it.filePos] + it.filePos++ + it.buffer = nil + + if f.lastIndex < it.start { + continue // entirely below the start index; skip without opening + } + + handle, err := it.openFile(f) + if err != nil { + return false, err + } + + parsed := parsedFileName{fileSeq: f.fileSeq, firstIndex: f.firstIndex, lastIndex: f.lastIndex, sealed: true} + contents, err := readWalFileFromHandle(handle, parsed) + if err != nil { + return false, fmt.Errorf("failed to read WAL file (sequence %d) during iteration: %w", f.fileSeq, err) + } + for _, record := range contents.records { + if record.index < it.start { + continue + } + it.buffer = append(it.buffer, record) + } + return true, nil + } +} + +// openFile opens a snapshot file by its immutable sealed name. The read lease keeps the file alive against +// pruning, so the open cannot miss it. readWalFileFromHandle closes the returned handle after reading. +func (it *walIterator) openFile(f *iteratorFile) (*os.File, error) { + path := filepath.Join(it.wal.config.Path, f.name) + handle, err := os.Open(path) //nolint:gosec // path derived from the writer's file snapshot + if err != nil { + return nil, fmt.Errorf("failed to open WAL file %s during iteration: %w", f.name, err) + } + return handle, nil +} diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go new file mode 100644 index 0000000000..be4790d5ae --- /dev/null +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -0,0 +1,263 @@ +package seiwal + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestIteratorEmpty(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + it, err := w.Iterator(0) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.False(t, ok) +} + +func TestIteratorFromMiddle(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{3, 4, 5}, collectIndices(t, w, 3)) +} + +func TestIteratorAcrossFiles(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.TargetFileSize = 1 // one record per file + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{2, 3, 4, 5}, collectIndices(t, w, 2)) +} + +func TestIteratorWithTinyPrefetchBuffer(t *testing.T) { + // A prefetch buffer smaller than the number of records exercises reader backpressure: the reader must + // block on a full channel and resume as the consumer drains, without deadlocking or dropping records. + cfg := testConfig(t.TempDir()) + cfg.IteratorPrefetchSize = 1 + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 20; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, + collectIndices(t, w, 1)) +} + +func TestIteratorCloseBeforeDrainDoesNotLeak(t *testing.T) { + // Closing an iterator before consuming it must unblock and shut down the reader goroutine cleanly. + cfg := testConfig(t.TempDir()) + cfg.IteratorPrefetchSize = 1 + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 20; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + // Consume just one record, then close while the reader is still mid-stream (blocked on the full buffer). + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, it.Close()) + require.NoError(t, it.Close()) // idempotent +} + +func TestIteratorReaderExitsWhenWALTornDownWhileOrphaned(t *testing.T) { + // Regression: an iterator whose reader fills the prefetch buffer and is never consumed or Closed — as + // happens when Iterator() is aborted via ctx.Done() and the constructed iterator is returned to no one — + // must not leave its reader goroutine blocked on send() forever. Watching the WAL context lets the reader + // exit when the WAL is torn down, so it cannot become a zombie. + cfg := testConfig(t.TempDir()) + cfg.IteratorPrefetchSize = 1 + w := openWAL(t, cfg) + for index := uint64(1); index <= 20; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + iter := it.(*walIterator) + + // Do not consume the iterator: the reader fills the prefetch buffer (size 1) and blocks on send. Tear + // down the WAL context out from under it, as fail() or Close() would. + w.(*walImpl).cancel() + + select { + case <-iter.readerExited: + case <-time.After(5 * time.Second): + t.Fatal("reader goroutine did not exit after the WAL context was cancelled") + } + + // A consumer that races the teardown drains whatever the reader had already buffered, then observes an + // error rather than a clean EOF: a truncated iteration must never masquerade as fully consumed. + var termErr error + for i := 0; i < 25; i++ { + ok, err := it.Next() + if err != nil { + termErr = err + break + } + if !ok { + break + } + } + require.Error(t, termErr, "truncated iteration must surface an error, not a clean EOF") +} + +func TestIteratorYieldsRecordContents(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + require.NoError(t, w.Append(1, []byte("hello world"))) + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + index, data := it.Entry() + require.Equal(t, uint64(1), index) + require.Equal(t, []byte("hello world"), data) + + ok, err = it.Next() + require.NoError(t, err) + require.False(t, ok) +} + +// TestConcurrentIterationDuringRotation hammers the writer with rotate-on-every-record churn while several +// iterators read concurrently. Each iterator seals the mutable file and snapshots its file set at creation on +// the writer goroutine, so every file it reads is sealed and immutable and can never be renamed or rewritten +// out from under an in-flight read; every iteration must be error-free and gap-free. +func TestConcurrentIterationDuringRotation(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.TargetFileSize = 1 // rotate (rename) after every record, maximizing the seal/rename churn + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + const totalRecords = 300 + const readers = 4 + const iterationsPerReader = 40 + + var wg sync.WaitGroup + + writeErr := make(chan error, 1) + wg.Add(1) + go func() { + defer wg.Done() + for index := uint64(1); index <= totalRecords; index++ { + if err := w.Append(index, recordPayload(index)); err != nil { + writeErr <- err + return + } + } + writeErr <- nil + }() + + readerErr := make(chan error, readers) + for r := 0; r < readers; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterationsPerReader; i++ { + if err := drainContiguousFrom(w, 1); err != nil { + readerErr <- err + return + } + } + readerErr <- nil + }() + } + + wg.Wait() + require.NoError(t, <-writeErr) + for r := 0; r < readers; r++ { + require.NoError(t, <-readerErr) + } +} + +// drainContiguousFrom fully consumes an iterator anchored at start, verifying the yielded indices form a +// gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not have +// produced start yet). Returns the first error encountered. +func drainContiguousFrom(w WAL, start uint64) error { + it, err := w.Iterator(start) + if err != nil { + return fmt.Errorf("create iterator: %w", err) + } + prev := start - 1 + for { + ok, err := it.Next() + if err != nil { + _ = it.Close() + return fmt.Errorf("next: %w", err) + } + if !ok { + break + } + index, _ := it.Entry() + if index != prev+1 { + _ = it.Close() + return fmt.Errorf("non-contiguous iteration: got index %d after %d (start %d)", index, prev, start) + } + prev = index + } + return it.Close() +} + +// TestIteratorDoesNotSeePostConstructionRecords pins down the snapshot contract: an iterator yields only +// records that existed when it was created, never records appended afterward. Because Iterator() seals the +// mutable file at creation, a record appended after lands in a fresh file outside the snapshot and must not +// appear. +func TestIteratorDoesNotSeePostConstructionRecords(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) // large target: records begin in one mutable file + defer func() { require.NoError(t, w.Close()) }() + + for index := uint64(1); index <= 3; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + // Appended after the iterator exists, before draining: must not be observed. + require.NoError(t, w.Append(4, recordPayload(4))) + require.NoError(t, w.Flush()) + + var got []uint64 + for { + ok, err := it.Next() + require.NoError(t, err) + if !ok { + break + } + index, _ := it.Entry() + got = append(got, index) + } + require.Equal(t, []uint64{1, 2, 3}, got, "post-construction record 4 must not be iterated") +} diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index bea08ce977..a4e2365a70 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -69,16 +69,15 @@ type StateWAL interface { } // Iterates over data in a state WAL, in ascending block order, yielding one entry per block. All changesets -// written for a block (across one or more Write calls) are coalesced, in write order, into that block's single -// entry; the entry's EndOfBlock field is always false. Incomplete trailing blocks (those with no end-of-block -// marker) are not yielded. +// written for a block (across one or more Write calls) are combined, in write order, into that block's single +// entry. Blocks that were never ended with SignalEndOfBlock are not yielded. type StateWALIterator interface { // Next advances the iterator to the next block. It returns false when iteration is complete (no more // blocks), and returns an error if advancing failed. After Next returns (false, nil), iteration is // complete; after it returns an error, the iterator must not be used further (other than Close). Next() (bool, error) - // Entry returns the coalesced entry for the block at the iterator's current position. It is only valid to + // Entry returns the combined entry for the block at the iterator's current position. It is only valid to // call Entry after Next has returned (true, nil). // // The returned entry, and every byte slice reachable through it (changeset keys and values), must be diff --git a/sei-db/state_db/statewal/state_wal_config.go b/sei-db/state_db/statewal/state_wal_config.go index 40bcb9fa17..bc329672b4 100644 --- a/sei-db/state_db/statewal/state_wal_config.go +++ b/sei-db/state_db/statewal/state_wal_config.go @@ -1,9 +1,7 @@ package statewal import ( - "fmt" - - "github.com/sei-protocol/sei-chain/sei-db/common/unit" + "github.com/sei-protocol/sei-chain/sei-db/seiwal" ) // Configuration for a state WAL. @@ -14,12 +12,13 @@ type Config struct { // The size of the channel used to send work from the caller to the serialization goroutine. RequestBufferSize uint - // The size of the channel used to send serialized records from the serialization goroutine to the + // The size of the channel used to send framed records from the underlying WAL's serialization to its // writer goroutine. WriteBufferSize uint - // The size a WAL file may reach before it is sealed and a fresh one is opened. Rotation only happens on - // block boundaries, so a file may exceed this by the size of a single block. Must be greater than 0. + // The size a WAL file may reach before it is sealed and a fresh one is opened. Because each block is + // written as a single record, a file may exceed this by the size of one block's serialized changesets. + // Must be greater than 0. TargetFileSize uint // When true, Flush calls fsync on the underlying file so that flushed data survives a power loss, not @@ -34,27 +33,29 @@ type Config struct { // Constructor for a default state WAL configuration. func DefaultConfig(path string) *Config { + s := seiwal.DefaultConfig(path) return &Config{ Path: path, RequestBufferSize: 16, - WriteBufferSize: 16, - TargetFileSize: 64 * unit.MB, - FsyncOnFlush: true, - IteratorPrefetchSize: 32, + WriteBufferSize: s.WriteBufferSize, + TargetFileSize: s.TargetFileSize, + FsyncOnFlush: s.FsyncOnFlush, + IteratorPrefetchSize: s.IteratorPrefetchSize, } } // Validate the configuration, returning nil if valid, or an error describing the problem if invalid. func (c *Config) Validate() error { - if c.Path == "" { - return fmt.Errorf("path is required") - } - if c.TargetFileSize == 0 { - // A zero target would seal and rotate a fresh file after every single block. - return fmt.Errorf("target file size must be greater than 0") - } - if c.IteratorPrefetchSize == 0 { - return fmt.Errorf("iterator prefetch size must be greater than 0") + return c.toSeiwalConfig().Validate() +} + +// toSeiwalConfig maps this configuration onto the underlying generic WAL's configuration. +func (c *Config) toSeiwalConfig() *seiwal.Config { + return &seiwal.Config{ + Path: c.Path, + WriteBufferSize: c.WriteBufferSize, + TargetFileSize: c.TargetFileSize, + FsyncOnFlush: c.FsyncOnFlush, + IteratorPrefetchSize: c.IteratorPrefetchSize, } - return nil } diff --git a/sei-db/state_db/statewal/state_wal_entry.go b/sei-db/state_db/statewal/state_wal_entry.go index f8b17cf2e6..78d2533451 100644 --- a/sei-db/state_db/statewal/state_wal_entry.go +++ b/sei-db/state_db/statewal/state_wal_entry.go @@ -7,35 +7,17 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" ) -// The kind of a WAL record. Every serialized entry begins with one of these bytes. -type entryKind byte - -const ( - // A changeset record: a block number plus the set of changes written for that block. - kindChangeset entryKind = 1 - // An end-of-block record: marks that no more changes will be written for a block number. On reload, a - // block whose changeset records are not followed by an end-of-block marker is discarded. - kindEndOfBlock entryKind = 2 -) - -// A WAL entry for state. -// -// An entry is either a changeset record (EndOfBlock is false, Changeset holds the block's changes) or an -// end-of-block marker (EndOfBlock is true, Changeset is nil). A single block may be described by several -// changeset records followed by exactly one end-of-block marker. +// A decoded block entry from the state WAL: a block number and the changesets written for that block. type Entry struct { // The block number associated with this entry. BlockNumber uint64 - // The changeset associated with this entry. Nil for end-of-block markers. + // The changesets associated with this block, in write order. Changeset []*proto.NamedChangeSet - - // True if this entry marks the end of a block. End-of-block entries carry no changeset. - EndOfBlock bool } -// Constructor for a changeset entry. +// NewEntry constructs an entry for the given block number and changesets. func NewEntry(blockNumber uint64, changeset []*proto.NamedChangeSet) *Entry { return &Entry{ BlockNumber: blockNumber, @@ -43,116 +25,59 @@ func NewEntry(blockNumber uint64, changeset []*proto.NamedChangeSet) *Entry { } } -// Constructor for an end-of-block marker entry. -func NewEndOfBlockEntry(blockNumber uint64) *Entry { - return &Entry{ - BlockNumber: blockNumber, - EndOfBlock: true, +// appendChangeset appends the framing [uvarint marshaled length][marshaled NamedChangeSet] for ncs to buf and +// returns the extended buffer. This is the incremental unit the serializer goroutine accumulates across the +// multiple Write calls of a single block before appending the whole block as one WAL record. +func appendChangeset(buf []byte, ncs *proto.NamedChangeSet) ([]byte, error) { + if ncs == nil { + return nil, fmt.Errorf("changeset is nil") } -} - -// Serialize the WAL entry to bytes. The returned bytes are the record payload; the file layer is responsible -// for framing (length prefix and checksum). The layout is: -// -// [1-byte kind][uvarint block number] -// -// followed, for changeset records only, by: -// -// [uvarint changeset count]([uvarint marshaled length][marshaled NamedChangeSet])* -func (e *Entry) Serialize() ([]byte, error) { - var buf []byte - var scratch [binary.MaxVarintLen64]byte - - if e.EndOfBlock { - buf = append(buf, byte(kindEndOfBlock)) - n := binary.PutUvarint(scratch[:], e.BlockNumber) - buf = append(buf, scratch[:n]...) - return buf, nil + marshaled, err := ncs.Marshal() + if err != nil { + return nil, fmt.Errorf("failed to marshal changeset: %w", err) } - - buf = append(buf, byte(kindChangeset)) - n := binary.PutUvarint(scratch[:], e.BlockNumber) - buf = append(buf, scratch[:n]...) - - n = binary.PutUvarint(scratch[:], uint64(len(e.Changeset))) + var scratch [binary.MaxVarintLen64]byte + n := binary.PutUvarint(scratch[:], uint64(len(marshaled))) buf = append(buf, scratch[:n]...) + buf = append(buf, marshaled...) + return buf, nil +} - for i, ncs := range e.Changeset { - if ncs == nil { - return nil, fmt.Errorf("changeset %d is nil", i) - } - marshaled, err := ncs.Marshal() +// serializeChangesets encodes a changeset list as the concatenation ([uvarint length][marshaled])* — the +// payload of a single block's WAL record. The block number is not encoded: it is the WAL record's index. +func serializeChangesets(cs []*proto.NamedChangeSet) ([]byte, error) { + var buf []byte + var err error + for _, ncs := range cs { + buf, err = appendChangeset(buf, ncs) if err != nil { - return nil, fmt.Errorf("failed to marshal changeset %d: %w", i, err) + return nil, err } - n = binary.PutUvarint(scratch[:], uint64(len(marshaled))) - buf = append(buf, scratch[:n]...) - buf = append(buf, marshaled...) } - return buf, nil } -// DeserializeEntry parses a record payload previously produced by Serialize. -func DeserializeEntry(data []byte) ( - // The resulting WAL entry. - entry *Entry, - // If true, the WAL entry was successfully deserialized. - // If false, the data was truncated or otherwise incomplete and entry is nil. - ok bool, - // Returns an error if the data could not be deserialized due to an unexpected error (e.g. a corrupt - // protobuf payload). Does not return an error if the data is simply truncated. - err error, -) { - if len(data) == 0 { - return nil, false, nil - } - - kind := entryKind(data[0]) - rest := data[1:] - - blockNumber, n := binary.Uvarint(rest) - if n <= 0 { - return nil, false, nil - } - rest = rest[n:] - - switch kind { - case kindEndOfBlock: - return NewEndOfBlockEntry(blockNumber), true, nil - case kindChangeset: - count, n := binary.Uvarint(rest) +// deserializeChangesets decodes the payload produced by serializeChangesets. Because the enclosing WAL record +// is length-delimited and CRC-verified by the underlying WAL, any truncation encountered here indicates +// corruption and is reported as an error rather than tolerated. +func deserializeChangesets(data []byte) ([]*proto.NamedChangeSet, error) { + var result []*proto.NamedChangeSet + rest := data + for len(rest) > 0 { + length, n := binary.Uvarint(rest) if n <= 0 { - return nil, false, nil + return nil, fmt.Errorf("corrupt changeset length prefix") } rest = rest[n:] - - // Each changeset entry occupies at least one byte in rest (its length prefix), so a count larger - // than the remaining bytes cannot be valid. Reject it before allocating, to avoid a panic/OOM on a - // corrupt payload that survived the CRC32 check. Mirrors the length bound in the loop below. - if count > uint64(len(rest)) { - return nil, false, nil + if uint64(len(rest)) < length { + return nil, fmt.Errorf("changeset payload truncated: need %d bytes, have %d", length, len(rest)) } - - changeset := make([]*proto.NamedChangeSet, 0, count) - for i := uint64(0); i < count; i++ { - length, n := binary.Uvarint(rest) - if n <= 0 { - return nil, false, nil - } - rest = rest[n:] - if uint64(len(rest)) < length { - return nil, false, nil - } - ncs := &proto.NamedChangeSet{} - if err := ncs.Unmarshal(rest[:length]); err != nil { - return nil, false, fmt.Errorf("failed to unmarshal changeset %d: %w", i, err) - } - rest = rest[length:] - changeset = append(changeset, ncs) + ncs := &proto.NamedChangeSet{} + if err := ncs.Unmarshal(rest[:length]); err != nil { + return nil, fmt.Errorf("failed to unmarshal changeset: %w", err) } - return NewEntry(blockNumber, changeset), true, nil - default: - return nil, false, fmt.Errorf("unknown WAL entry kind %d", kind) + rest = rest[length:] + result = append(result, ncs) } + return result, nil } diff --git a/sei-db/state_db/statewal/state_wal_entry_test.go b/sei-db/state_db/statewal/state_wal_entry_test.go index a1576a421f..52a50b5edc 100644 --- a/sei-db/state_db/statewal/state_wal_entry_test.go +++ b/sei-db/state_db/statewal/state_wal_entry_test.go @@ -16,109 +16,52 @@ func makeChangeSet(name string, key []byte, value []byte) *proto.NamedChangeSet } } -func TestEntrySerializeRoundTrip(t *testing.T) { - t.Run("changeset with multiple named change sets", func(t *testing.T) { - entry := NewEntry(42, []*proto.NamedChangeSet{ +func TestChangesetsRoundTrip(t *testing.T) { + t.Run("multiple named change sets", func(t *testing.T) { + cs := []*proto.NamedChangeSet{ makeChangeSet("bank", []byte("a"), []byte("1")), makeChangeSet("evm", []byte("b"), []byte("2")), - }) - - data, err := entry.Serialize() - require.NoError(t, err) - - got, ok, err := DeserializeEntry(data) - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, entry, got) - }) + } - t.Run("empty (non-nil) changeset", func(t *testing.T) { - entry := NewEntry(7, []*proto.NamedChangeSet{}) - data, err := entry.Serialize() + data, err := serializeChangesets(cs) require.NoError(t, err) - got, ok, err := DeserializeEntry(data) + got, err := deserializeChangesets(data) require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(7), got.BlockNumber) - require.False(t, got.EndOfBlock) - require.Empty(t, got.Changeset) + require.Equal(t, cs, got) }) - t.Run("end of block marker", func(t *testing.T) { - entry := NewEndOfBlockEntry(99) - data, err := entry.Serialize() + t.Run("empty changeset list", func(t *testing.T) { + data, err := serializeChangesets([]*proto.NamedChangeSet{}) require.NoError(t, err) + require.Empty(t, data) - got, ok, err := DeserializeEntry(data) + got, err := deserializeChangesets(data) require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(99), got.BlockNumber) - require.True(t, got.EndOfBlock) - require.Nil(t, got.Changeset) + require.Empty(t, got) }) } -func TestDeserializeTruncated(t *testing.T) { - entry := NewEntry(42, []*proto.NamedChangeSet{ +func TestDeserializeChangesetsTruncated(t *testing.T) { + cs := []*proto.NamedChangeSet{ makeChangeSet("bank", []byte("hello"), []byte("world")), - }) - data, err := entry.Serialize() + } + data, err := serializeChangesets(cs) require.NoError(t, err) - // Every strict prefix is either incomplete (ok=false) or, by chance, a shorter valid record; it must - // never yield the original entry and never panic. - for length := 0; length < len(data); length++ { - got, ok, err := DeserializeEntry(data[:length]) - if ok { - require.NotEqual(t, entry, got) - } - _ = err + // Every strict prefix is truncated. Because the enclosing record is length-delimited by the underlying + // WAL, a truncated payload here is corruption and must surface an error, never a silent partial decode. + for length := 1; length < len(data); length++ { + _, err := deserializeChangesets(data[:length]) + require.Error(t, err) } - - // Empty input is cleanly reported as incomplete. - got, ok, err := DeserializeEntry(nil) - require.NoError(t, err) - require.False(t, ok) - require.Nil(t, got) } func TestDeserializeCorruptChangeset(t *testing.T) { - // A changeset record whose declared change set length points at bytes that are not a valid - // NamedChangeSet protobuf must surface an error, not a false "ok". - // Layout: [kind=changeset][blockNumber=1][count=1][len=2][0x08 0xFF] where 0x08 is a varint field tag - // (field 1, wire type 0) followed by a truncated varint, which the protobuf decoder rejects. - payload := []byte{byte(kindChangeset), 0x01, 0x01, 0x02, 0x08, 0xFF} - _, ok, err := DeserializeEntry(payload) + // A length prefix pointing at bytes that are not a valid NamedChangeSet protobuf must surface an error. + // Layout: [len=2][0x08 0xFF] where 0x08 is a varint field tag (field 1, wire type 0) followed by a + // truncated varint, which the protobuf decoder rejects. + payload := []byte{0x02, 0x08, 0xFF} + _, err := deserializeChangesets(payload) require.Error(t, err) - require.False(t, ok) -} - -func TestDeserializeUnknownKind(t *testing.T) { - _, ok, err := DeserializeEntry([]byte{0xFF, 0x01}) - require.Error(t, err) - require.False(t, ok) -} - -func TestDeserializeOversizedCount(t *testing.T) { - // A changeset count larger than the remaining bytes cannot be valid (each entry needs at least a - // one-byte length prefix). It must be rejected as incomplete before allocating, never panicking with - // "makeslice: cap out of range" or OOMing on a corrupt payload that slipped past the CRC32 check. - t.Run("count is MaxUint64", func(t *testing.T) { - // Layout: [kind=changeset][blockNumber=1][count=MaxUint64 as a 10-byte uvarint]. - payload := []byte{byte(kindChangeset), 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01} - got, ok, err := DeserializeEntry(payload) - require.NoError(t, err) - require.False(t, ok) - require.Nil(t, got) - }) - - t.Run("count exceeds remaining bytes", func(t *testing.T) { - // Layout: [kind=changeset][blockNumber=1][count=5], with no changeset bytes following. - payload := []byte{byte(kindChangeset), 0x01, 0x05} - got, ok, err := DeserializeEntry(payload) - require.NoError(t, err) - require.False(t, ok) - require.Nil(t, got) - }) } diff --git a/sei-db/state_db/statewal/state_wal_file_test.go b/sei-db/state_db/statewal/state_wal_file_test.go deleted file mode 100644 index 21684d39cf..0000000000 --- a/sei-db/state_db/statewal/state_wal_file_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package statewal - -import ( - "os" - "path/filepath" - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/stretchr/testify/require" -) - -func TestFileNaming(t *testing.T) { - require.Equal(t, "3.swal.u", unsealedFileName(3)) - require.Equal(t, "3-10-20.swal", sealedFileName(3, 10, 20)) - - parsed, ok := parseFileName("3.swal.u") - require.True(t, ok) - require.Equal(t, parsedFileName{index: 3, sealed: false}, parsed) - - parsed, ok = parseFileName("3-10-20.swal") - require.True(t, ok) - require.Equal(t, parsedFileName{index: 3, firstBlock: 10, lastBlock: 20, sealed: true}, parsed) - - _, ok = parseFileName("not-a-wal-file.txt") - require.False(t, ok) -} - -// writeMutableFile creates a mutable file at index 0, applies fn to it, then flushes and closes the underlying -// handle without sealing, leaving an unsealed file on disk. It returns the file path. -func writeMutableFile(t *testing.T, dir string, fn func(f *walFile)) string { - t.Helper() - f, err := newWalFile(dir, 0) - require.NoError(t, err) - fn(f) - require.NoError(t, f.flush(true)) - require.NoError(t, f.file.Close()) - return filepath.Join(dir, unsealedFileName(0)) -} - -func writeCompleteBlock(t *testing.T, f *walFile, block uint64) { - t.Helper() - cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} - require.NoError(t, f.writeEntry(NewEntry(block, cs))) - require.NoError(t, f.writeEntry(NewEndOfBlockEntry(block))) -} - -func TestReadWalFileCleanTail(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeCompleteBlock(t, f, 1) - writeCompleteBlock(t, f, 2) - }) - - contents, err := readWalFile(path) - require.NoError(t, err) - require.True(t, contents.hasCompleteBlock) - require.Equal(t, uint64(1), contents.firstBlock) - require.Equal(t, uint64(2), contents.lastCompleteBlock) - require.Len(t, contents.entries, 4) // 2 changeset + 2 end-of-block records -} - -func TestReadWalFileIncompleteTailBlock(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeCompleteBlock(t, f, 1) - writeCompleteBlock(t, f, 2) - // Block 3 changeset with no end-of-block marker. - require.NoError(t, f.writeEntry(NewEntry(3, - []*proto.NamedChangeSet{makeChangeSet("evm", []byte{3}, []byte{3})}))) - }) - - contents, err := readWalFile(path) - require.NoError(t, err) - require.True(t, contents.hasCompleteBlock) - require.Equal(t, uint64(2), contents.lastCompleteBlock) - // The dangling block-3 changeset is read as an entry, but the completed boundary stops at block 2. - require.Equal(t, uint64(3), contents.lastBlock) -} - -func TestReadWalFilePartialLengthPrefix(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeCompleteBlock(t, f, 1) - }) - - // Append a lone 0x80 byte: an incomplete uvarint length prefix, as a torn write would leave. - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) - require.NoError(t, err) - _, err = f.Write([]byte{0x80}) - require.NoError(t, err) - require.NoError(t, f.Close()) - - contents, err := readWalFile(path) - require.NoError(t, err) - require.True(t, contents.hasCompleteBlock) - require.Equal(t, uint64(1), contents.lastCompleteBlock) - require.Len(t, contents.entries, 2) -} - -func TestReadWalFileMidRecordTruncation(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeCompleteBlock(t, f, 1) - writeCompleteBlock(t, f, 2) - }) - - info, err := os.Stat(path) - require.NoError(t, err) - // Lop a few bytes off the end, tearing block 2's end-of-block record. - require.NoError(t, os.Truncate(path, info.Size()-3)) - - contents, err := readWalFile(path) - require.NoError(t, err) - require.True(t, contents.hasCompleteBlock) - require.Equal(t, uint64(1), contents.lastCompleteBlock) -} - -func TestReadWalFileChecksumMismatch(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeCompleteBlock(t, f, 1) - }) - - // Flip the final byte (part of the end-of-block record's CRC), so that record fails its checksum. - data, err := os.ReadFile(path) - require.NoError(t, err) - data[len(data)-1] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - contents, err := readWalFile(path) - require.NoError(t, err) - // The changeset record survives; the corrupt end-of-block record is dropped, so no complete block remains. - require.False(t, contents.hasCompleteBlock) - require.Len(t, contents.entries, 1) -} - -func TestReadWalFileBadMagic(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeCompleteBlock(t, f, 1) - }) - - data, err := os.ReadFile(path) - require.NoError(t, err) - data[0] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - _, err = readWalFile(path) - require.Error(t, err) -} diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 3cf6d601ea..5a30e89e65 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -3,14 +3,11 @@ package statewal import ( "context" "fmt" - "os" - "path/filepath" - "sort" "sync" "sync/atomic" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/seiwal" "github.com/sei-protocol/seilog" ) @@ -18,96 +15,87 @@ var _ StateWAL = (*stateWALImpl)(nil) var logger = seilog.NewLogger("db", "state-db", "statewal") -// dataToBeSerialized carries an entry from a caller to the serializer to be serialized. -type dataToBeSerialized struct { - entry *Entry +// changesetMsg carries one Write's changesets to the serializer goroutine to be marshaled and accumulated +// into the current block's buffer. +type changesetMsg struct { + blockNumber uint64 + cs []*proto.NamedChangeSet } -// dataToBeWritten carries a framed record from the serializer to the writer to be appended. -type dataToBeWritten struct { - record []byte +// endOfBlockMsg tells the serializer goroutine that the current block is complete: it appends the accumulated +// buffer to the underlying WAL as a single record and resets the buffer. +type endOfBlockMsg struct { blockNumber uint64 - endOfBlock bool } -// flushRequest asks the writer to flush (and optionally fsync) the mutable file, signaling done when durable. -type flushRequest struct { +// flushMsg asks the serializer goroutine to flush the underlying WAL, signaling done when durable. +type flushMsg struct { done chan error } -// rangeQuery asks the writer to report the stored block range. -type rangeQuery struct { - reply chan storedRange -} - -// pruneRequest asks the writer to drop whole sealed files below `through`. -type pruneRequest struct { - through uint64 +// rangeMsg asks the serializer goroutine to report the stored block range. +type rangeMsg struct { + reply chan rangeReply } -// closeRequest asks the writer to seal the mutable file and shut down, signaling done when sealed. -type closeRequest struct { - done chan error +// The block range (and any error) reported by GetStoredRange. +type rangeReply struct { + ok bool + start uint64 + end uint64 + err error } -// unpinRequest releases a read lease previously registered when an iterator was created. -type unpinRequest struct { - block uint64 +// pruneMsg asks the serializer goroutine to prune the underlying WAL below `through`. +type pruneMsg struct { + through uint64 } -// iteratorStartRequest asks the writer to construct an iterator. The writer flushes the mutable file (so the -// iterator observes all prior writes), snapshots the current set of files, registers the read lease, and builds -// the iterator, all on its own goroutine so construction is serialized with rotation/seal/prune. -type iteratorStartRequest struct { +// iteratorMsg asks the serializer goroutine to create an iterator, so it is ordered after every prior write. +type iteratorMsg struct { startBlock uint64 - reply chan iteratorStartResponse + reply chan iteratorReply } -// The iterator (or an error) produced by the writer in response to an iteratorStartRequest. -type iteratorStartResponse struct { - iterator *walIterator +// The iterator (or an error) produced in response to an iteratorMsg. +type iteratorReply struct { + iterator StateWALIterator err error } -// The block range reported by GetStoredRange. -type storedRange struct { - ok bool - start uint64 - end uint64 -} - -// Bookkeeping for a sealed WAL file, owned by the writer goroutine. -type sealedFileInfo struct { - index uint64 - name string - firstBlock uint64 - lastBlock uint64 +// closeMsg asks the serializer goroutine to close the underlying WAL and shut down, signaling done when closed. +type closeMsg struct { + done chan error } -// A standard state WAL implementation. +// A state WAL implemented as a thin, block-aware wrapper over a generic seiwal.WAL. +// +// The wrapper owns the block write-ordering contract (Write/SignalEndOfBlock) and the mapping of a block's +// changesets to a single opaque WAL record: the block number becomes the record index, and the block's +// changesets (accumulated across one or more Write calls) become the record payload. A single serializer +// goroutine marshals changesets off the caller's critical path — the throughput-sensitive path — and appends +// one record per block at end-of-block. type stateWALImpl struct { // The configuration this WAL was opened with. Read-only after construction. config *Config - // caller ──serializerChan──▶ serializer ──writerChan──▶ writer + // The underlying generic write-ahead log. + wal seiwal.WAL // Caller entry points funnel through serializerChan as a single ordered stream to the serializer. serializerChan chan any - // The serializer forwards serialized records and control messages to the writer over writerChan. - writerChan chan any - - // The hard-stop context the serializer and writer watch. Cancelled by fail() on a fatal error and by - // Close() once everything has drained. + // The hard-stop context the serializer watches. Cancelled by fail() on a fatal error and by Close() once + // everything has drained. ctx context.Context cancel context.CancelFunc - // A child of ctx that the serializerChan producers watch, cancelled once the serializer stops reading so an - // in-flight or future push aborts rather than deadlocking. + // A child of ctx that the serializerChan producers watch, cancelled once the serializer stops reading so + // an in-flight or future push aborts rather than deadlocking. senderCtx context.Context senderCancel context.CancelFunc - // Tracks the serializer and writer goroutines so Close() can wait for them to exit. + // Tracks the serializer goroutine so Close() can wait for it to exit. wg sync.WaitGroup // Guarantees the Close() shutdown sequence runs at most once. @@ -120,7 +108,7 @@ type stateWALImpl struct { asyncErr atomic.Pointer[error] // Guards the write-ordering contract state below, which is read/written synchronously in Write and - // SignalEndOfBlock (not on the background goroutines). + // SignalEndOfBlock (not on the serializer goroutine). mu sync.Mutex // The block number of the most recent Write or SignalEndOfBlock. currentBlock uint64 @@ -128,31 +116,16 @@ type stateWALImpl struct { currentBlockEnded bool // Whether any block has been observed (this session or recovered from disk). hasCurrentBlock bool - - // The following fields are owned exclusively by the writer goroutine. - - // The current mutable file accepting records. - mutableFile *walFile - - // The index to assign the next mutable file. - nextIndex uint64 - - // Sealed files in ascending block order. Rotation appends to the back; pruning removes from the front. - sealedFiles *util.RandomAccessDeque[*sealedFileInfo] - - // Read leases held by live iterators: block number -> reference count. Pruning will not delete a file - // whose block range contains a leased block. Mutated only by the writer goroutine. - blockRefs map[uint64]int } -// New opens (or creates) a state WAL in the configured directory, recovering any files left behind -// by a previous session. +// New opens (or creates) a state WAL in the configured directory, recovering any files left behind by a +// previous session. func New(config *Config) (StateWAL, error) { return newStateWAL(config, nil) } -// NewWithRollback opens a state WAL and deletes all data for blocks beyond rollbackBlockNumber -// before returning, so the WAL contains no block greater than rollbackBlockNumber. +// NewWithRollback opens a state WAL and deletes all data for blocks beyond rollbackBlockNumber before +// returning, so the WAL contains no block greater than rollbackBlockNumber. func NewWithRollback(config *Config, rollbackBlockNumber uint64) (StateWAL, error) { return newStateWAL(config, &rollbackBlockNumber) } @@ -161,36 +134,16 @@ func newStateWAL(config *Config, rollbackThrough *uint64) (StateWAL, error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid state WAL config: %w", err) } - if err := util.EnsureDirectoryExists(config.Path, true); err != nil { - return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) - } - // Clean up remnants of a rollback swap interrupted by a crash before scanning (see rollbackStraddlingFile): - // a leftover swap file from an unfinished AtomicWrite, or two sealed files sharing an index because the old - // file was not yet removed. This leaves a set where every sealed index is unique and name matches content. - if err := util.DeleteOrphanedSwapFiles(config.Path); err != nil { - return nil, fmt.Errorf("failed to delete orphaned swap files: %w", err) - } - if err := reconcileRollbackRemnants(config.Path); err != nil { - return nil, fmt.Errorf("failed to reconcile rollback remnants: %w", err) - } - if err := recoverOrphans(config.Path); err != nil { - return nil, fmt.Errorf("failed to recover orphaned WAL files: %w", err) - } + var wal seiwal.WAL + var err error if rollbackThrough != nil { - if err := rollbackDirectory(config.Path, *rollbackThrough); err != nil { - return nil, fmt.Errorf("failed to roll back WAL beyond block %d: %w", *rollbackThrough, err) - } - } - - sealedFiles, nextIndex, err := scanSealedFiles(config.Path) - if err != nil { - return nil, fmt.Errorf("failed to scan sealed WAL files: %w", err) + wal, err = seiwal.NewWithRollback(config.toSeiwalConfig(), *rollbackThrough) + } else { + wal, err = seiwal.New(config.toSeiwalConfig()) } - - mutable, err := newWalFile(config.Path, nextIndex) if err != nil { - return nil, fmt.Errorf("failed to open mutable WAL file: %w", err) + return nil, fmt.Errorf("failed to open underlying WAL: %w", err) } ctx, cancel := context.WithCancel(context.Background()) @@ -198,32 +151,33 @@ func newStateWAL(config *Config, rollbackThrough *uint64) (StateWAL, error) { w := &stateWALImpl{ config: config, + wal: wal, serializerChan: make(chan any, config.RequestBufferSize), - writerChan: make(chan any, config.WriteBufferSize), ctx: ctx, cancel: cancel, senderCtx: senderCtx, senderCancel: senderCancel, - mutableFile: mutable, - nextIndex: nextIndex + 1, - sealedFiles: sealedFiles, - blockRefs: make(map[uint64]int), } - // Recover the write-ordering position from the last complete block already on disk. - if r := w.blockRange(); r.ok { - w.currentBlock = r.end + + // Recover the write-ordering position from the highest block already on disk. + ok, _, last, err := wal.Bounds() + if err != nil { + _ = wal.Close() + return nil, fmt.Errorf("failed to read WAL bounds: %w", err) + } + if ok { + w.currentBlock = last w.currentBlockEnded = true w.hasCurrentBlock = true } - w.wg.Add(2) + w.wg.Add(1) go w.serializerLoop() - go w.writerLoop() return w, nil } -// Write schedules a changeset record for the given block number. +// Write schedules a set of changes for the given block number. func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { if w.closed.Load() { return fmt.Errorf("state WAL is closed") @@ -231,13 +185,13 @@ func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) err if err := w.enforceWriteOrdering(blockNumber); err != nil { return fmt.Errorf("write rejected: %w", err) } - if err := w.sendToSerializer(dataToBeSerialized{entry: NewEntry(blockNumber, cs)}); err != nil { + if err := w.sendToSerializer(changesetMsg{blockNumber: blockNumber, cs: cs}); err != nil { return fmt.Errorf("failed to schedule write for block %d: %w", blockNumber, err) } return nil } -// SignalEndOfBlock schedules an end-of-block marker for the current block. +// SignalEndOfBlock schedules the current block's accumulated changesets to be appended as a single record. func (w *stateWALImpl) SignalEndOfBlock() error { if w.closed.Load() { return fmt.Errorf("state WAL is closed") @@ -252,7 +206,7 @@ func (w *stateWALImpl) SignalEndOfBlock() error { w.currentBlockEnded = true w.mu.Unlock() - if err := w.sendToSerializer(dataToBeSerialized{entry: NewEndOfBlockEntry(blockNumber)}); err != nil { + if err := w.sendToSerializer(endOfBlockMsg{blockNumber: blockNumber}); err != nil { return fmt.Errorf("failed to schedule end-of-block for block %d: %w", blockNumber, err) } return nil @@ -292,12 +246,12 @@ func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { // Flush blocks until all previously scheduled writes are durable. func (w *stateWALImpl) Flush() error { done := make(chan error, 1) - if err := w.sendToSerializer(flushRequest{done: done}); err != nil { + if err := w.sendToSerializer(flushMsg{done: done}); err != nil { return fmt.Errorf("failed to schedule flush: %w", err) } select { case err := <-done: - return err // already wrapped by the writer, or nil on success + return err // already wrapped by the underlying WAL, or nil on success case <-w.ctx.Done(): if err := w.asyncError(); err != nil { return fmt.Errorf("flush aborted: %w", err) @@ -308,12 +262,15 @@ func (w *stateWALImpl) Flush() error { // GetStoredRange reports the range of complete blocks stored in the WAL. func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { - reply := make(chan storedRange, 1) - if err := w.sendToSerializer(rangeQuery{reply: reply}); err != nil { + reply := make(chan rangeReply, 1) + if err := w.sendToSerializer(rangeMsg{reply: reply}); err != nil { return false, 0, 0, fmt.Errorf("failed to schedule stored-range query: %w", err) } select { case r := <-reply: + if r.err != nil { + return false, 0, 0, fmt.Errorf("stored-range query failed: %w", r.err) + } return r.ok, r.start, r.end, nil case <-w.ctx.Done(): if err := w.asyncError(); err != nil { @@ -323,21 +280,20 @@ func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { } } -// Prune schedules removal of whole sealed files below lowestBlockNumberToKeep. It does not block on completion. +// Prune schedules removal of whole underlying files below lowestBlockNumberToKeep. It does not block on +// completion. func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { - if err := w.sendToSerializer(pruneRequest{through: lowestBlockNumberToKeep}); err != nil { + if err := w.sendToSerializer(pruneMsg{through: lowestBlockNumberToKeep}); err != nil { return fmt.Errorf("failed to schedule prune below block %d: %w", lowestBlockNumberToKeep, err) } return nil } -// Iterator returns an iterator over the WAL starting at startingBlockNumber. Construction runs on the writer -// goroutine (see iteratorStartRequest): the writer flushes so all previously scheduled writes are visible, -// registers a read lease so pruning cannot delete files out from under the iterator, and builds the iterator. -// The lease is released by the iterator's Close. +// Iterator returns an iterator over the WAL starting at startingBlockNumber. Construction is ordered on the +// serializer goroutine after every prior write, so the iterator observes all previously scheduled writes. func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (StateWALIterator, error) { - reply := make(chan iteratorStartResponse, 1) - if err := w.sendToSerializer(iteratorStartRequest{startBlock: startingBlockNumber, reply: reply}); err != nil { + reply := make(chan iteratorReply, 1) + if err := w.sendToSerializer(iteratorMsg{startBlock: startingBlockNumber, reply: reply}); err != nil { return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) } select { @@ -354,18 +310,13 @@ func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (StateWALIterator, e } } -// unpinBlock releases a read lease. Best-effort: if the WAL is already shutting down the lease is moot. -func (w *stateWALImpl) unpinBlock(block uint64) { - _ = w.sendToSerializer(unpinRequest{block: block}) -} - -// Close flushes pending writes, seals the mutable file, and releases resources. +// Close flushes pending writes, closes the underlying WAL, and releases resources. func (w *stateWALImpl) Close() error { var closeErr error w.closeOnce.Do(func() { w.closed.Store(true) done := make(chan error, 1) - if err := w.sendToSerializer(closeRequest{done: done}); err == nil { + if err := w.sendToSerializer(closeMsg{done: done}); err == nil { select { case closeErr = <-done: case <-w.ctx.Done(): @@ -377,11 +328,11 @@ func (w *stateWALImpl) Close() error { if err := w.asyncError(); err != nil { return fmt.Errorf("state WAL closed with error: %w", err) } - return closeErr // already wrapped by the writer, or nil on a clean seal + return closeErr // already wrapped by the underlying WAL, or nil on a clean close } -// sendToSerializer enqueues a message onto the serializer's input channel, aborting if the WAL is -// shutting down or has failed. +// sendToSerializer enqueues a message onto the serializer's input channel, aborting if the WAL is shutting +// down or has failed. func (w *stateWALImpl) sendToSerializer(msg any) error { select { case w.serializerChan <- msg: @@ -394,283 +345,65 @@ func (w *stateWALImpl) sendToSerializer(msg any) error { } } -// serializerLoop turns dataToBeSerialized messages into dataToBeWritten messages and forwards every message to -// the writer in FIFO order. Runs on its own goroutine until close or a fatal error. +// serializerLoop marshals each block's changesets into a per-block buffer and, at end-of-block, appends the +// buffer to the underlying WAL as a single record. Control messages (flush, range, prune, iterator, close) are +// handled in FIFO order relative to writes so they observe a consistent view. Runs on its own goroutine until +// close or a fatal error. func (w *stateWALImpl) serializerLoop() { defer w.wg.Done() - for { - var msg any - select { - case <-w.ctx.Done(): - return - case msg = <-w.serializerChan: - } - // A dataToBeSerialized becomes a dataToBeWritten; all other messages are forwarded unchanged. - if req, ok := msg.(dataToBeSerialized); ok { - payload, err := req.entry.Serialize() - if err != nil { - w.fail(fmt.Errorf("failed to serialize WAL entry: %w", err)) - return - } - msg = dataToBeWritten{ - record: frameRecord(payload), - blockNumber: req.entry.BlockNumber, - endOfBlock: req.entry.EndOfBlock, - } - } + // The accumulated payload of the block currently being written, reused across blocks. + var buf []byte - select { - case w.writerChan <- msg: - case <-w.ctx.Done(): - return - } - - if _, ok := msg.(closeRequest); ok { - // FIFO guarantees every prior write has been forwarded. Stop reading and forbid further - // pushes so any racing/future schedule aborts instead of deadlocking. - w.senderCancel() - return - } - } -} - -// writerLoop consumes forwarded messages, appending records to the mutable file and handling control messages. -// It owns all file bookkeeping and runs on its own goroutine until close or a fatal error. -func (w *stateWALImpl) writerLoop() { - defer w.wg.Done() for { var msg any select { case <-w.ctx.Done(): return - case msg = <-w.writerChan: + case msg = <-w.serializerChan: } switch m := msg.(type) { - case dataToBeWritten: - if err := w.appendRecord(m); err != nil { - w.fail(err) + case changesetMsg: + for _, ncs := range m.cs { + var err error + buf, err = appendChangeset(buf, ncs) + if err != nil { + w.fail(fmt.Errorf("failed to serialize changeset for block %d: %w", m.blockNumber, err)) + return + } + } + case endOfBlockMsg: + if err := w.wal.Append(m.blockNumber, buf); err != nil { + w.fail(fmt.Errorf("failed to append block %d: %w", m.blockNumber, err)) return } - case flushRequest: - m.done <- w.mutableFile.flush(w.config.FsyncOnFlush) - case rangeQuery: - m.reply <- w.blockRange() - case pruneRequest: - if err := w.pruneSealedFiles(m.through); err != nil { - w.fail(err) + buf = buf[:0] + case flushMsg: + m.done <- w.wal.Flush() + case rangeMsg: + ok, first, last, err := w.wal.Bounds() + m.reply <- rangeReply{ok: ok, start: first, end: last, err: err} + case pruneMsg: + if err := w.wal.Prune(m.through); err != nil { + w.fail(fmt.Errorf("failed to prune below block %d: %w", m.through, err)) return } - case iteratorStartRequest: - m.reply <- w.startIterator(m.startBlock) - case unpinRequest: - w.releaseBlock(m.block) - case closeRequest: - _, err := w.mutableFile.seal() - m.done <- err - return - } - } -} - -// appendRecord appends a record to the mutable file, updates bookkeeping, and rotates on block boundaries once -// the file exceeds the target size. -func (w *stateWALImpl) appendRecord(m dataToBeWritten) error { - if err := w.mutableFile.writeRecord(m.record, m.blockNumber, m.endOfBlock); err != nil { - return fmt.Errorf("failed to append record for block %d: %w", m.blockNumber, err) - } - walBytesWritten.Add(w.ctx, int64(len(m.record))) - - if m.endOfBlock { - walBlocksWritten.Add(w.ctx, 1) - if w.mutableFile.size >= uint64(w.config.TargetFileSize) { - if err := w.rotate(); err != nil { - return fmt.Errorf("failed to rotate after block %d: %w", m.blockNumber, err) + case iteratorMsg: + inner, err := w.wal.Iterator(m.startBlock) + if err != nil { + m.reply <- iteratorReply{err: err} + } else { + m.reply <- iteratorReply{iterator: newStateIterator(inner)} } + case closeMsg: + m.done <- w.wal.Close() + // FIFO guarantees every prior write has been appended. Forbid further pushes so any + // racing/future schedule aborts instead of deadlocking against the now-exiting serializer. + w.senderCancel() + return } } - return nil -} - -// rotate seals the current mutable file, records its bookkeeping, and opens a fresh mutable file. It is only -// called immediately after an end-of-block marker, so the mutable file ends on a block boundary. -func (w *stateWALImpl) rotate() error { - index := w.mutableFile.index - first := w.mutableFile.firstBlock - last := w.mutableFile.lastCompleteBlock - sealedName, err := w.mutableFile.seal() - if err != nil { - return fmt.Errorf("failed to seal WAL file during rotation: %w", err) - } - w.sealedFiles.PushBack(&sealedFileInfo{index: index, name: sealedName, firstBlock: first, lastBlock: last}) - walFilesSealed.Add(w.ctx, 1) - - mutable, err := newWalFile(w.config.Path, w.nextIndex) - if err != nil { - return fmt.Errorf("failed to open new mutable WAL file during rotation: %w", err) - } - w.mutableFile = mutable - w.nextIndex++ - return nil -} - -// pruneSealedFiles deletes sealed files whose highest block is below pruneThrough. Files are removed -// oldest-first (from the front of the deque) with a directory fsync after each removal, so a crash mid-prune -// leaves a contiguous suffix of files rather than a gap in the block sequence. The mutable file is never -// pruned. -// -// A live iterator holds a read lease at some block R and may still read every block from R onward, so no file -// whose range reaches R or higher may be removed. A file [first, last] is needed iff it overlaps [R, ∞), i.e. -// iff last >= R. Comparing the lowest live reservation against each file's last block (rather than testing -// whether the reservation falls inside a file's range) protects exactly the files an iterator can still open — -// even when the reservation lands in a gap between files or strictly inside a file's range. Because -// reservations never fall below the lowest stored block (see pinLowestReadableBlock), a file left below the -// lowest reservation is one the iterator has already moved past and can safely be dropped. -// -// Iteration stops at the first retained file: block ranges grow toward the back, so once a file is kept (by -// pruneThrough or by the lowest reservation) every later file is kept too. -func (w *stateWALImpl) pruneSealedFiles(pruneThrough uint64) error { - // Reservations are mutated only on this (the writer) goroutine, so the lowest reservation is stable for the - // duration of this prune and can be computed once. - reservation, hasReservation := w.lowestReservation() - for { - front, ok := w.sealedFiles.TryPeekFront() - if !ok || front.lastBlock >= pruneThrough { - break - } - if hasReservation && front.lastBlock >= reservation { - break // a live iterator may still read this file (or a later one); keep it and everything after - } - path := filepath.Join(w.config.Path, front.name) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to prune WAL file %s: %w", path, err) - } - if err := util.SyncParentPath(path); err != nil { - return fmt.Errorf("failed to fsync directory after pruning %s: %w", path, err) - } - w.sealedFiles.PopFront() - walFilesPruned.Add(w.ctx, 1) - } - return nil -} - -// startIterator builds an iterator on the writer goroutine. It first seals the mutable file (see -// sealForIterator) so every complete block written so far lives in an immutable sealed file, then snapshots -// the sealed files in ascending block order, registers the read lease, and constructs the iterator (which -// launches its reader goroutine). Running here serializes construction with rotation, seal, and prune, so the -// snapshot is a consistent point-in-time view: every file the iterator reads is sealed and immutable, opened -// lazily by name and protected from pruning by the lease, so its contents cannot change underneath the reader. -func (w *stateWALImpl) startIterator(startBlock uint64) iteratorStartResponse { - if err := w.sealForIterator(); err != nil { - return iteratorStartResponse{err: fmt.Errorf("failed to seal mutable file before creating iterator: %w", err)} - } - - files := make([]iteratorFile, 0, w.sealedFiles.Size()) - for _, info := range w.sealedFiles.Iterator() { - files = append(files, iteratorFile{ - index: info.index, - name: info.name, - firstBlock: info.firstBlock, - lastBlock: info.lastBlock, - }) - } - - pinned := w.pinLowestReadableBlock(startBlock) - it := newWalIterator(w, startBlock, pinned, files, w.config.IteratorPrefetchSize) - return iteratorStartResponse{iterator: it} -} - -// sealForIterator seals the mutable file so a newly-created iterator sees a snapshot that cannot change -// underneath it: after this call every complete block lives in an immutable sealed file. Any in-progress -// (unended) block is carried forward into the fresh mutable file so no scheduled write is lost. It is a no-op -// when the mutable file holds no complete block — the iterator reads only sealed files and never yields an -// unended block, so the mutable file (and any in-progress block) is simply left in place. -func (w *stateWALImpl) sealForIterator() error { - if !w.mutableFile.hasCompleteBlock { - return nil - } - - // Capture any in-progress block (records past the last end-of-block marker) before the seal truncates - // it away, so it can be re-appended to the fresh mutable file. The write-ordering contract guarantees - // these records all belong to a single block, namely the mutable file's last block. - tail, err := w.mutableFile.readIncompleteTail() - if err != nil { - return fmt.Errorf("failed to capture in-progress block: %w", err) - } - tailBlock := w.mutableFile.lastBlock - - if err := w.rotate(); err != nil { - return fmt.Errorf("failed to seal mutable file: %w", err) - } - - if len(tail) > 0 { - if err := w.mutableFile.appendIncompleteTail(tail, tailBlock); err != nil { - return fmt.Errorf("failed to carry in-progress block forward: %w", err) - } - } - return nil -} - -// pinLowestReadableBlock records a read lease and returns the pinned block. An iterator reads blocks at or -// above startBlock but never below the oldest block actually stored, so the lease is clamped up to that: a -// stale low start must not pin files that no longer exist (or wedge pruning forever). Clamping to the oldest -// stored block also establishes the invariant pruneSealedFiles relies on: a reservation never falls below the -// lowest stored block, so a file entirely below the lowest reservation is one every iterator has moved past. -func (w *stateWALImpl) pinLowestReadableBlock(startBlock uint64) uint64 { - pinned := startBlock - if r := w.blockRange(); r.ok && r.start > pinned { - pinned = r.start - } - w.blockRefs[pinned]++ - return pinned -} - -// releaseBlock drops one reference to a leased block, forgetting it once the count reaches zero. -func (w *stateWALImpl) releaseBlock(block uint64) { - if w.blockRefs[block] <= 1 { - delete(w.blockRefs, block) - return - } - w.blockRefs[block]-- -} - -// lowestReservation returns the smallest block number currently leased by a live iterator, and ok=false when no -// lease is held. A lease at block R means some iterator may still read blocks at or above R, so every sealed -// file whose range reaches R or higher must be retained by pruning. -func (w *stateWALImpl) lowestReservation() (uint64, bool) { - var lowest uint64 - found := false - for block := range w.blockRefs { - if !found || block < lowest { - lowest = block - found = true - } - } - return lowest, found -} - -// blockRange reports the range of complete blocks across all files. Complete blocks live in the sealed files -// (all complete) and in the mutable file up to its last end-of-block marker. Owned by the writer goroutine. -func (w *stateWALImpl) blockRange() storedRange { - var r storedRange - - // The highest complete block is in the mutable file if it has one, otherwise in the newest sealed file. - if w.mutableFile.hasCompleteBlock { - r = storedRange{ok: true, end: w.mutableFile.lastCompleteBlock} - } else if back, ok := w.sealedFiles.TryPeekBack(); ok { - r = storedRange{ok: true, end: back.lastBlock} - } else { - return storedRange{} // nothing complete stored yet - } - - // The lowest stored block is in the oldest sealed file if any, otherwise in the mutable file. - if front, ok := w.sealedFiles.TryPeekFront(); ok { - r.start = front.firstBlock - } else { - r.start = w.mutableFile.firstBlock - } - return r } // fail records the first fatal background error and triggers shutdown of the pipeline. @@ -687,111 +420,3 @@ func (w *stateWALImpl) asyncError() error { } return nil } - -// recoverOrphans seals any unsealed WAL files left behind by a crash. -func recoverOrphans(directory string) error { - entries, err := os.ReadDir(directory) - if err != nil { - return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - parsed, ok := parseFileName(entry.Name()) - if !ok || parsed.sealed { - continue - } - if err := sealOrphanFile(directory, entry.Name()); err != nil { - return fmt.Errorf("failed to seal orphan %s: %w", entry.Name(), err) - } - } - return nil -} - -// rollbackDirectory drops all data beyond rollbackThrough from the sealed files. Assumes orphans are already -// sealed. Files are processed highest-index-first: the files entirely beyond the rollback point (a suffix of -// the index sequence) are removed one at a time, each removal made durable before the next, and finally the -// single file straddling the rollback point is truncated. This ordering guarantees that a crash mid-rollback -// always leaves a contiguous prefix of files — never a gap that scanSealedFiles would reject — mirroring the -// contiguous-suffix guarantee that pruning provides from the other end. -func rollbackDirectory(directory string, rollbackThrough uint64) error { - entries, err := os.ReadDir(directory) - if err != nil { - return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) - } - - sealed := make([]parsedFileName, 0, len(entries)) - names := make(map[uint64]string, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - parsed, ok := parseFileName(entry.Name()) - if !ok || !parsed.sealed { - continue - } - sealed = append(sealed, parsed) - names[parsed.index] = entry.Name() - } - sort.Slice(sealed, func(i int, j int) bool { return sealed[i].index > sealed[j].index }) - - for _, parsed := range sealed { - if parsed.lastBlock <= rollbackThrough { - // This file lies entirely at or below the rollback point; so does every lower-indexed file. Done. - break - } - if parsed.firstBlock > rollbackThrough { - // Entirely beyond the rollback point: remove the whole file, durably, before the next-lower one. - if err := removeAndSyncDir(directory, names[parsed.index]); err != nil { - return fmt.Errorf("failed to roll back %s: %w", names[parsed.index], err) - } - continue - } - // Straddles the rollback point: truncate away the blocks beyond it. This is the last file to process. - if err := rollbackStraddlingFile(directory, names[parsed.index], rollbackThrough); err != nil { - return fmt.Errorf("failed to roll back %s: %w", names[parsed.index], err) - } - } - return nil -} - -// scanSealedFiles loads the sealed files in a directory into an ascending-order deque and returns the index to -// assign the next mutable file (one past the highest sealed index, or 0 when there are none). File indices -// must be contiguous: a gap means a sealed file went missing, which is unrecoverable corruption, so this fails -// with an informative error rather than silently leaving a hole in the block sequence. -func scanSealedFiles(directory string) (*util.RandomAccessDeque[*sealedFileInfo], uint64, error) { - entries, err := os.ReadDir(directory) - if err != nil { - return nil, 0, fmt.Errorf("failed to read WAL directory %s: %w", directory, err) - } - - parsed := make([]parsedFileName, 0, len(entries)) - names := make(map[uint64]string, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - p, ok := parseFileName(entry.Name()) - if !ok || !p.sealed { - continue - } - parsed = append(parsed, p) - names[p.index] = entry.Name() - } - sort.Slice(parsed, func(i int, j int) bool { return parsed[i].index < parsed[j].index }) - - sealedFiles := util.NewRandomAccessDeque[*sealedFileInfo](uint64(len(parsed))) - var nextIndex uint64 - for i, p := range parsed { - if i > 0 && p.index != parsed[i-1].index+1 { - return nil, 0, fmt.Errorf( - "WAL is corrupt: sealed file indices are not contiguous (gap between %d and %d)", - parsed[i-1].index, p.index) - } - sealedFiles.PushBack(&sealedFileInfo{ - index: p.index, name: names[p.index], firstBlock: p.firstBlock, lastBlock: p.lastBlock}) - nextIndex = p.index + 1 - } - return sealedFiles, nextIndex, nil -} diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go index 69ebc3fec8..12d861baf3 100644 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ b/sei-db/state_db/statewal/state_wal_impl_test.go @@ -1,9 +1,6 @@ package statewal import ( - "os" - "path/filepath" - "sort" "testing" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -29,8 +26,8 @@ func writeBlock(t *testing.T, w StateWAL, block uint64) { require.NoError(t, w.SignalEndOfBlock()) } -// collectBlocks iterates from start and returns the block number of each coalesced block entry, verifying -// that entries are strictly increasing and never carry an end-of-block marker. +// collectBlocks iterates from start and returns the block number of each entry, verifying that entries are +// strictly increasing and never below start. func collectBlocks(t *testing.T, w StateWAL, start uint64) []uint64 { t.Helper() it, err := w.Iterator(start) @@ -46,7 +43,6 @@ func collectBlocks(t *testing.T, w StateWAL, start uint64) []uint64 { } entry := it.Entry() require.GreaterOrEqual(t, entry.BlockNumber, start) - require.False(t, entry.EndOfBlock) if len(blocks) > 0 { require.Greater(t, entry.BlockNumber, blocks[len(blocks)-1]) } @@ -122,7 +118,7 @@ func TestContractViolations(t *testing.T) { }) } -func TestIncompleteTailBlockDiscardedOnReopen(t *testing.T) { +func TestIncompleteBlockDiscardedOnReopen(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -130,7 +126,7 @@ func TestIncompleteTailBlockDiscardedOnReopen(t *testing.T) { for block := uint64(1); block <= 3; block++ { writeBlock(t, w, block) } - // Block 4 is written but never ended (a crash mid-block). + // Block 4 is written but never ended (a crash mid-block): it was never appended as a record. require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{0x04}, []byte{0x04})})) require.NoError(t, w.Flush()) require.NoError(t, w.Close()) @@ -154,127 +150,42 @@ func TestIncompleteTailBlockDiscardedOnReopen(t *testing.T) { require.Equal(t, uint64(4), end) } -func TestOrphanFileRecovery(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - // Fabricate an orphaned unsealed file: blocks 1 and 2 complete, block 3 incomplete, left unsealed as if - // the process crashed before it could seal. - f, err := newWalFile(dir, 0) - require.NoError(t, err) - writeCompleteBlock(t, f, 1) - writeCompleteBlock(t, f, 2) - cs := []*proto.NamedChangeSet{makeChangeSet("a", []byte{3}, []byte{3})} - require.NoError(t, f.writeEntry(NewEntry(3, cs))) // no end-of-block marker: block 3 is incomplete - require.NoError(t, f.flush(true)) - require.NoError(t, f.file.Close()) - - w := openWAL(t, cfg) +func TestGetStoredRangeEmpty(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() - ok, start, end, err := w.GetStoredRange() + ok, _, _, err := w.GetStoredRange() require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(2), end) - require.Equal(t, []uint64{1, 2}, collectBlocks(t, w, 1)) + require.False(t, ok) } -func TestRotationProducesContiguousSealedFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // rotate after every completed block +func TestEmptyChangesetBlockIsStored(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() - w := openWAL(t, cfg) - for block := uint64(1); block <= 6; block++ { - writeBlock(t, w, block) - } + // A block with an empty changeset that is properly ended is a real, stored block. + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{})) + require.NoError(t, w.SignalEndOfBlock()) require.NoError(t, w.Flush()) ok, start, end, err := w.GetStoredRange() require.NoError(t, err) require.True(t, ok) require.Equal(t, uint64(1), start) - require.Equal(t, uint64(6), end) - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectBlocks(t, w, 1)) - require.NoError(t, w.Close()) - - // Every completed block should have produced its own sealed file with a clean [k,k] range. - var sealed []parsedFileName - entries, err := os.ReadDir(dir) - require.NoError(t, err) - for _, entry := range entries { - if parsed, okName := parseFileName(entry.Name()); okName && parsed.sealed { - sealed = append(sealed, parsed) - require.Equal(t, parsed.firstBlock, parsed.lastBlock) - } - } - require.Len(t, sealed, 6) -} - -func countSealedFiles(t *testing.T, dir string) int { - t.Helper() - entries, err := os.ReadDir(dir) - require.NoError(t, err) - count := 0 - for _, entry := range entries { - if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { - count++ - } - } - return count -} - -func TestBlockNeverSplitAcrossFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 128 // tiny, so a single block's data dwarfs the rotation threshold - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - // Write many changesets for the same block, far exceeding TargetFileSize, without ending the block. - const changesetCount = 50 - value := make([]byte, 100) - for i := 0; i < changesetCount; i++ { - cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(i)}, value)} - require.NoError(t, w.Write(1, cs)) - } - require.NoError(t, w.Flush()) - - // Despite blowing past TargetFileSize many times over, the still-open block must not have been sealed: - // no sealed file exists yet, so all of block 1's data lives in the single mutable file. - require.Equal(t, 0, countSealedFiles(t, dir)) - - // Closing the block permits rotation; block 1's data is sealed into exactly one file. - require.NoError(t, w.SignalEndOfBlock()) - require.NoError(t, w.Flush()) - - require.Equal(t, 1, countSealedFiles(t, dir)) + require.Equal(t, uint64(1), end) - // The iterator coalesces all of block 1's Write records into a single entry whose changeset is the - // concatenation, in write order, of every record's changesets. it, err := w.Iterator(1) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() - - ok, err := it.Next() + ok, err = it.Next() require.NoError(t, err) require.True(t, ok) entry := it.Entry() require.Equal(t, uint64(1), entry.BlockNumber) - require.False(t, entry.EndOfBlock) - require.Len(t, entry.Changeset, changesetCount) - for i, ncs := range entry.Changeset { - require.Equal(t, []byte{byte(i)}, ncs.Changeset.Pairs[0].Key) - } - - ok, err = it.Next() - require.NoError(t, err) - require.False(t, ok) + require.Empty(t, entry.Changeset) } -func TestPruneDropsWholeFiles(t *testing.T) { +func TestPruneDropsOldBlocks(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) cfg.TargetFileSize = 1 // one block per file, so pruning can drop whole files @@ -296,402 +207,44 @@ func TestPruneDropsWholeFiles(t *testing.T) { require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 0)) } -func TestPrunePastAllBlocksEmptiesRange(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one block per file so every block sits in a prunable sealed file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 5; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - require.NoError(t, w.Prune(100)) - - ok, _, _, err := w.GetStoredRange() - require.NoError(t, err) - require.False(t, ok) -} - -func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one block per sealed file, so pruning works file-by-file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 10; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - // Hold an iterator anchored at block 1 (the oldest). Its read lease must keep block 1's file alive. - it, err := w.Iterator(1) - require.NoError(t, err) - - require.NoError(t, w.Prune(5)) - ok, start, end, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start, "block 1 must survive pruning while a live iterator pins it") - require.Equal(t, uint64(10), end) - - // The iterator still sees the full, intact sequence. - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 1)) - - // Releasing the lease lets the same prune make progress. - require.NoError(t, it.Close()) - require.NoError(t, w.Prune(5)) - ok, start, _, err = w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), start) -} - -func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 10; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - // An iterator anchored at block 8 does not need blocks below 5, so pruning to 5 proceeds. - it, err := w.Iterator(8) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - require.NoError(t, w.Prune(5)) - ok, start, _, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), start) -} - -// TestIteratorInGapBlocksPruningAcrossGap covers the block-number gap case: the WAL contract allows block -// numbers to jump, so an iterator's read lease can land in a gap between stored files. Pruning must still -// protect every file the iterator will read (those reaching the lease block or higher), even though no file's -// range contains the lease block itself. The directory is inspected directly rather than relying on iterator -// output, since the reader goroutine may have buffered the files into memory before an unsafe delete. -func TestIteratorInGapBlocksPruningAcrossGap(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one block per sealed file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - // Blocks 1,2,3 then a legal jump to 10,11,12. The lease block 5 falls in the gap (3, 10). - for _, block := range []uint64{1, 2, 3, 10, 11, 12} { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(5) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - // Prune(12) would remove every file with last block < 12, but the live lease at 5 must keep the files for - // blocks 10 and 11 (both >= 5). Only the files entirely below the lease (blocks 1,2,3) may be dropped. - require.NoError(t, w.Prune(12)) - _, _, _, err = w.GetStoredRange() // synchronous round-trip forces the async prune to complete - require.NoError(t, err) - - names := sealedFileNames(t, dir) - require.Contains(t, names, sealedFileName(3, 10, 10), "file for block 10 must survive while iterator(5) is live") - require.Contains(t, names, sealedFileName(4, 11, 11), "file for block 11 must survive while iterator(5) is live") - require.NotContains(t, names, sealedFileName(0, 1, 1), "file for block 1 is below the lease and should be pruned") - - require.Equal(t, []uint64{10, 11, 12}, collectBlocks(t, w, 5)) -} - -// TestIteratorLeaseInsideFileRangeBlocksPruning checks the boundary where the lease block sits within the kept -// window: an iterator anchored at 5 must keep blocks 5..10 even as pruning is asked to drop through a higher -// point, because those files reach the lease block or higher. -func TestIteratorLeaseInsideFileRangeBlocksPruning(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one block per sealed file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 10; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(5) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - require.NoError(t, w.Prune(8)) - ok, start, end, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), start, "lease at 5 must keep blocks from 5 onward") - require.Equal(t, uint64(10), end) - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 5)) -} - -func TestScanRejectsGapInSealedFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one block per sealed file - - w := openWAL(t, cfg) - for block := uint64(1); block <= 4; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - // Delete a middle sealed file to punch a gap in the index sequence, simulating corruption. - var sealed []parsedFileName - entries, err := os.ReadDir(dir) - require.NoError(t, err) - for _, entry := range entries { - if p, ok := parseFileName(entry.Name()); ok && p.sealed { - sealed = append(sealed, p) - } - } - require.GreaterOrEqual(t, len(sealed), 3) - sort.Slice(sealed, func(i int, j int) bool { return sealed[i].index < sealed[j].index }) - victim := sealed[len(sealed)/2] - require.NoError(t, os.Remove(filepath.Join(dir, sealedFileName(victim.index, victim.firstBlock, victim.lastBlock)))) - - _, err = New(cfg) - require.Error(t, err) - require.Contains(t, err.Error(), "not contiguous") -} - -func TestGetStoredRangeEmpty(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - ok, _, _, err := w.GetStoredRange() - require.NoError(t, err) - require.False(t, ok) -} - +// TestRollbackConstructor is a wrapper-level smoke test that NewWithRollback drops blocks beyond the rollback +// point end to end (the crash-safety details are exercised in the seiwal package). func TestRollbackConstructor(t *testing.T) { - t.Run("drops whole files beyond the rollback point", func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one block per file - - w := openWAL(t, cfg) - for block := uint64(1); block <= 6; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - w2, err := NewWithRollback(cfg, 3) - require.NoError(t, err) - defer func() { require.NoError(t, w2.Close()) }() - - ok, start, end, err := w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) - }) - - t.Run("truncates within a file at the rollback point", func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: all blocks land in one file - - w := openWAL(t, cfg) - for block := uint64(1); block <= 6; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - w2, err := NewWithRollback(cfg, 3) - require.NoError(t, err) - defer func() { require.NoError(t, w2.Close()) }() - - ok, start, end, err := w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) - - // Writing continues cleanly after the rollback point. - writeBlock(t, w2, 4) - require.NoError(t, w2.Flush()) - _, _, end, err = w2.GetStoredRange() - require.NoError(t, err) - require.Equal(t, uint64(4), end) - }) - - // After a rollback, a subsequent *normal* open (not another rollback) must observe exactly the rolled-back - // range. This is the path that would expose a name/content mismatch left by a non-crash-safe rollback: - // GetStoredRange is name-derived while iteration is content-bound, so the two agree only if the truncation - // and rename were applied consistently. Exercises both rollback shapes: whole-file removal and in-file - // truncation of the straddling file. - t.Run("rolled-back state is consistent under a normal reopen", func(t *testing.T) { - for _, tc := range []struct { - name string - targetSize uint - }{ - {"whole-file removal", 1}, // one block per file: rollback removes whole trailing files - {"in-file truncation", 64 * 1024 * 1024}, // all blocks in one file: rollback truncates it in place - } { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = tc.targetSize - - w := openWAL(t, cfg) - for block := uint64(1); block <= 6; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - w2, err := NewWithRollback(cfg, 3) - require.NoError(t, err) - require.NoError(t, w2.Close()) - - // Reopen normally; the rollback must have durably and consistently reduced the range to [1,3]. - w3 := openWAL(t, cfg) - defer func() { require.NoError(t, w3.Close()) }() - - ok, start, end, err := w3.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w3, 1)) - }) - } - }) -} - -// sealedFileNames returns the names of all sealed WAL files in dir, sorted for stable assertions. -func sealedFileNames(t *testing.T, dir string) []string { - t.Helper() - entries, err := os.ReadDir(dir) - require.NoError(t, err) - var names []string - for _, entry := range entries { - if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { - names = append(names, entry.Name()) - } + for _, tc := range []struct { + name string + targetSize uint + }{ + {"whole-file removal", 1}, // one block per file: rollback removes whole trailing files + {"in-file truncation", 64 * 1024 * 1024}, // all blocks in one file: rollback truncates it in place + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = tc.targetSize + + w := openWAL(t, cfg) + for block := uint64(1); block <= 6; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + w2, err := NewWithRollback(cfg, 3) + require.NoError(t, err) + defer func() { require.NoError(t, w2.Close()) }() + + ok, start, end, err := w2.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(3), end) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) + + // Writing continues cleanly after the rollback point. + writeBlock(t, w2, 4) + require.NoError(t, w2.Flush()) + _, _, end, err = w2.GetStoredRange() + require.NoError(t, err) + require.Equal(t, uint64(4), end) + }) } - sort.Strings(names) - return names -} - -// blockPrefixBytes reads the sealed file at path and returns the raw bytes of the prefix ending just past the -// end-of-block marker for lastKeep — i.e. the exact content rollbackStraddlingFile's AtomicWrite would install -// for a rollback to lastKeep. It is the test's stand-in for "the truncated copy the rollback would produce". -func blockPrefixBytes(t *testing.T, path string, lastKeep uint64) []byte { - t.Helper() - data, err := os.ReadFile(path) - require.NoError(t, err) - contents, err := readWalFile(path) - require.NoError(t, err) - var truncateTo int64 - found := false - for _, b := range contents.blockBoundaries { - if b.block == lastKeep { - truncateTo = b.offset - found = true - break - } - } - require.True(t, found, "block %d has no end-of-block boundary in %s", lastKeep, path) - return data[:truncateTo] -} - -// TestRollbackCrashAfterSwapReconciledOnReopen simulates a crash in rollbackStraddlingFile after the reduced -// file was durably written (AtomicWrite) but before the old, larger-named file was removed. That leaves two -// sealed files sharing an index. A subsequent open must reconcile them — keeping the reduced file — so the -// name-derived GetStoredRange and the content-derived iterator agree on the rolled-back range. -func TestRollbackCrashAfterSwapReconciledOnReopen(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: all six blocks land in one file, index 0 - - w := openWAL(t, cfg) - for block := uint64(1); block <= 6; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - oldName := sealedFileName(0, 1, 6) - require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) - - // Reproduce the crash state: the reduced file [1,3] exists next to the untouched original [1,6]. - reducedName := sealedFileName(0, 1, 3) - prefix := blockPrefixBytes(t, filepath.Join(dir, oldName), 3) - require.NoError(t, os.WriteFile(filepath.Join(dir, reducedName), prefix, 0o600)) - require.Equal(t, []string{reducedName, oldName}, sealedFileNames(t, dir)) - - // A plain reopen must reconcile the duplicate index down to the rolled-back file. - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - require.Equal(t, []string{reducedName}, sealedFileNames(t, dir)) - ok, start, end, err := w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) -} - -// TestRollbackCrashDuringSwapWindowRecovers simulates a crash mid-rollback in the earlier window: the -// AtomicWrite's swap file was created but not yet renamed into place, so only a leftover ".swap" exists beside -// the still-intact original. A reopen must drop the swap and leave the original range intact (the rollback -// simply did not take effect), and a subsequent rollback must then complete cleanly and durably. -func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: all six blocks in one file, index 0 - - w := openWAL(t, cfg) - for block := uint64(1); block <= 6; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - oldName := sealedFileName(0, 1, 6) - - // Reproduce the crash state: an unfinished AtomicWrite left a swap file for the reduced name, alongside - // the untouched original. util.AtomicWrite names its temp ".swap". - prefix := blockPrefixBytes(t, filepath.Join(dir, oldName), 3) - swapName := sealedFileName(0, 1, 3) + ".swap" - require.NoError(t, os.WriteFile(filepath.Join(dir, swapName), prefix, 0o600)) - - // A plain reopen drops the swap; the original range survives (rollback did not take effect). - w2 := openWAL(t, cfg) - require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) - _, err := os.Stat(filepath.Join(dir, swapName)) - require.True(t, os.IsNotExist(err), "leftover swap file should have been removed") - ok, start, end, err := w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(6), end) - require.NoError(t, w2.Close()) - - // The subsequent rollback completes cleanly, and a normal reopen sees the consistent rolled-back range. - w3, err := NewWithRollback(cfg, 3) - require.NoError(t, err) - require.NoError(t, w3.Close()) - - w4 := openWAL(t, cfg) - defer func() { require.NoError(t, w4.Close()) }() - require.Equal(t, []string{sealedFileName(0, 1, 3)}, sealedFileNames(t, dir)) - ok, start, end, err = w4.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w4, 1)) } diff --git a/sei-db/state_db/statewal/state_wal_iterator.go b/sei-db/state_db/statewal/state_wal_iterator.go index 96c6aeb3a6..760e341c81 100644 --- a/sei-db/state_db/statewal/state_wal_iterator.go +++ b/sei-db/state_db/statewal/state_wal_iterator.go @@ -2,284 +2,50 @@ package statewal import ( "fmt" - "os" - "path/filepath" - "sync" + + "github.com/sei-protocol/sei-chain/sei-db/seiwal" ) var _ StateWALIterator = (*walIterator)(nil) -// A block produced by the reader goroutine, or a terminal error. -type iteratorResult struct { - entry *Entry - err error -} - -// iteratorFile is one entry in an iterator's file snapshot, captured on the writer goroutine when the iterator -// is created (see startIterator). Every snapshot file is sealed and immutable: it carries its immutable name -// and is opened lazily by the reader, held against pruning by the iterator's read lease. -type iteratorFile struct { - index uint64 - name string - firstBlock uint64 - lastBlock uint64 -} - -// walIterator iterates the WAL a block at a time, in ascending block order. A dedicated reader goroutine reads -// WAL files from disk, coalesces each block's records (one per Write call, plus its end-of-block marker) into a -// single entry, and pushes it onto a buffered channel; Next simply dequeues. Decoupling disk reads from the -// consumer keeps the reader busy while the consumer works, which matters for startup replay speed. The reader -// loads one file at a time, so its memory use is bounded by a single WAL file plus the prefetch buffer. -// -// The set of files to read is snapshotted once at creation (files), so the reader walks it in O(n) without -// re-scanning the directory. Every snapshot file is sealed and immutable (the mutable file is sealed at -// creation, see startIterator), so its contents cannot change under the reader. A read lease (pinnedBlock) -// holds the files the reader needs against concurrent pruning; Close releases it. +// walIterator adapts a generic seiwal.Iterator (which yields opaque byte payloads keyed by index) into a +// StateWALIterator (which yields decoded block entries). Each record's index is the block number and its +// payload is the block's serialized changesets; deserialization happens in Next so it can surface an error, +// and the decoded entry is cached for Entry. type walIterator struct { - // The WAL this iterator reads from. - wal *stateWALImpl - - // The lowest block the consumer asked for; blocks below it are skipped. - start uint64 - - // The block pinned as this iterator's read lease, released on Close. - pinnedBlock uint64 - - // Coalesced blocks produced by the reader goroutine. Closed by the reader on clean EOF. - results chan iteratorResult - - // Closed by Close to tell the reader goroutine to stop early. - stop chan struct{} - - // Closed by the reader goroutine when it exits, so Close can wait for it. - readerExited chan struct{} - - // Ensures the shutdown sequence runs at most once. - closeOnce sync.Once - - // The entry returned by Entry, set by the most recent successful Next. Consumer-owned. - result *Entry - - // Set once iteration is complete. Consumer-owned. - done bool - - // The following fields are owned exclusively by the reader goroutine. - - // The point-in-time snapshot of files to read, in ascending block order. Set once at construction. - files []iteratorFile - // The index into files of the next file to load. - filePos int - // The records loaded from the current file, filtered to complete blocks at or beyond start. - buffer []*Entry - // The position within buffer; -1 before the first record is read. - pos int + inner seiwal.Iterator + entry *Entry } -// newWalIterator creates an iterator over wal starting at startingBlockNumber and launches its reader -// goroutine. pinnedBlock is the read lease registered on the iterator's behalf, released by Close. files is the -// snapshot of files to read (captured on the writer goroutine). prefetch is the number of blocks the reader may -// buffer ahead of the consumer. -func newWalIterator( - wal *stateWALImpl, - startingBlockNumber uint64, - pinnedBlock uint64, - files []iteratorFile, - prefetch uint, -) *walIterator { - it := &walIterator{ - wal: wal, - start: startingBlockNumber, - pinnedBlock: pinnedBlock, - results: make(chan iteratorResult, prefetch), - stop: make(chan struct{}), - readerExited: make(chan struct{}), - files: files, - pos: -1, - } - go it.read() - return it +// newStateIterator wraps a generic WAL iterator as a state WAL iterator. +func newStateIterator(inner seiwal.Iterator) *walIterator { + return &walIterator{inner: inner} } func (it *walIterator) Next() (bool, error) { - if it.done { - return false, nil - } - // Drain an already-available result first (non-blocking), so a clean EOF — the reader closing results - // with blocks still buffered — is never lost to a concurrent WAL shutdown in the select below. - select { - case result, ok := <-it.results: - return it.deliver(result, ok) - default: - } - // Otherwise wait for the next result, but don't block forever if the WAL is torn down. The reader - // goroutine watches the same context (see send) and stops producing when it fires, so the results - // channel would never advance again; surface the shutdown as an error rather than hang. - select { - case result, ok := <-it.results: - return it.deliver(result, ok) - case <-it.wal.ctx.Done(): - it.done = true - return false, fmt.Errorf("WAL shut down during iteration: %w", it.wal.ctx.Err()) + ok, err := it.inner.Next() + if err != nil { + it.entry = nil + return false, err } -} - -// deliver turns a dequeued reader result (or a closed-channel signal) into a Next return value. -func (it *walIterator) deliver(result iteratorResult, ok bool) (bool, error) { if !ok { - it.done = true + it.entry = nil return false, nil } - if result.err != nil { - it.done = true - return false, result.err + index, data := it.inner.Entry() + changeset, err := deserializeChangesets(data) + if err != nil { + it.entry = nil + return false, fmt.Errorf("failed to deserialize block %d: %w", index, err) } - it.result = result.entry + it.entry = &Entry{BlockNumber: index, Changeset: changeset} return true, nil } func (it *walIterator) Entry() *Entry { - return it.result + return it.entry } func (it *walIterator) Close() error { - it.closeOnce.Do(func() { - close(it.stop) // tell the reader to stop if it is mid-read - <-it.readerExited // wait for it to actually exit before releasing resources - it.wal.unpinBlock(it.pinnedBlock) - }) - it.done = true - return nil -} - -// read is the reader goroutine: it produces coalesced blocks onto the results channel until the WAL is -// exhausted (then closes the channel), a read fails (then sends the error), Close signals a stop, or the WAL -// context is cancelled (see send). It never blocks indefinitely, so it cannot outlive the WAL as a zombie. -func (it *walIterator) read() { - defer close(it.readerExited) - for { - block, ok, err := it.nextBlock() - if err != nil { - it.send(iteratorResult{err: err}) - return - } - if !ok { - close(it.results) - return - } - if !it.send(iteratorResult{entry: block}) { - return // Close signalled a stop - } - } -} - -// send pushes a result onto the channel, returning false if Close signalled a stop or the WAL was torn down -// first. Watching the WAL context here is what keeps the reader from becoming a zombie: if an iterator is -// orphaned (Iterator aborted via ctx.Done before the caller ever received it, so Close is never called) the -// prefetch buffer eventually fills and this send would otherwise block forever with no one to close stop. -func (it *walIterator) send(result iteratorResult) bool { - select { - case it.results <- result: - return true - case <-it.stop: - return false - case <-it.wal.ctx.Done(): - return false - } -} - -// nextBlock assembles the next block by draining records until it consumes that block's end-of-block marker. -// Returns ok=false once no records remain. -func (it *walIterator) nextBlock() (*Entry, bool, error) { - var block *Entry - for { - record, ok, err := it.nextRecord() - if err != nil { - return nil, false, err - } - if !ok { - // End of stream. A complete block always ends with an end-of-block marker, so reaching here - // mid-block should not happen; emit any assembled changes defensively rather than dropping them. - if block != nil { - return block, true, nil - } - return nil, false, nil - } - if block == nil { - block = &Entry{BlockNumber: record.BlockNumber} - } - if record.EndOfBlock { - return block, true, nil - } - block.Changeset = append(block.Changeset, record.Changeset...) - } -} - -// nextRecord returns the next individual record (changeset or end-of-block marker) in ascending order, -// advancing across files as needed. It returns ok=false once no further records remain. -func (it *walIterator) nextRecord() (*Entry, bool, error) { - for { - it.pos++ - if it.pos < len(it.buffer) { - return it.buffer[it.pos], true, nil - } - loaded, err := it.loadNextFile() - if err != nil { - return nil, false, err - } - if !loaded { - return nil, false, nil - } - it.pos = -1 - } -} - -// loadNextFile walks the file snapshot from filePos, loading the next file's records (filtered to complete -// blocks at or beyond start) into buffer and advancing filePos. It returns false when the snapshot is -// exhausted. Sealed files entirely below start are skipped without being opened; a file that yields no matching -// records leaves buffer empty (still reported as loaded). -func (it *walIterator) loadNextFile() (bool, error) { - for { - if it.filePos >= len(it.files) { - return false, nil - } - f := &it.files[it.filePos] - it.filePos++ - it.buffer = nil - - if f.lastBlock < it.start { - continue // entirely below the start block; skip without opening - } - - handle, err := it.openFile(f) - if err != nil { - return false, err - } - - parsed := parsedFileName{index: f.index, firstBlock: f.firstBlock, lastBlock: f.lastBlock, sealed: true} - contents, err := readWalFileFromHandle(handle, parsed) - if err != nil { - return false, fmt.Errorf("failed to read WAL file (index %d) during iteration: %w", f.index, err) - } - if !contents.hasCompleteBlock { - return true, nil - } - for _, entry := range contents.entries { - if entry.BlockNumber < it.start || entry.BlockNumber > contents.lastCompleteBlock { - continue - } - it.buffer = append(it.buffer, entry) - } - return true, nil - } -} - -// openFile opens a snapshot file by its immutable sealed name. The read lease keeps the file alive against -// pruning, so the open cannot miss it. readWalFileFromHandle closes the returned handle after reading. -func (it *walIterator) openFile(f *iteratorFile) (*os.File, error) { - path := filepath.Join(it.wal.config.Path, f.name) - handle, err := os.Open(path) //nolint:gosec // path derived from the writer's file snapshot - if err != nil { - return nil, fmt.Errorf("failed to open WAL file %s during iteration: %w", f.name, err) - } - return handle, nil + return it.inner.Close() } diff --git a/sei-db/state_db/statewal/state_wal_iterator_test.go b/sei-db/state_db/statewal/state_wal_iterator_test.go index 8f73b3484f..2126ff96cf 100644 --- a/sei-db/state_db/statewal/state_wal_iterator_test.go +++ b/sei-db/state_db/statewal/state_wal_iterator_test.go @@ -1,10 +1,7 @@ package statewal import ( - "fmt" - "sync" "testing" - "time" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/stretchr/testify/require" @@ -34,112 +31,6 @@ func TestIteratorFromMiddle(t *testing.T) { require.Equal(t, []uint64{3, 4, 5}, collectBlocks(t, w, 3)) } -func TestIteratorAcrossFiles(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.TargetFileSize = 1 // one block per file - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 5; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{2, 3, 4, 5}, collectBlocks(t, w, 2)) -} - -func TestIteratorWithTinyPrefetchBuffer(t *testing.T) { - // A prefetch buffer smaller than the number of blocks exercises reader backpressure: the reader must - // block on a full channel and resume as the consumer drains, without deadlocking or dropping blocks. - cfg := testConfig(t.TempDir()) - cfg.IteratorPrefetchSize = 1 - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 20; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, - collectBlocks(t, w, 1)) -} - -func TestIteratorCloseBeforeDrainDoesNotLeak(t *testing.T) { - // Closing an iterator before consuming it must unblock and shut down the reader goroutine cleanly. - cfg := testConfig(t.TempDir()) - cfg.IteratorPrefetchSize = 1 - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 20; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1) - require.NoError(t, err) - // Consume just one block, then close while the reader is still mid-stream (blocked on the full buffer). - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - require.NoError(t, it.Close()) - require.NoError(t, it.Close()) // idempotent -} - -func TestIteratorReaderExitsWhenWALTornDownWhileOrphaned(t *testing.T) { - // Regression: an iterator whose reader fills the prefetch buffer and is never consumed or Closed — as - // happens when Iterator() is aborted via ctx.Done() and the constructed iterator is returned to no one — - // must not leave its reader goroutine blocked on send() forever. Watching the WAL context lets the reader - // exit when the WAL is torn down, so it cannot become a zombie. - cfg := testConfig(t.TempDir()) - cfg.IteratorPrefetchSize = 1 - w := openWAL(t, cfg) - for block := uint64(1); block <= 20; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1) - require.NoError(t, err) - iter := it.(*walIterator) - - // Do not consume the iterator: the reader fills the prefetch buffer (size 1) and blocks on send. Tear - // down the WAL context out from under it, as fail() or Close() would. - w.(*stateWALImpl).cancel() - - select { - case <-iter.readerExited: - case <-time.After(5 * time.Second): - t.Fatal("reader goroutine did not exit after the WAL context was cancelled") - } - - // A consumer that races the teardown drains whatever the reader had already buffered, then observes an - // error rather than a clean EOF: a truncated iteration must never masquerade as fully consumed. - var termErr error - for i := 0; i < 25; i++ { - ok, err := it.Next() - if err != nil { - termErr = err - break - } - if !ok { - break - } - } - require.Error(t, termErr, "truncated iteration must surface an error, not a clean EOF") -} - -func TestIteratorStopsBeforeIncompleteTail(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 3; block++ { - writeBlock(t, w, block) - } - // Block 4 written but not ended. - require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{4}, []byte{4})})) - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1)) -} - func TestIteratorYieldsChangesetContents(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() @@ -158,113 +49,68 @@ func TestIteratorYieldsChangesetContents(t *testing.T) { require.True(t, ok) entry := it.Entry() require.Equal(t, uint64(1), entry.BlockNumber) - require.False(t, entry.EndOfBlock) require.Len(t, entry.Changeset, 1) require.Equal(t, "evm", entry.Changeset[0].Name) require.Equal(t, []byte("key"), entry.Changeset[0].Changeset.Pairs[0].Key) require.Equal(t, []byte("value"), entry.Changeset[0].Changeset.Pairs[0].Value) - // The end-of-block marker is folded into the block's single entry, not surfaced separately. ok, err = it.Next() require.NoError(t, err) require.False(t, ok) } -// TestConcurrentIterationDuringRotation hammers the writer with rotate-on-every-block churn while several -// iterators read concurrently. Each iterator seals the mutable file and snapshots its file set at creation on -// the writer goroutine, so every file it reads is sealed and immutable and can never be renamed or rewritten -// out from under an in-flight read; every iteration must be error-free and gap-free. -func TestConcurrentIterationDuringRotation(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.TargetFileSize = 1 // rotate (rename) after every block, maximizing the seal/rename churn - w := openWAL(t, cfg) +// TestIteratorCombinesMultipleWritesInOrder verifies that all changesets written for one block across several +// Write calls appear, in write order, in that block's single entry. +func TestIteratorCombinesMultipleWritesInOrder(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() - const totalBlocks = 300 - const readers = 4 - const iterationsPerReader = 40 + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) + require.NoError(t, w.Write(1, []*proto.NamedChangeSet{ + makeChangeSet("b", []byte("k2"), []byte("v2")), + makeChangeSet("c", []byte("k3"), []byte("v3")), + })) + require.NoError(t, w.SignalEndOfBlock()) + require.NoError(t, w.Flush()) - var wg sync.WaitGroup + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() - writeErr := make(chan error, 1) - wg.Add(1) - go func() { - defer wg.Done() - for block := uint64(1); block <= totalBlocks; block++ { - cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} - if err := w.Write(block, cs); err != nil { - writeErr <- err - return - } - if err := w.SignalEndOfBlock(); err != nil { - writeErr <- err - return - } - } - writeErr <- nil - }() + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) - readerErr := make(chan error, readers) - for r := 0; r < readers; r++ { - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < iterationsPerReader; i++ { - if err := drainContiguousFrom(w, 1); err != nil { - readerErr <- err - return - } - } - readerErr <- nil - }() - } + entry := it.Entry() + require.Equal(t, uint64(1), entry.BlockNumber) + // Three changesets total (1 from the first Write, 2 from the second), in write order. + require.Len(t, entry.Changeset, 3) + require.Equal(t, "a", entry.Changeset[0].Name) + require.Equal(t, "b", entry.Changeset[1].Name) + require.Equal(t, "c", entry.Changeset[2].Name) - wg.Wait() - require.NoError(t, <-writeErr) - for r := 0; r < readers; r++ { - require.NoError(t, <-readerErr) - } + ok, err = it.Next() + require.NoError(t, err) + require.False(t, ok) } -// drainContiguousFrom fully consumes an iterator anchored at start, verifying the yielded blocks form a -// gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not have -// produced start yet). Returns the first error encountered. -func drainContiguousFrom(w StateWAL, start uint64) error { - it, err := w.Iterator(start) - if err != nil { - return fmt.Errorf("create iterator: %w", err) - } - prev := start - 1 - for { - ok, err := it.Next() - if err != nil { - _ = it.Close() - return fmt.Errorf("next: %w", err) - } - if !ok { - break - } - b := it.Entry().BlockNumber - if b != prev+1 { - _ = it.Close() - return fmt.Errorf("non-contiguous iteration: got block %d after %d (start %d)", b, prev, start) - } - prev = b +func TestIteratorStopsBeforeIncompleteBlock(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 3; block++ { + writeBlock(t, w, block) } - return it.Close() + // Block 4 written but not ended: it was never appended, so it must not be yielded. + require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{4}, []byte{4})})) + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1)) } -// TestIteratorDoesNotSeePostConstructionBlocks pins down the snapshot contract: an iterator yields only -// blocks that were complete when it was created, never blocks written afterward. The setup makes the check -// deterministic (no timing race): one block per file plus a prefetch of 1 means the reader blocks on the full -// results channel after the first block and cannot reach later files until the consumer drains, which happens -// only after block 4 is written. Because Iterator() now seals the mutable file at creation, block 4 lands in a -// fresh file outside the snapshot and must not appear. +// TestIteratorDoesNotSeePostConstructionBlocks confirms the snapshot contract at the wrapper level: an +// iterator yields only blocks that were complete when it was created. func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.TargetFileSize = 1 - cfg.IteratorPrefetchSize = 1 - w := openWAL(t, cfg) + w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() for block := uint64(1); block <= 3; block++ { @@ -277,8 +123,7 @@ func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { defer func() { require.NoError(t, it.Close()) }() // Written after the iterator exists, before draining: must not be observed. - require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{4}, []byte{4})})) - require.NoError(t, w.SignalEndOfBlock()) + writeBlock(t, w, 4) require.NoError(t, w.Flush()) var got []uint64 @@ -292,78 +137,3 @@ func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { } require.Equal(t, []uint64{1, 2, 3}, got, "post-construction block 4 must not be iterated") } - -// TestIteratorSealPreservesInProgressBlock verifies the correctness subtlety of sealing at iterator creation: -// when a block is only partially written (several Writes, no end-of-block marker yet), sealing must capture the -// completed blocks for the snapshot AND carry the in-progress block forward without dropping any changeset -// already accepted by Write. -func TestIteratorSealPreservesInProgressBlock(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) // large target: everything begins in one mutable file - defer func() { require.NoError(t, w.Close()) }() - - // Block 1 complete. - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) - require.NoError(t, w.SignalEndOfBlock()) - // Block 2 partially written: one changeset, no end-of-block marker yet. - require.NoError(t, w.Write(2, []*proto.NamedChangeSet{makeChangeSet("b", []byte("k2"), []byte("v2"))})) - - // Opening the iterator seals block 1 and carries block 2's in-progress record into a fresh mutable file. - // The incomplete block 2 must not be yielded. - require.Equal(t, []uint64{1}, collectBlocks(t, w, 1)) - - // Finish block 2 with a second changeset, then end it. - require.NoError(t, w.Write(2, []*proto.NamedChangeSet{makeChangeSet("c", []byte("k3"), []byte("v3"))})) - require.NoError(t, w.SignalEndOfBlock()) - require.NoError(t, w.Flush()) - - // Block 2 must contain BOTH changesets, in write order — nothing lost to the mid-block seal. - it, err := w.Iterator(2) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - entry := it.Entry() - require.Equal(t, uint64(2), entry.BlockNumber) - require.Len(t, entry.Changeset, 2) - require.Equal(t, "b", entry.Changeset[0].Name) - require.Equal(t, "c", entry.Changeset[1].Name) - - ok, err = it.Next() - require.NoError(t, err) - require.False(t, ok) -} - -func TestIteratorCoalescesMultipleWritesInOrder(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{ - makeChangeSet("b", []byte("k2"), []byte("v2")), - makeChangeSet("c", []byte("k3"), []byte("v3")), - })) - require.NoError(t, w.SignalEndOfBlock()) - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - - entry := it.Entry() - require.Equal(t, uint64(1), entry.BlockNumber) - require.False(t, entry.EndOfBlock) - // Three changesets total (1 from the first Write, 2 from the second), concatenated in write order. - require.Len(t, entry.Changeset, 3) - require.Equal(t, "a", entry.Changeset[0].Name) - require.Equal(t, "b", entry.Changeset[1].Name) - require.Equal(t, "c", entry.Changeset[2].Name) - - ok, err = it.Next() - require.NoError(t, err) - require.False(t, ok) -} From 9e4df1bb2012b62078b86eaf38958acc293e3b2f Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 8 Jul 2026 08:57:17 -0500 Subject: [PATCH 13/44] create async serializing utility --- sei-db/seiwal/seiwal.go | 30 +- sei-db/seiwal/seiwal_config.go | 5 + sei-db/seiwal/seiwal_impl.go | 24 +- sei-db/seiwal/seiwal_impl_test.go | 18 +- sei-db/seiwal/seiwal_iterator.go | 2 +- sei-db/seiwal/seiwal_iterator_test.go | 2 +- sei-db/seiwal/seiwal_serializing.go | 365 ++++++++++++++++++ sei-db/seiwal/seiwal_serializing_test.go | 191 +++++++++ sei-db/state_db/statewal/state_wal_config.go | 1 + sei-db/state_db/statewal/state_wal_impl.go | 336 ++-------------- .../state_db/statewal/state_wal_iterator.go | 21 +- 11 files changed, 649 insertions(+), 346 deletions(-) create mode 100644 sei-db/seiwal/seiwal_serializing.go create mode 100644 sei-db/seiwal/seiwal_serializing_test.go diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index be18add286..3444a724d9 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -1,22 +1,20 @@ package seiwal -// WAL is a generic, index-keyed, append-only write-ahead log over opaque byte payloads. +// WAL is a generic, index-keyed, append-only write-ahead log over payloads of type T. // // Each record is tagged with a caller-provided monotonic index. The index is what makes garbage // collection ("drop everything below N"), iteration ("start at N"), and rollback ("drop everything -// above N") expressible without the WAL ever interpreting a payload. The WAL never inspects the bytes -// it stores; callers own all serialization. -type WAL interface { +// above N") expressible without the WAL ever interpreting a payload. +type WAL[T any] interface { // Append a record with the given index and payload. // // The index must be strictly greater than the index of the most recently appended record (indices - // need not be contiguous, but they must strictly increase). data may be empty; it is copied into the - // WAL's framing before this call returns, so the caller may reuse the buffer immediately. + // need not be contiguous, but they must strictly increase). // // This method only schedules the append; it does not block until the record is durable. Durability is // achieved by a subsequent Flush. - Append(index uint64, data []byte) error + Append(index uint64, data T) error // Flush blocks until all previously scheduled appends are durable. Flush() error @@ -48,14 +46,14 @@ type WAL interface { // start and the return of this call. Records appended before that instant are included; records // appended after it are not. For records appended concurrently with this call, whether they are // included is unspecified. - Iterator(startIndex uint64) (Iterator, error) + Iterator(startIndex uint64) (Iterator[T], error) // Close flushes pending appends, seals the current file, and releases resources. Close() error } // Iterator iterates over the records of a WAL in ascending index order. -type Iterator interface { +type Iterator[T any] interface { // Next advances the iterator to the next record. It returns false when iteration is complete (no more // records), and returns an error if advancing failed. After Next returns (false, nil), iteration is // complete; after it returns an error, the iterator must not be used further (other than Close). @@ -66,20 +64,8 @@ type Iterator interface { // // The returned payload must be treated as read-only and must not be modified. Callers that need to // retain or mutate the data must copy it first. - Entry() (index uint64, data []byte) + Entry() (index uint64, data T) // Close releases the resources held by the iterator. Close() error } - -// New opens (or creates) a WAL in the configured directory, recovering any files left behind by a -// previous session. -func New(config *Config) (WAL, error) { - return newWAL(config, nil) -} - -// NewWithRollback opens a WAL and deletes all records with an index greater than rollbackIndex before -// returning, so the WAL contains no record with an index greater than rollbackIndex. -func NewWithRollback(config *Config, rollbackIndex uint64) (WAL, error) { - return newWAL(config, &rollbackIndex) -} diff --git a/sei-db/seiwal/seiwal_config.go b/sei-db/seiwal/seiwal_config.go index 60e9d85a22..658ca084e7 100644 --- a/sei-db/seiwal/seiwal_config.go +++ b/sei-db/seiwal/seiwal_config.go @@ -14,6 +14,10 @@ type Config struct { // The size of the channel used to send framed records and control messages to the writer goroutine. WriteBufferSize uint + // The depth of the serialization request queue. Used only by the generic serializing WAL + // (NewGenericWAL); the byte-oriented engine ignores it. + SerializerBufferSize uint + // The size a WAL file may reach before it is sealed and a fresh one is opened. Rotation happens after a // record is appended, so a file may exceed this by the size of a single record — and because a record // is written atomically to a single file, a record larger than this threshold produces a file that @@ -35,6 +39,7 @@ func DefaultConfig(path string) *Config { return &Config{ Path: path, WriteBufferSize: 16, + SerializerBufferSize: 16, TargetFileSize: 64 * unit.MB, FsyncOnFlush: true, IteratorPrefetchSize: 32, diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 0cd25abfe3..1fb2a703c5 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -13,7 +13,7 @@ import ( "github.com/sei-protocol/seilog" ) -var _ WAL = (*walImpl)(nil) +var _ WAL[[]byte] = (*walImpl)(nil) var logger = seilog.NewLogger("db", "seiwal") @@ -88,12 +88,14 @@ type walImpl struct { // The hard-stop context the writer watches. Cancelled by fail() on a fatal error and by Close() once // everything has drained. - ctx context.Context + ctx context.Context + // Cancels ctx, tearing down the writer goroutine. cancel context.CancelFunc // A child of ctx that the writerChan producers watch, cancelled once the writer stops reading so an // in-flight or future push aborts rather than deadlocking. - senderCtx context.Context + senderCtx context.Context + // Cancels senderCtx. senderCancel context.CancelFunc // Tracks the writer goroutine so Close() can wait for it to exit. @@ -132,7 +134,19 @@ type walImpl struct { indexRefs map[uint64]int } -func newWAL(config *Config, rollbackThrough *uint64) (WAL, error) { +// NewWAL opens (or creates) a byte-oriented WAL in the configured directory, recovering any files left +// behind by a previous session. Operates on []byte payloads. +func NewWAL(config *Config) (WAL[[]byte], error) { + return newWAL(config, nil) +} + +// NewWALWithRollback opens a byte-oriented WAL and deletes all records with an index greater than +// rollbackIndex before returning, so the WAL contains no record with an index greater than rollbackIndex. +func NewWALWithRollback(config *Config, rollbackIndex uint64) (WAL[[]byte], error) { + return newWAL(config, &rollbackIndex) +} + +func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid WAL config: %w", err) } @@ -264,7 +278,7 @@ func (w *walImpl) Prune(lowestIndexToKeep uint64) error { // (see iteratorStartRequest): the writer flushes so all previously scheduled appends are visible, registers a // read lease so pruning cannot delete files out from under the iterator, and builds the iterator. The lease is // released by the iterator's Close. -func (w *walImpl) Iterator(startIndex uint64) (Iterator, error) { +func (w *walImpl) Iterator(startIndex uint64) (Iterator[[]byte], error) { reply := make(chan iteratorStartResponse, 1) if err := w.sendToWriter(iteratorStartRequest{startIndex: startIndex, reply: reply}); err != nil { return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 581b38a054..a13123c918 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -14,9 +14,9 @@ func testConfig(dir string) *Config { return DefaultConfig(dir) } -func openWAL(t *testing.T, cfg *Config) WAL { +func openWAL(t *testing.T, cfg *Config) WAL[[]byte] { t.Helper() - w, err := New(cfg) + w, err := NewWAL(cfg) require.NoError(t, err) return w } @@ -27,14 +27,14 @@ func recordPayload(index uint64) []byte { } // appendRecord appends a record with recordPayload(index) at the given index. -func appendRecord(t *testing.T, w WAL, index uint64) { +func appendRecord(t *testing.T, w WAL[[]byte], index uint64) { t.Helper() require.NoError(t, w.Append(index, recordPayload(index))) } // collectIndices iterates from start and returns the index of each record, verifying that indices are // strictly increasing and never below start. -func collectIndices(t *testing.T, w WAL, start uint64) []uint64 { +func collectIndices(t *testing.T, w WAL[[]byte], start uint64) []uint64 { t.Helper() it, err := w.Iterator(start) require.NoError(t, err) @@ -446,7 +446,7 @@ func TestScanRejectsGapInSealedFiles(t *testing.T) { victim := sealed[len(sealed)/2] require.NoError(t, os.Remove(filepath.Join(dir, sealedFileName(victim.fileSeq, victim.firstIndex, victim.lastIndex)))) - _, err = New(cfg) + _, err = NewWAL(cfg) require.Error(t, err) require.Contains(t, err.Error(), "not contiguous") } @@ -472,7 +472,7 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewWithRollback(cfg, 3) + w2, err := NewWALWithRollback(cfg, 3) require.NoError(t, err) defer func() { require.NoError(t, w2.Close()) }() @@ -494,7 +494,7 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewWithRollback(cfg, 3) + w2, err := NewWALWithRollback(cfg, 3) require.NoError(t, err) defer func() { require.NoError(t, w2.Close()) }() @@ -537,7 +537,7 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewWithRollback(cfg, 3) + w2, err := NewWALWithRollback(cfg, 3) require.NoError(t, err) require.NoError(t, w2.Close()) @@ -649,7 +649,7 @@ func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { require.NoError(t, w2.Close()) // The subsequent rollback completes cleanly, and a normal reopen sees the consistent rolled-back range. - w3, err := NewWithRollback(cfg, 3) + w3, err := NewWALWithRollback(cfg, 3) require.NoError(t, err) require.NoError(t, w3.Close()) diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index 1ad3762095..6da6b82f83 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -7,7 +7,7 @@ import ( "sync" ) -var _ Iterator = (*walIterator)(nil) +var _ Iterator[[]byte] = (*walIterator)(nil) // A record produced by the reader goroutine, or a terminal error. type iteratorResult struct { diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index be4790d5ae..975f75e583 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -203,7 +203,7 @@ func TestConcurrentIterationDuringRotation(t *testing.T) { // drainContiguousFrom fully consumes an iterator anchored at start, verifying the yielded indices form a // gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not have // produced start yet). Returns the first error encountered. -func drainContiguousFrom(w WAL, start uint64) error { +func drainContiguousFrom(w WAL[[]byte], start uint64) error { it, err := w.Iterator(start) if err != nil { return fmt.Errorf("create iterator: %w", err) diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go new file mode 100644 index 0000000000..fb77dbd2e3 --- /dev/null +++ b/sei-db/seiwal/seiwal_serializing.go @@ -0,0 +1,365 @@ +package seiwal + +import ( + "context" + "fmt" + "sync" + "sync/atomic" +) + +var _ WAL[[]byte] = (*serializingWAL[[]byte])(nil) + +// serAppend carries a framed-payload producer to the serializer goroutine. The closure captures the typed +// item so this message type stays non-generic — T never enters the channel's dynamic type, which keeps the +// serializer loop's type switch free of type parameters. +type serAppend struct { + index uint64 + serialize func() ([]byte, error) +} + +// serFlush asks the serializer goroutine to flush the inner WAL, signaling done when durable. +type serFlush struct { + done chan error +} + +// serBounds asks the serializer goroutine to report the inner WAL's stored index range. +type serBounds struct { + reply chan serBoundsResult +} + +// The index range (and any error) reported by the inner WAL's Bounds. +type serBoundsResult struct { + ok bool + first uint64 + last uint64 + err error +} + +// serPrune asks the serializer goroutine to prune the inner WAL below `through`. +type serPrune struct { + through uint64 +} + +// serIterator asks the serializer goroutine to create an inner iterator, ordered after every prior append. +type serIterator struct { + startIndex uint64 + reply chan serIteratorResult +} + +// The inner iterator (or an error) produced in response to a serIterator request. +type serIteratorResult struct { + it Iterator[[]byte] + err error +} + +// serClose asks the serializer goroutine to close the inner WAL and shut down, signaling done when closed. +type serClose struct { + done chan error +} + +// serializingWAL is a WAL[T] that serializes each payload to []byte on a background goroutine. +type serializingWAL[T any] struct { + // The inner byte-oriented WAL that framed records are delegated to. + inner WAL[[]byte] + + // Serialize a payload to bytes (run on the serializer goroutine) and deserialize it back (run inline in + // the iterator). + serialize func(T) ([]byte, error) + deserialize func([]byte) (T, error) + + // Caller entry points funnel through serializerChan as a single ordered stream to the serializer. + serializerChan chan any + + // The hard-stop context the serializer watches. Cancelled by fail() on a fatal error and by Close() once + // everything has drained. + ctx context.Context + // Cancels ctx, tearing down the serializer goroutine. + cancel context.CancelFunc + + // A child of ctx that the serializerChan producers watch, cancelled once the serializer stops reading so + // an in-flight or future push aborts rather than deadlocking. + senderCtx context.Context + // Cancels senderCtx. + senderCancel context.CancelFunc + + // Tracks the serializer goroutine so Close() can wait for it to exit. + wg sync.WaitGroup + + // Guarantees the Close() shutdown sequence runs at most once. + closeOnce sync.Once + + // Set by Close() so subsequent scheduling calls fail fast. + closed atomic.Bool + + // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). + asyncErr atomic.Pointer[error] +} + +// NewGenericWAL opens a WAL over payloads of type T that does serialization on a background goroutine. +func NewGenericWAL[T any]( + config *Config, + serialize func(T) ([]byte, error), + deserialize func([]byte) (T, error), +) (WAL[T], error) { + inner, err := NewWAL(config) + if err != nil { + return nil, fmt.Errorf("failed to open inner WAL: %w", err) + } + return newSerializingWAL(config, inner, serialize, deserialize), nil +} + +// NewGenericWALWithRollback is like NewGenericWAL but first rolls the inner WAL back so it contains no record +// with an index greater than rollbackIndex. +func NewGenericWALWithRollback[T any]( + config *Config, + rollbackIndex uint64, + serialize func(T) ([]byte, error), + deserialize func([]byte) (T, error), +) (WAL[T], error) { + inner, err := NewWALWithRollback(config, rollbackIndex) + if err != nil { + return nil, fmt.Errorf("failed to open inner WAL: %w", err) + } + return newSerializingWAL(config, inner, serialize, deserialize), nil +} + +func newSerializingWAL[T any]( + config *Config, + inner WAL[[]byte], + serialize func(T) ([]byte, error), + deserialize func([]byte) (T, error), +) *serializingWAL[T] { + ctx, cancel := context.WithCancel(context.Background()) + senderCtx, senderCancel := context.WithCancel(ctx) + + s := &serializingWAL[T]{ + inner: inner, + serialize: serialize, + deserialize: deserialize, + serializerChan: make(chan any, config.SerializerBufferSize), + ctx: ctx, + cancel: cancel, + senderCtx: senderCtx, + senderCancel: senderCancel, + } + + s.wg.Add(1) + go s.serializerLoop() + + return s +} + +// Append schedules a payload to be serialized and appended at the given index. +func (s *serializingWAL[T]) Append(index uint64, data T) error { + if s.closed.Load() { + return fmt.Errorf("WAL is closed") + } + req := serAppend{ + index: index, + serialize: func() ([]byte, error) { return s.serialize(data) }, + } + if err := s.submit(req); err != nil { + return fmt.Errorf("failed to schedule append for index %d: %w", index, err) + } + return nil +} + +// Flush blocks until all previously scheduled appends are durable. +func (s *serializingWAL[T]) Flush() error { + done := make(chan error, 1) + if err := s.submit(serFlush{done: done}); err != nil { + return fmt.Errorf("failed to schedule flush: %w", err) + } + select { + case err := <-done: + return err // already wrapped by the inner WAL, or nil on success + case <-s.ctx.Done(): + if err := s.asyncError(); err != nil { + return fmt.Errorf("flush aborted: %w", err) + } + return fmt.Errorf("flush aborted: %w", s.ctx.Err()) + } +} + +// Bounds reports the range of record indices stored in the WAL. +func (s *serializingWAL[T]) Bounds() (bool, uint64, uint64, error) { + reply := make(chan serBoundsResult, 1) + if err := s.submit(serBounds{reply: reply}); err != nil { + return false, 0, 0, fmt.Errorf("failed to schedule bounds query: %w", err) + } + select { + case r := <-reply: + if r.err != nil { + return false, 0, 0, fmt.Errorf("bounds query failed: %w", r.err) + } + return r.ok, r.first, r.last, nil + case <-s.ctx.Done(): + if err := s.asyncError(); err != nil { + return false, 0, 0, fmt.Errorf("bounds query aborted: %w", err) + } + return false, 0, 0, fmt.Errorf("bounds query aborted: %w", s.ctx.Err()) + } +} + +// Prune schedules removal of whole inner files below lowestIndexToKeep. It does not block on completion. +func (s *serializingWAL[T]) Prune(lowestIndexToKeep uint64) error { + if err := s.submit(serPrune{through: lowestIndexToKeep}); err != nil { + return fmt.Errorf("failed to schedule prune below index %d: %w", lowestIndexToKeep, err) + } + return nil +} + +// Iterator returns an iterator over the WAL starting at startIndex. Construction is ordered on the serializer +// goroutine after every prior append, so the iterator observes all previously scheduled appends. +func (s *serializingWAL[T]) Iterator(startIndex uint64) (Iterator[T], error) { + reply := make(chan serIteratorResult, 1) + if err := s.submit(serIterator{startIndex: startIndex, reply: reply}); err != nil { + return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) + } + select { + case r := <-reply: + if r.err != nil { + return nil, fmt.Errorf("failed to create iterator: %w", r.err) + } + return &serializingIterator[T]{inner: r.it, deserialize: s.deserialize}, nil + case <-s.ctx.Done(): + if err := s.asyncError(); err != nil { + return nil, fmt.Errorf("iterator creation aborted: %w", err) + } + return nil, fmt.Errorf("iterator creation aborted: %w", s.ctx.Err()) + } +} + +// Close flushes pending appends, closes the inner WAL, and releases resources. +func (s *serializingWAL[T]) Close() error { + var closeErr error + s.closeOnce.Do(func() { + s.closed.Store(true) + done := make(chan error, 1) + if err := s.submit(serClose{done: done}); err == nil { + select { + case closeErr = <-done: + case <-s.ctx.Done(): + } + } + s.wg.Wait() + s.cancel() + }) + if err := s.asyncError(); err != nil { + return fmt.Errorf("WAL closed with error: %w", err) + } + return closeErr // already wrapped by the inner WAL, or nil on a clean close +} + +// submit enqueues a message onto the serializer's input channel, aborting if the WAL is shutting down or has +// failed. +func (s *serializingWAL[T]) submit(msg any) error { + select { + case s.serializerChan <- msg: + return nil + case <-s.senderCtx.Done(): + if err := s.asyncError(); err != nil { + return fmt.Errorf("WAL failed: %w", err) + } + return fmt.Errorf("WAL is closed") + } +} + +// serializerLoop serializes each append's payload and delegates it to the inner WAL, handling control +// messages (flush, bounds, prune, iterator, close) in FIFO order relative to appends so they observe a +// consistent view. Runs on its own goroutine until close or a fatal error. +func (s *serializingWAL[T]) serializerLoop() { + defer s.wg.Done() + for { + var msg any + select { + case <-s.ctx.Done(): + return + case msg = <-s.serializerChan: + } + + switch m := msg.(type) { + case serAppend: + data, err := m.serialize() + if err != nil { + s.fail(fmt.Errorf("failed to serialize record for index %d: %w", m.index, err)) + return + } + if err := s.inner.Append(m.index, data); err != nil { + s.fail(fmt.Errorf("failed to append record for index %d: %w", m.index, err)) + return + } + case serFlush: + m.done <- s.inner.Flush() + case serBounds: + ok, first, last, err := s.inner.Bounds() + m.reply <- serBoundsResult{ok: ok, first: first, last: last, err: err} + case serPrune: + if err := s.inner.Prune(m.through); err != nil { + s.fail(fmt.Errorf("failed to prune below index %d: %w", m.through, err)) + return + } + case serIterator: + it, err := s.inner.Iterator(m.startIndex) + m.reply <- serIteratorResult{it: it, err: err} + case serClose: + m.done <- s.inner.Close() + // FIFO guarantees every prior append has been delegated. Forbid further pushes so any + // racing/future schedule aborts instead of deadlocking against the now-exiting serializer. + s.senderCancel() + return + } + } +} + +// fail records the first fatal background error and triggers shutdown of the pipeline. +func (s *serializingWAL[T]) fail(err error) { + s.asyncErr.CompareAndSwap(nil, &err) + s.cancel() + logger.Error("serializing WAL encountered a fatal error", "err", err) +} + +// asyncError returns the first fatal background error, or nil if none occurred. +func (s *serializingWAL[T]) asyncError() error { + if p := s.asyncErr.Load(); p != nil { + return *p + } + return nil +} + +var _ Iterator[[]byte] = (*serializingIterator[[]byte])(nil) + +// serializingIterator adapts an inner byte iterator to a typed iterator by running deserialize inline in Next. +type serializingIterator[T any] struct { + inner Iterator[[]byte] + deserialize func([]byte) (T, error) + index uint64 + entry T +} + +func (it *serializingIterator[T]) Next() (bool, error) { + ok, err := it.inner.Next() + if err != nil || !ok { + var zero T + it.entry = zero + return false, err + } + index, data := it.inner.Entry() + value, err := it.deserialize(data) + if err != nil { + var zero T + it.entry = zero + return false, fmt.Errorf("failed to deserialize record at index %d: %w", index, err) + } + it.index = index + it.entry = value + return true, nil +} + +func (it *serializingIterator[T]) Entry() (uint64, T) { + return it.index, it.entry +} + +func (it *serializingIterator[T]) Close() error { + return it.inner.Close() +} diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go new file mode 100644 index 0000000000..913a73f222 --- /dev/null +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -0,0 +1,191 @@ +package seiwal + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func stringSerialize(s string) ([]byte, error) { return []byte(s), nil } +func stringDeserialize(b []byte) (string, error) { return string(b), nil } + +func openStringWAL(t *testing.T, cfg *Config) WAL[string] { + t.Helper() + w, err := NewGenericWAL[string](cfg, stringSerialize, stringDeserialize) + require.NoError(t, err) + return w +} + +type indexedString struct { + index uint64 + value string +} + +func collectStrings(t *testing.T, w WAL[string], start uint64) []indexedString { + t.Helper() + it, err := w.Iterator(start) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + var out []indexedString + for { + ok, err := it.Next() + require.NoError(t, err) + if !ok { + break + } + index, value := it.Entry() + out = append(out, indexedString{index: index, value: value}) + } + return out +} + +func TestGenericWALRoundTrip(t *testing.T) { + w := openStringWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + require.NoError(t, w.Append(1, "one")) + require.NoError(t, w.Append(2, "two")) + require.NoError(t, w.Append(5, "five")) // non-contiguous index is allowed + require.NoError(t, w.Flush()) + + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(5), last) + + require.Equal(t, []indexedString{{1, "one"}, {2, "two"}, {5, "five"}}, collectStrings(t, w, 0)) + require.Equal(t, []indexedString{{2, "two"}, {5, "five"}}, collectStrings(t, w, 2)) +} + +func TestGenericWALReopen(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openStringWAL(t, cfg) + for i := uint64(1); i <= 3; i++ { + require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) + } + require.NoError(t, w.Flush()) + require.NoError(t, w.Close()) + + w2 := openStringWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + + ok, first, last, err := w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0)) +} + +func TestGenericWALPrune(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.TargetFileSize = 1 // one record per file so pruning drops whole files + + w := openStringWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for i := uint64(1); i <= 10; i++ { + require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) + } + require.NoError(t, w.Flush()) + + require.NoError(t, w.Prune(5)) + + ok, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), first) + require.Equal(t, uint64(10), last) +} + +func TestGenericWALRollback(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 + + w := openStringWAL(t, cfg) + for i := uint64(1); i <= 6; i++ { + require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) + } + require.NoError(t, w.Close()) + + w2, err := NewGenericWALWithRollback[string](cfg, 3, stringSerialize, stringDeserialize) + require.NoError(t, err) + defer func() { require.NoError(t, w2.Close()) }() + + ok, first, last, err := w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0)) +} + +func TestGenericWALSerializeErrorSurfaces(t *testing.T) { + serErr := errors.New("serialize boom") + serialize := func(s string) ([]byte, error) { + if s == "bad" { + return nil, serErr + } + return []byte(s), nil + } + w, err := NewGenericWAL[string](testConfig(t.TempDir()), serialize, stringDeserialize) + require.NoError(t, err) + + require.NoError(t, w.Append(1, "good")) + require.NoError(t, w.Append(2, "bad")) // async; the serialize failure tears the pipeline down + + // The fatal serialization error surfaces on the next synchronous operation. + require.Error(t, w.Flush()) + require.Error(t, w.Close()) +} + +func TestGenericWALDeserializeErrorSurfaces(t *testing.T) { + deErr := errors.New("deserialize boom") + deserialize := func(b []byte) (string, error) { + if string(b) == "poison" { + return "", deErr + } + return string(b), nil + } + w, err := NewGenericWAL[string](testConfig(t.TempDir()), stringSerialize, deserialize) + require.NoError(t, err) + defer func() { require.NoError(t, w.Close()) }() + + require.NoError(t, w.Append(1, "ok")) + require.NoError(t, w.Append(2, "poison")) + require.NoError(t, w.Flush()) + + it, err := w.Iterator(0) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + index, value := it.Entry() + require.Equal(t, uint64(1), index) + require.Equal(t, "ok", value) + + // The poison record fails to deserialize; the error surfaces from Next, not a clean EOF. + ok, err = it.Next() + require.Error(t, err) + require.False(t, ok) +} + +func TestGenericWALAppendOrdering(t *testing.T) { + w := openStringWAL(t, testConfig(t.TempDir())) + + require.NoError(t, w.Append(5, "five")) + require.NoError(t, w.Flush()) + // The inner byte engine enforces strictly-increasing indices; a stale index tears the pipeline down. + require.NoError(t, w.Append(4, "four")) // async, will fail on the goroutine + require.Error(t, w.Flush()) + // Close surfaces the same fatal error rather than succeeding. + require.Error(t, w.Close()) +} diff --git a/sei-db/state_db/statewal/state_wal_config.go b/sei-db/state_db/statewal/state_wal_config.go index bc329672b4..de77c0bcd3 100644 --- a/sei-db/state_db/statewal/state_wal_config.go +++ b/sei-db/state_db/statewal/state_wal_config.go @@ -54,6 +54,7 @@ func (c *Config) toSeiwalConfig() *seiwal.Config { return &seiwal.Config{ Path: c.Path, WriteBufferSize: c.WriteBufferSize, + SerializerBufferSize: c.RequestBufferSize, TargetFileSize: c.TargetFileSize, FsyncOnFlush: c.FsyncOnFlush, IteratorPrefetchSize: c.IteratorPrefetchSize, diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 5a30e89e65..047b19cffe 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -1,114 +1,25 @@ package statewal import ( - "context" "fmt" "sync" "sync/atomic" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/seiwal" - "github.com/sei-protocol/seilog" ) var _ StateWAL = (*stateWALImpl)(nil) -var logger = seilog.NewLogger("db", "state-db", "statewal") - -// changesetMsg carries one Write's changesets to the serializer goroutine to be marshaled and accumulated -// into the current block's buffer. -type changesetMsg struct { - blockNumber uint64 - cs []*proto.NamedChangeSet -} - -// endOfBlockMsg tells the serializer goroutine that the current block is complete: it appends the accumulated -// buffer to the underlying WAL as a single record and resets the buffer. -type endOfBlockMsg struct { - blockNumber uint64 -} - -// flushMsg asks the serializer goroutine to flush the underlying WAL, signaling done when durable. -type flushMsg struct { - done chan error -} - -// rangeMsg asks the serializer goroutine to report the stored block range. -type rangeMsg struct { - reply chan rangeReply -} - -// The block range (and any error) reported by GetStoredRange. -type rangeReply struct { - ok bool - start uint64 - end uint64 - err error -} - -// pruneMsg asks the serializer goroutine to prune the underlying WAL below `through`. -type pruneMsg struct { - through uint64 -} - -// iteratorMsg asks the serializer goroutine to create an iterator, so it is ordered after every prior write. -type iteratorMsg struct { - startBlock uint64 - reply chan iteratorReply -} - -// The iterator (or an error) produced in response to an iteratorMsg. -type iteratorReply struct { - iterator StateWALIterator - err error -} - -// closeMsg asks the serializer goroutine to close the underlying WAL and shut down, signaling done when closed. -type closeMsg struct { - done chan error -} - -// A state WAL implemented as a thin, block-aware wrapper over a generic seiwal.WAL. -// -// The wrapper owns the block write-ordering contract (Write/SignalEndOfBlock) and the mapping of a block's -// changesets to a single opaque WAL record: the block number becomes the record index, and the block's -// changesets (accumulated across one or more Write calls) become the record payload. A single serializer -// goroutine marshals changesets off the caller's critical path — the throughput-sensitive path — and appends -// one record per block at end-of-block. +// A WAL for storing state changesets by block number. type stateWALImpl struct { - // The configuration this WAL was opened with. Read-only after construction. - config *Config - - // The underlying generic write-ahead log. - wal seiwal.WAL + // The underlying generic WAL, keyed by block number, whose payload is a block's changesets. + wal seiwal.WAL[[]*proto.NamedChangeSet] - // Caller entry points funnel through serializerChan as a single ordered stream to the serializer. - serializerChan chan any - - // The hard-stop context the serializer watches. Cancelled by fail() on a fatal error and by Close() once - // everything has drained. - ctx context.Context - cancel context.CancelFunc - - // A child of ctx that the serializerChan producers watch, cancelled once the serializer stops reading so - // an in-flight or future push aborts rather than deadlocking. - senderCtx context.Context - senderCancel context.CancelFunc - - // Tracks the serializer goroutine so Close() can wait for it to exit. - wg sync.WaitGroup - - // Guarantees the Close() shutdown sequence runs at most once. - closeOnce sync.Once - - // Set by Close() so subsequent scheduling calls fail fast. + // Set by Close() so subsequent Write/SignalEndOfBlock calls fail fast. closed atomic.Bool - // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). - asyncErr atomic.Pointer[error] - - // Guards the write-ordering contract state below, which is read/written synchronously in Write and - // SignalEndOfBlock (not on the serializer goroutine). + // Guards the write-ordering contract state and the accumulation buffer below. mu sync.Mutex // The block number of the most recent Write or SignalEndOfBlock. currentBlock uint64 @@ -116,48 +27,36 @@ type stateWALImpl struct { currentBlockEnded bool // Whether any block has been observed (this session or recovered from disk). hasCurrentBlock bool + // The changesets accumulated for the current block across its Write calls, appended as one record at + // end-of-block. Ownership is handed to the WAL at end-of-block and a fresh buffer starts for the next + // block, so the serialization goroutine never races the wrapper over the backing array. + buf []*proto.NamedChangeSet } // New opens (or creates) a state WAL in the configured directory, recovering any files left behind by a // previous session. func New(config *Config) (StateWAL, error) { - return newStateWAL(config, nil) + wal, err := seiwal.NewGenericWAL[[]*proto.NamedChangeSet]( + config.toSeiwalConfig(), serializeChangesets, deserializeChangesets) + if err != nil { + return nil, fmt.Errorf("failed to open state WAL: %w", err) + } + return newStateWAL(wal) } // NewWithRollback opens a state WAL and deletes all data for blocks beyond rollbackBlockNumber before // returning, so the WAL contains no block greater than rollbackBlockNumber. func NewWithRollback(config *Config, rollbackBlockNumber uint64) (StateWAL, error) { - return newStateWAL(config, &rollbackBlockNumber) -} - -func newStateWAL(config *Config, rollbackThrough *uint64) (StateWAL, error) { - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("invalid state WAL config: %w", err) - } - - var wal seiwal.WAL - var err error - if rollbackThrough != nil { - wal, err = seiwal.NewWithRollback(config.toSeiwalConfig(), *rollbackThrough) - } else { - wal, err = seiwal.New(config.toSeiwalConfig()) - } + wal, err := seiwal.NewGenericWALWithRollback[[]*proto.NamedChangeSet]( + config.toSeiwalConfig(), rollbackBlockNumber, serializeChangesets, deserializeChangesets) if err != nil { - return nil, fmt.Errorf("failed to open underlying WAL: %w", err) + return nil, fmt.Errorf("failed to open state WAL: %w", err) } + return newStateWAL(wal) +} - ctx, cancel := context.WithCancel(context.Background()) - senderCtx, senderCancel := context.WithCancel(ctx) - - w := &stateWALImpl{ - config: config, - wal: wal, - serializerChan: make(chan any, config.RequestBufferSize), - ctx: ctx, - cancel: cancel, - senderCtx: senderCtx, - senderCancel: senderCancel, - } +func newStateWAL(wal seiwal.WAL[[]*proto.NamedChangeSet]) (StateWAL, error) { + w := &stateWALImpl{wal: wal} // Recover the write-ordering position from the highest block already on disk. ok, _, last, err := wal.Bounds() @@ -170,28 +69,24 @@ func newStateWAL(config *Config, rollbackThrough *uint64) (StateWAL, error) { w.currentBlockEnded = true w.hasCurrentBlock = true } - - w.wg.Add(1) - go w.serializerLoop() - return w, nil } -// Write schedules a set of changes for the given block number. +// Write accumulates a set of changes for the given block number in memory. func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { if w.closed.Load() { return fmt.Errorf("state WAL is closed") } + w.mu.Lock() + defer w.mu.Unlock() if err := w.enforceWriteOrdering(blockNumber); err != nil { return fmt.Errorf("write rejected: %w", err) } - if err := w.sendToSerializer(changesetMsg{blockNumber: blockNumber, cs: cs}); err != nil { - return fmt.Errorf("failed to schedule write for block %d: %w", blockNumber, err) - } + w.buf = append(w.buf, cs...) return nil } -// SignalEndOfBlock schedules the current block's accumulated changesets to be appended as a single record. +// SignalEndOfBlock appends the current block's accumulated changesets to the WAL as a single record. func (w *stateWALImpl) SignalEndOfBlock() error { if w.closed.Load() { return fmt.Errorf("state WAL is closed") @@ -204,20 +99,20 @@ func (w *stateWALImpl) SignalEndOfBlock() error { } blockNumber := w.currentBlock w.currentBlockEnded = true + changeset := w.buf + w.buf = nil // hand ownership to the WAL; the next block starts a fresh buffer w.mu.Unlock() - if err := w.sendToSerializer(endOfBlockMsg{blockNumber: blockNumber}); err != nil { - return fmt.Errorf("failed to schedule end-of-block for block %d: %w", blockNumber, err) + if err := w.wal.Append(blockNumber, changeset); err != nil { + return fmt.Errorf("failed to append block %d: %w", blockNumber, err) } return nil } // enforceWriteOrdering rejects a Write that violates the block-ordering rules (no decreasing block numbers; no // advancing to a new block before the current one is ended) and records the new position when it is allowed. +// The caller must hold w.mu. func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { - w.mu.Lock() - defer w.mu.Unlock() - if !w.hasCurrentBlock { w.currentBlock = blockNumber w.currentBlockEnded = false @@ -245,178 +140,31 @@ func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { // Flush blocks until all previously scheduled writes are durable. func (w *stateWALImpl) Flush() error { - done := make(chan error, 1) - if err := w.sendToSerializer(flushMsg{done: done}); err != nil { - return fmt.Errorf("failed to schedule flush: %w", err) - } - select { - case err := <-done: - return err // already wrapped by the underlying WAL, or nil on success - case <-w.ctx.Done(): - if err := w.asyncError(); err != nil { - return fmt.Errorf("flush aborted: %w", err) - } - return fmt.Errorf("flush aborted: %w", w.ctx.Err()) - } + return w.wal.Flush() } // GetStoredRange reports the range of complete blocks stored in the WAL. func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { - reply := make(chan rangeReply, 1) - if err := w.sendToSerializer(rangeMsg{reply: reply}); err != nil { - return false, 0, 0, fmt.Errorf("failed to schedule stored-range query: %w", err) - } - select { - case r := <-reply: - if r.err != nil { - return false, 0, 0, fmt.Errorf("stored-range query failed: %w", r.err) - } - return r.ok, r.start, r.end, nil - case <-w.ctx.Done(): - if err := w.asyncError(); err != nil { - return false, 0, 0, fmt.Errorf("stored-range query aborted: %w", err) - } - return false, 0, 0, fmt.Errorf("stored-range query aborted: %w", w.ctx.Err()) - } + return w.wal.Bounds() } // Prune schedules removal of whole underlying files below lowestBlockNumberToKeep. It does not block on // completion. func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { - if err := w.sendToSerializer(pruneMsg{through: lowestBlockNumberToKeep}); err != nil { - return fmt.Errorf("failed to schedule prune below block %d: %w", lowestBlockNumberToKeep, err) - } - return nil + return w.wal.Prune(lowestBlockNumberToKeep) } -// Iterator returns an iterator over the WAL starting at startingBlockNumber. Construction is ordered on the -// serializer goroutine after every prior write, so the iterator observes all previously scheduled writes. +// Iterator returns an iterator over the WAL starting at startingBlockNumber. func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (StateWALIterator, error) { - reply := make(chan iteratorReply, 1) - if err := w.sendToSerializer(iteratorMsg{startBlock: startingBlockNumber, reply: reply}); err != nil { - return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) - } - select { - case resp := <-reply: - if resp.err != nil { - return nil, fmt.Errorf("failed to create iterator: %w", resp.err) - } - return resp.iterator, nil - case <-w.ctx.Done(): - if err := w.asyncError(); err != nil { - return nil, fmt.Errorf("iterator creation aborted: %w", err) - } - return nil, fmt.Errorf("iterator creation aborted: %w", w.ctx.Err()) + inner, err := w.wal.Iterator(startingBlockNumber) + if err != nil { + return nil, err } + return newStateIterator(inner), nil } // Close flushes pending writes, closes the underlying WAL, and releases resources. func (w *stateWALImpl) Close() error { - var closeErr error - w.closeOnce.Do(func() { - w.closed.Store(true) - done := make(chan error, 1) - if err := w.sendToSerializer(closeMsg{done: done}); err == nil { - select { - case closeErr = <-done: - case <-w.ctx.Done(): - } - } - w.wg.Wait() - w.cancel() - }) - if err := w.asyncError(); err != nil { - return fmt.Errorf("state WAL closed with error: %w", err) - } - return closeErr // already wrapped by the underlying WAL, or nil on a clean close -} - -// sendToSerializer enqueues a message onto the serializer's input channel, aborting if the WAL is shutting -// down or has failed. -func (w *stateWALImpl) sendToSerializer(msg any) error { - select { - case w.serializerChan <- msg: - return nil - case <-w.senderCtx.Done(): - if err := w.asyncError(); err != nil { - return fmt.Errorf("state WAL failed: %w", err) - } - return fmt.Errorf("state WAL is closed") - } -} - -// serializerLoop marshals each block's changesets into a per-block buffer and, at end-of-block, appends the -// buffer to the underlying WAL as a single record. Control messages (flush, range, prune, iterator, close) are -// handled in FIFO order relative to writes so they observe a consistent view. Runs on its own goroutine until -// close or a fatal error. -func (w *stateWALImpl) serializerLoop() { - defer w.wg.Done() - - // The accumulated payload of the block currently being written, reused across blocks. - var buf []byte - - for { - var msg any - select { - case <-w.ctx.Done(): - return - case msg = <-w.serializerChan: - } - - switch m := msg.(type) { - case changesetMsg: - for _, ncs := range m.cs { - var err error - buf, err = appendChangeset(buf, ncs) - if err != nil { - w.fail(fmt.Errorf("failed to serialize changeset for block %d: %w", m.blockNumber, err)) - return - } - } - case endOfBlockMsg: - if err := w.wal.Append(m.blockNumber, buf); err != nil { - w.fail(fmt.Errorf("failed to append block %d: %w", m.blockNumber, err)) - return - } - buf = buf[:0] - case flushMsg: - m.done <- w.wal.Flush() - case rangeMsg: - ok, first, last, err := w.wal.Bounds() - m.reply <- rangeReply{ok: ok, start: first, end: last, err: err} - case pruneMsg: - if err := w.wal.Prune(m.through); err != nil { - w.fail(fmt.Errorf("failed to prune below block %d: %w", m.through, err)) - return - } - case iteratorMsg: - inner, err := w.wal.Iterator(m.startBlock) - if err != nil { - m.reply <- iteratorReply{err: err} - } else { - m.reply <- iteratorReply{iterator: newStateIterator(inner)} - } - case closeMsg: - m.done <- w.wal.Close() - // FIFO guarantees every prior write has been appended. Forbid further pushes so any - // racing/future schedule aborts instead of deadlocking against the now-exiting serializer. - w.senderCancel() - return - } - } -} - -// fail records the first fatal background error and triggers shutdown of the pipeline. -func (w *stateWALImpl) fail(err error) { - w.asyncErr.CompareAndSwap(nil, &err) - w.cancel() - logger.Error("state WAL encountered a fatal error", "err", err) -} - -// asyncError returns the first fatal background error, or nil if none occurred. -func (w *stateWALImpl) asyncError() error { - if p := w.asyncErr.Load(); p != nil { - return *p - } - return nil + w.closed.Store(true) + return w.wal.Close() } diff --git a/sei-db/state_db/statewal/state_wal_iterator.go b/sei-db/state_db/statewal/state_wal_iterator.go index 760e341c81..710fcb26c4 100644 --- a/sei-db/state_db/statewal/state_wal_iterator.go +++ b/sei-db/state_db/statewal/state_wal_iterator.go @@ -1,24 +1,22 @@ package statewal import ( - "fmt" - + "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/seiwal" ) var _ StateWALIterator = (*walIterator)(nil) -// walIterator adapts a generic seiwal.Iterator (which yields opaque byte payloads keyed by index) into a -// StateWALIterator (which yields decoded block entries). Each record's index is the block number and its -// payload is the block's serialized changesets; deserialization happens in Next so it can surface an error, -// and the decoded entry is cached for Entry. +// walIterator adapts a generic seiwal iterator (which yields a block's changesets keyed by block number) +// into a StateWALIterator (which yields decoded block entries). Deserialization is handled by the underlying +// generic iterator; this wrapper only repackages each (index, changesets) pair as an *Entry. type walIterator struct { - inner seiwal.Iterator + inner seiwal.Iterator[[]*proto.NamedChangeSet] entry *Entry } // newStateIterator wraps a generic WAL iterator as a state WAL iterator. -func newStateIterator(inner seiwal.Iterator) *walIterator { +func newStateIterator(inner seiwal.Iterator[[]*proto.NamedChangeSet]) *walIterator { return &walIterator{inner: inner} } @@ -32,12 +30,7 @@ func (it *walIterator) Next() (bool, error) { it.entry = nil return false, nil } - index, data := it.inner.Entry() - changeset, err := deserializeChangesets(data) - if err != nil { - it.entry = nil - return false, fmt.Errorf("failed to deserialize block %d: %w", index, err) - } + index, changeset := it.inner.Entry() it.entry = &Entry{BlockNumber: index, Changeset: changeset} return true, nil } From 47b5ac03a8870ecc92efe553042aa5de22fd007f Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 8 Jul 2026 09:31:19 -0500 Subject: [PATCH 14/44] iterate and improve --- sei-db/seiwal/metrics.go | 47 ++++++++++++++++++ sei-db/seiwal/seiwal_config.go | 35 ++++++++++---- sei-db/seiwal/seiwal_config_test.go | 18 +++++-- sei-db/seiwal/seiwal_impl.go | 46 ++++++++++++++++-- sei-db/seiwal/seiwal_impl_test.go | 17 ++++++- sei-db/seiwal/seiwal_serializing.go | 48 +++++++++++++++++-- sei-db/seiwal/seiwal_serializing_test.go | 15 ++++++ sei-db/state_db/statewal/state_wal.go | 33 ++++--------- sei-db/state_db/statewal/state_wal_config.go | 44 +++++++++++------ .../statewal/state_wal_config_test.go | 18 +++++-- sei-db/state_db/statewal/state_wal_impl.go | 11 ++--- .../state_db/statewal/state_wal_impl_test.go | 16 +++---- .../state_db/statewal/state_wal_iterator.go | 44 ----------------- .../statewal/state_wal_iterator_test.go | 27 ++++++----- ...al_entry.go => state_wal_serialization.go} | 42 +++++++--------- ...est.go => state_wal_serialization_test.go} | 26 +++++++--- 16 files changed, 320 insertions(+), 167 deletions(-) delete mode 100644 sei-db/state_db/statewal/state_wal_iterator.go rename sei-db/state_db/statewal/{state_wal_entry.go => state_wal_serialization.go} (63%) rename sei-db/state_db/statewal/{state_wal_entry_test.go => state_wal_serialization_test.go} (57%) diff --git a/sei-db/seiwal/metrics.go b/sei-db/seiwal/metrics.go index 90048b7bc8..b845fb599d 100644 --- a/sei-db/seiwal/metrics.go +++ b/sei-db/seiwal/metrics.go @@ -2,12 +2,18 @@ package seiwal import ( "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" + + commonmetrics "github.com/sei-protocol/sei-chain/sei-db/common/metrics" ) // The name of the OpenTelemetry meter for WAL metrics. const walMeterName = "seidb_seiwal" +// Instruments are shared process-wide (created once); individual WAL instances are distinguished by the +// "wal" attribute attached at each recording (see walNameAttr), mirroring LittDB's per-table labeling. This +// keeps metrics from multiple instances in one process from clobbering each other. var ( walMeter = otel.Meter(walMeterName) @@ -38,8 +44,49 @@ var ( metric.WithDescription("Number of WAL files removed by pruning"), metric.WithUnit("{count}"), )) + + // The time spent serializing a payload in the generic serializing WAL. + walSerializeDuration = must(walMeter.Float64Histogram( + "seiwal_serialize_duration_seconds", + metric.WithDescription("Time spent serializing a payload in the generic WAL"), + metric.WithUnit("s"), + metric.WithExplicitBucketBoundaries(commonmetrics.LatencyBuckets...), + )) + + // The number of payload bytes produced by serialization in the generic serializing WAL. + walSerializedBytes = must(walMeter.Int64Counter( + "seiwal_serialized_bytes", + metric.WithDescription("Number of payload bytes produced by serialization in the generic WAL"), + metric.WithUnit("By"), + )) + + // The number of serialization failures in the generic serializing WAL. + walSerializeErrors = must(walMeter.Int64Counter( + "seiwal_serialize_errors", + metric.WithDescription("Number of serialization failures in the generic WAL"), + metric.WithUnit("{count}"), + )) + + // The buffered depth of a WAL's internal channel, sampled periodically. + walQueueDepth = must(walMeter.Int64Gauge( + "seiwal_queue_depth", + metric.WithDescription("Buffered depth of a WAL internal channel, sampled periodically"), + metric.WithUnit("{count}"), + )) ) +// walNameAttr returns the measurement option that tags an observation with a WAL instance's name, so metrics +// from distinct instances in the same process remain distinguishable. +func walNameAttr(name string) metric.MeasurementOption { + return metric.WithAttributeSet(attribute.NewSet(attribute.String("wal", name))) +} + +// queueDepthAttrs tags a queue-depth observation with the WAL instance name and which internal channel +// ("writer" or "serializer") is being measured. +func queueDepthAttrs(name string, queue string) metric.MeasurementOption { + return metric.WithAttributeSet(attribute.NewSet(attribute.String("wal", name), attribute.String("queue", queue))) +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/sei-db/seiwal/seiwal_config.go b/sei-db/seiwal/seiwal_config.go index 658ca084e7..3487522e0d 100644 --- a/sei-db/seiwal/seiwal_config.go +++ b/sei-db/seiwal/seiwal_config.go @@ -2,15 +2,25 @@ package seiwal import ( "fmt" + "regexp" + "time" "github.com/sei-protocol/sei-chain/sei-db/common/unit" ) +// The permitted shape of a WAL instance name: it becomes a metric attribute value, so it is restricted to +// characters safe for label values. +var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + // Config configures a WAL. type Config struct { // The directory where the WAL writes its files. Path string + // A short identifier for this WAL instance, used to distinguish its metrics from those of other + // instances in the same process. Required; must match [a-zA-Z0-9_-]+. + Name string + // The size of the channel used to send framed records and control messages to the writer goroutine. WriteBufferSize uint @@ -32,17 +42,23 @@ type Config struct { // keeps the reader busy while the consumer processes records, which matters for startup replay speed. // Must be greater than 0. IteratorPrefetchSize uint + + // The interval at which the WAL samples the buffered depth of its internal channel into the + // seiwal_queue_depth gauge. Zero or negative disables sampling. + MetricsSampleInterval time.Duration } -// DefaultConfig returns a default WAL configuration. -func DefaultConfig(path string) *Config { +// DefaultConfig returns a default WAL configuration for the WAL at path, identified by name. +func DefaultConfig(path string, name string) *Config { return &Config{ - Path: path, - WriteBufferSize: 16, - SerializerBufferSize: 16, - TargetFileSize: 64 * unit.MB, - FsyncOnFlush: true, - IteratorPrefetchSize: 32, + Path: path, + Name: name, + WriteBufferSize: 16, + SerializerBufferSize: 16, + TargetFileSize: 64 * unit.MB, + FsyncOnFlush: true, + IteratorPrefetchSize: 32, + MetricsSampleInterval: 15 * time.Second, } } @@ -51,6 +67,9 @@ func (c *Config) Validate() error { if c.Path == "" { return fmt.Errorf("path is required") } + if !nameRegex.MatchString(c.Name) { + return fmt.Errorf("name %q is required and must match %s", c.Name, nameRegex.String()) + } if c.TargetFileSize == 0 { // A zero target would seal and rotate a fresh file after every single record. return fmt.Errorf("target file size must be greater than 0") diff --git a/sei-db/seiwal/seiwal_config_test.go b/sei-db/seiwal/seiwal_config_test.go index cd426f640f..281f5ffbac 100644 --- a/sei-db/seiwal/seiwal_config_test.go +++ b/sei-db/seiwal/seiwal_config_test.go @@ -8,22 +8,32 @@ import ( func TestConfigValidate(t *testing.T) { t.Run("default config is valid", func(t *testing.T) { - require.NoError(t, DefaultConfig("/tmp/wal").Validate()) + require.NoError(t, DefaultConfig("/tmp/wal", "test").Validate()) }) t.Run("empty path is rejected", func(t *testing.T) { - cfg := DefaultConfig("") + cfg := DefaultConfig("", "test") + require.Error(t, cfg.Validate()) + }) + + t.Run("empty name is rejected", func(t *testing.T) { + cfg := DefaultConfig("/tmp/wal", "") + require.Error(t, cfg.Validate()) + }) + + t.Run("malformed name is rejected", func(t *testing.T) { + cfg := DefaultConfig("/tmp/wal", "bad name!") require.Error(t, cfg.Validate()) }) t.Run("zero target file size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal") + cfg := DefaultConfig("/tmp/wal", "test") cfg.TargetFileSize = 0 require.Error(t, cfg.Validate()) }) t.Run("zero iterator prefetch size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal") + cfg := DefaultConfig("/tmp/wal", "test") cfg.IteratorPrefetchSize = 0 require.Error(t, cfg.Validate()) }) diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 1fb2a703c5..94bcdd4084 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -8,6 +8,9 @@ import ( "sort" "sync" "sync/atomic" + "time" + + "go.opentelemetry.io/otel/metric" "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" "github.com/sei-protocol/seilog" @@ -82,6 +85,9 @@ type walImpl struct { // The configuration this WAL was opened with. Read-only after construction. config *Config + // The measurement option tagging this instance's metrics with its name. Read-only after construction. + metricAttrs metric.MeasurementOption + // Callers funnel framed records and control messages through writerChan as a single ordered stream to // the writer goroutine. writerChan chan any @@ -98,9 +104,12 @@ type walImpl struct { // Cancels senderCtx. senderCancel context.CancelFunc - // Tracks the writer goroutine so Close() can wait for it to exit. + // Tracks the writer and queue-depth sampler goroutines so Close() can wait for them to exit. wg sync.WaitGroup + // Closed by Close() to stop the queue-depth sampler goroutine. + samplerStop chan struct{} + // Guarantees the Close() shutdown sequence runs at most once. closeOnce sync.Once @@ -187,6 +196,7 @@ func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { w := &walImpl{ config: config, + metricAttrs: walNameAttr(config.Name), writerChan: make(chan any, config.WriteBufferSize), ctx: ctx, cancel: cancel, @@ -196,6 +206,7 @@ func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { nextFileSeq: nextFileSeq + 1, sealedFiles: sealedFiles, indexRefs: make(map[uint64]int), + samplerStop: make(chan struct{}), } // Recover the append-ordering position from the highest index already on disk. if r := w.bounds(); r.ok { @@ -206,9 +217,33 @@ func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { w.wg.Add(1) go w.writerLoop() + if config.MetricsSampleInterval > 0 { + w.wg.Add(1) + go w.sampleQueueDepth(config.MetricsSampleInterval) + } + return w, nil } +// sampleQueueDepth periodically records the writer channel's buffered depth until Close stops it (samplerStop) +// or a fatal shutdown cancels ctx. +func (w *walImpl) sampleQueueDepth(interval time.Duration) { + defer w.wg.Done() + attrs := queueDepthAttrs(w.config.Name, "writer") + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-w.ctx.Done(): + return + case <-w.samplerStop: + return + case <-ticker.C: + walQueueDepth.Record(w.ctx, int64(len(w.writerChan)), attrs) + } + } +} + // Append frames a record and schedules it for the writer, after enforcing that indices strictly increase. func (w *walImpl) Append(index uint64, data []byte) error { if w.closed.Load() { @@ -307,6 +342,7 @@ func (w *walImpl) Close() error { var closeErr error w.closeOnce.Do(func() { w.closed.Store(true) + close(w.samplerStop) // stop the queue-depth sampler before waiting for goroutines done := make(chan error, 1) if err := w.sendToWriter(closeRequest{done: done}); err == nil { select { @@ -385,8 +421,8 @@ func (w *walImpl) appendRecord(m dataToBeWritten) error { if err := w.mutableFile.writeRecord(m.record, m.index); err != nil { return fmt.Errorf("failed to append record for index %d: %w", m.index, err) } - walBytesWritten.Add(w.ctx, int64(len(m.record))) - walRecordsWritten.Add(w.ctx, 1) + walBytesWritten.Add(w.ctx, int64(len(m.record)), w.metricAttrs) + walRecordsWritten.Add(w.ctx, 1, w.metricAttrs) if w.mutableFile.size >= uint64(w.config.TargetFileSize) { if err := w.rotate(); err != nil { @@ -408,7 +444,7 @@ func (w *walImpl) rotate() error { return fmt.Errorf("failed to seal WAL file during rotation: %w", err) } w.sealedFiles.PushBack(&sealedFileInfo{fileSeq: fileSeq, name: sealedName, firstIndex: first, lastIndex: last}) - walFilesSealed.Add(w.ctx, 1) + walFilesSealed.Add(w.ctx, 1, w.metricAttrs) mutable, err := newWalFile(w.config.Path, w.nextFileSeq) if err != nil { @@ -453,7 +489,7 @@ func (w *walImpl) pruneSealedFiles(pruneThrough uint64) error { return fmt.Errorf("failed to fsync directory after pruning %s: %w", path, err) } w.sealedFiles.PopFront() - walFilesPruned.Add(w.ctx, 1) + walFilesPruned.Add(w.ctx, 1, w.metricAttrs) } return nil } diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index a13123c918..5865ec7f91 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -6,12 +6,27 @@ import ( "path/filepath" "sort" "testing" + "time" "github.com/stretchr/testify/require" ) +// TestQueueDepthSamplerRunsAndStops exercises the queue-depth sampler goroutine on a tiny interval: it must +// sample the writer channel concurrently with appends (validated by the race detector) and shut down cleanly +// on Close. +func TestQueueDepthSamplerRunsAndStops(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.MetricsSampleInterval = time.Millisecond + w := openWAL(t, cfg) + for index := uint64(1); index <= 300; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + require.NoError(t, w.Close()) +} + func testConfig(dir string) *Config { - return DefaultConfig(dir) + return DefaultConfig(dir, "test") } func openWAL(t *testing.T, cfg *Config) WAL[[]byte] { diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index fb77dbd2e3..3219fa00d5 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -5,6 +5,9 @@ import ( "fmt" "sync" "sync/atomic" + "time" + + "go.opentelemetry.io/otel/metric" ) var _ WAL[[]byte] = (*serializingWAL[[]byte])(nil) @@ -62,11 +65,14 @@ type serializingWAL[T any] struct { // The inner byte-oriented WAL that framed records are delegated to. inner WAL[[]byte] - // Serialize a payload to bytes (run on the serializer goroutine) and deserialize it back (run inline in - // the iterator). - serialize func(T) ([]byte, error) + // Serializes a payload to bytes; runs on the serializer goroutine. + serialize func(T) ([]byte, error) + // Deserializes stored bytes back to a payload; runs inline in the iterator. deserialize func([]byte) (T, error) + // The measurement option tagging this instance's metrics with its name. Read-only after construction. + metricAttrs metric.MeasurementOption + // Caller entry points funnel through serializerChan as a single ordered stream to the serializer. serializerChan chan any @@ -82,9 +88,12 @@ type serializingWAL[T any] struct { // Cancels senderCtx. senderCancel context.CancelFunc - // Tracks the serializer goroutine so Close() can wait for it to exit. + // Tracks the serializer and queue-depth sampler goroutines so Close() can wait for them to exit. wg sync.WaitGroup + // Closed by Close() to stop the queue-depth sampler goroutine. + samplerStop chan struct{} + // Guarantees the Close() shutdown sequence runs at most once. closeOnce sync.Once @@ -136,19 +145,45 @@ func newSerializingWAL[T any]( inner: inner, serialize: serialize, deserialize: deserialize, + metricAttrs: walNameAttr(config.Name), serializerChan: make(chan any, config.SerializerBufferSize), ctx: ctx, cancel: cancel, senderCtx: senderCtx, senderCancel: senderCancel, + samplerStop: make(chan struct{}), } s.wg.Add(1) go s.serializerLoop() + if config.MetricsSampleInterval > 0 { + s.wg.Add(1) + go s.sampleQueueDepth(config.Name, config.MetricsSampleInterval) + } + return s } +// sampleQueueDepth periodically records the serializer channel's buffered depth until Close stops it +// (samplerStop) or a fatal shutdown cancels ctx. +func (s *serializingWAL[T]) sampleQueueDepth(name string, interval time.Duration) { + defer s.wg.Done() + attrs := queueDepthAttrs(name, "serializer") + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case <-s.samplerStop: + return + case <-ticker.C: + walQueueDepth.Record(s.ctx, int64(len(s.serializerChan)), attrs) + } + } +} + // Append schedules a payload to be serialized and appended at the given index. func (s *serializingWAL[T]) Append(index uint64, data T) error { if s.closed.Load() { @@ -235,6 +270,7 @@ func (s *serializingWAL[T]) Close() error { var closeErr error s.closeOnce.Do(func() { s.closed.Store(true) + close(s.samplerStop) // stop the queue-depth sampler before waiting for goroutines done := make(chan error, 1) if err := s.submit(serClose{done: done}); err == nil { select { @@ -280,11 +316,15 @@ func (s *serializingWAL[T]) serializerLoop() { switch m := msg.(type) { case serAppend: + start := time.Now() data, err := m.serialize() if err != nil { + walSerializeErrors.Add(s.ctx, 1, s.metricAttrs) s.fail(fmt.Errorf("failed to serialize record for index %d: %w", m.index, err)) return } + walSerializeDuration.Record(s.ctx, time.Since(start).Seconds(), s.metricAttrs) + walSerializedBytes.Add(s.ctx, int64(len(data)), s.metricAttrs) if err := s.inner.Append(m.index, data); err != nil { s.fail(fmt.Errorf("failed to append record for index %d: %w", m.index, err)) return diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go index 913a73f222..c81a6a7e73 100644 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -4,10 +4,25 @@ import ( "errors" "fmt" "testing" + "time" "github.com/stretchr/testify/require" ) +// TestGenericWALQueueDepthSampler exercises the queue-depth samplers (both the serializing layer's and the +// inner byte engine's) on a tiny interval, validating concurrent sampling under the race detector and a clean +// shutdown on Close. +func TestGenericWALQueueDepthSampler(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.MetricsSampleInterval = time.Millisecond + w := openStringWAL(t, cfg) + for i := uint64(1); i <= 300; i++ { + require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) + } + require.NoError(t, w.Flush()) + require.NoError(t, w.Close()) +} + func stringSerialize(s string) ([]byte, error) { return []byte(s), nil } func stringDeserialize(b []byte) (string, error) { return string(b), nil } diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index a4e2365a70..588542f6bb 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -1,6 +1,9 @@ package statewal -import "github.com/sei-protocol/sei-chain/sei-db/proto" +import ( + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/seiwal" +) // A WAL for state. type StateWAL interface { @@ -62,29 +65,13 @@ type StateWAL interface { // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the // start and the return of this call. Data written before that instant is included; data written after it // is not. For data written concurrently with this call, whether it is included is unspecified. - Iterator(startingBlockNumber uint64) (StateWALIterator, error) - - // Close the WAL, flushing any pending writes and releasing resources. - Close() error -} - -// Iterates over data in a state WAL, in ascending block order, yielding one entry per block. All changesets -// written for a block (across one or more Write calls) are combined, in write order, into that block's single -// entry. Blocks that were never ended with SignalEndOfBlock are not yielded. -type StateWALIterator interface { - // Next advances the iterator to the next block. It returns false when iteration is complete (no more - // blocks), and returns an error if advancing failed. After Next returns (false, nil), iteration is - // complete; after it returns an error, the iterator must not be used further (other than Close). - Next() (bool, error) - - // Entry returns the combined entry for the block at the iterator's current position. It is only valid to - // call Entry after Next has returned (true, nil). // - // The returned entry, and every byte slice reachable through it (changeset keys and values), must be - // treated as read-only and must not be modified. Callers that need to retain or mutate the data must - // copy it first. - Entry() *Entry + // The iterator yields one entry per block in ascending block order. Its Entry() returns (blockNumber, + // changesets), where changesets are all the changes written for that block (across one or more Write + // calls) combined in write order. Blocks that were never ended with SignalEndOfBlock are not yielded. + // The returned changesets, and every byte slice reachable through them, must be treated as read-only. + Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) - // Close releases the resources held by the iterator. + // Close the WAL, flushing any pending writes and releasing resources. Close() error } diff --git a/sei-db/state_db/statewal/state_wal_config.go b/sei-db/state_db/statewal/state_wal_config.go index de77c0bcd3..7b5bfa1700 100644 --- a/sei-db/state_db/statewal/state_wal_config.go +++ b/sei-db/state_db/statewal/state_wal_config.go @@ -1,6 +1,8 @@ package statewal import ( + "time" + "github.com/sei-protocol/sei-chain/sei-db/seiwal" ) @@ -9,6 +11,10 @@ type Config struct { // The directory where the WAL writes its files. Path string + // A short identifier for this WAL instance, used to distinguish its metrics from those of other + // instances in the same process. Required; must match [a-zA-Z0-9_-]+. + Name string + // The size of the channel used to send work from the caller to the serialization goroutine. RequestBufferSize uint @@ -29,18 +35,24 @@ type Config struct { // keeps the reader busy while the consumer processes blocks, which matters for startup replay speed. // Must be greater than 0. IteratorPrefetchSize uint + + // The interval at which the underlying WAL samples the buffered depth of its internal channels into the + // seiwal_queue_depth gauge. Zero or negative disables sampling. + MetricsSampleInterval time.Duration } -// Constructor for a default state WAL configuration. -func DefaultConfig(path string) *Config { - s := seiwal.DefaultConfig(path) +// Constructor for a default state WAL configuration for the WAL at path, identified by name. +func DefaultConfig(path string, name string) *Config { + s := seiwal.DefaultConfig(path, name) return &Config{ - Path: path, - RequestBufferSize: 16, - WriteBufferSize: s.WriteBufferSize, - TargetFileSize: s.TargetFileSize, - FsyncOnFlush: s.FsyncOnFlush, - IteratorPrefetchSize: s.IteratorPrefetchSize, + Path: path, + Name: name, + RequestBufferSize: 16, + WriteBufferSize: s.WriteBufferSize, + TargetFileSize: s.TargetFileSize, + FsyncOnFlush: s.FsyncOnFlush, + IteratorPrefetchSize: s.IteratorPrefetchSize, + MetricsSampleInterval: s.MetricsSampleInterval, } } @@ -52,11 +64,13 @@ func (c *Config) Validate() error { // toSeiwalConfig maps this configuration onto the underlying generic WAL's configuration. func (c *Config) toSeiwalConfig() *seiwal.Config { return &seiwal.Config{ - Path: c.Path, - WriteBufferSize: c.WriteBufferSize, - SerializerBufferSize: c.RequestBufferSize, - TargetFileSize: c.TargetFileSize, - FsyncOnFlush: c.FsyncOnFlush, - IteratorPrefetchSize: c.IteratorPrefetchSize, + Path: c.Path, + Name: c.Name, + WriteBufferSize: c.WriteBufferSize, + SerializerBufferSize: c.RequestBufferSize, + TargetFileSize: c.TargetFileSize, + FsyncOnFlush: c.FsyncOnFlush, + IteratorPrefetchSize: c.IteratorPrefetchSize, + MetricsSampleInterval: c.MetricsSampleInterval, } } diff --git a/sei-db/state_db/statewal/state_wal_config_test.go b/sei-db/state_db/statewal/state_wal_config_test.go index e9966ced7c..4d28024e7b 100644 --- a/sei-db/state_db/statewal/state_wal_config_test.go +++ b/sei-db/state_db/statewal/state_wal_config_test.go @@ -8,22 +8,32 @@ import ( func TestConfigValidate(t *testing.T) { t.Run("default config is valid", func(t *testing.T) { - require.NoError(t, DefaultConfig("/tmp/wal").Validate()) + require.NoError(t, DefaultConfig("/tmp/wal", "test").Validate()) }) t.Run("empty path is rejected", func(t *testing.T) { - cfg := DefaultConfig("") + cfg := DefaultConfig("", "test") + require.Error(t, cfg.Validate()) + }) + + t.Run("empty name is rejected", func(t *testing.T) { + cfg := DefaultConfig("/tmp/wal", "") + require.Error(t, cfg.Validate()) + }) + + t.Run("malformed name is rejected", func(t *testing.T) { + cfg := DefaultConfig("/tmp/wal", "bad name!") require.Error(t, cfg.Validate()) }) t.Run("zero target file size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal") + cfg := DefaultConfig("/tmp/wal", "test") cfg.TargetFileSize = 0 require.Error(t, cfg.Validate()) }) t.Run("zero iterator prefetch size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal") + cfg := DefaultConfig("/tmp/wal", "test") cfg.IteratorPrefetchSize = 0 require.Error(t, cfg.Validate()) }) diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 047b19cffe..11e663e3e6 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -154,13 +154,10 @@ func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { return w.wal.Prune(lowestBlockNumberToKeep) } -// Iterator returns an iterator over the WAL starting at startingBlockNumber. -func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (StateWALIterator, error) { - inner, err := w.wal.Iterator(startingBlockNumber) - if err != nil { - return nil, err - } - return newStateIterator(inner), nil +// Iterator returns an iterator over the WAL starting at startingBlockNumber. It yields (blockNumber, +// changesets) directly from the underlying generic WAL. +func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { + return w.wal.Iterator(startingBlockNumber) } // Close flushes pending writes, closes the underlying WAL, and releases resources. diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go index 12d861baf3..6a20f6da38 100644 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ b/sei-db/state_db/statewal/state_wal_impl_test.go @@ -8,7 +8,7 @@ import ( ) func testConfig(dir string) *Config { - return DefaultConfig(dir) + return DefaultConfig(dir, "test") } func openWAL(t *testing.T, cfg *Config) StateWAL { @@ -41,12 +41,12 @@ func collectBlocks(t *testing.T, w StateWAL, start uint64) []uint64 { if !ok { break } - entry := it.Entry() - require.GreaterOrEqual(t, entry.BlockNumber, start) + blockNumber, _ := it.Entry() + require.GreaterOrEqual(t, blockNumber, start) if len(blocks) > 0 { - require.Greater(t, entry.BlockNumber, blocks[len(blocks)-1]) + require.Greater(t, blockNumber, blocks[len(blocks)-1]) } - blocks = append(blocks, entry.BlockNumber) + blocks = append(blocks, blockNumber) } return blocks } @@ -180,9 +180,9 @@ func TestEmptyChangesetBlockIsStored(t *testing.T) { ok, err = it.Next() require.NoError(t, err) require.True(t, ok) - entry := it.Entry() - require.Equal(t, uint64(1), entry.BlockNumber) - require.Empty(t, entry.Changeset) + blockNumber, changeset := it.Entry() + require.Equal(t, uint64(1), blockNumber) + require.Empty(t, changeset) } func TestPruneDropsOldBlocks(t *testing.T) { diff --git a/sei-db/state_db/statewal/state_wal_iterator.go b/sei-db/state_db/statewal/state_wal_iterator.go deleted file mode 100644 index 710fcb26c4..0000000000 --- a/sei-db/state_db/statewal/state_wal_iterator.go +++ /dev/null @@ -1,44 +0,0 @@ -package statewal - -import ( - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/seiwal" -) - -var _ StateWALIterator = (*walIterator)(nil) - -// walIterator adapts a generic seiwal iterator (which yields a block's changesets keyed by block number) -// into a StateWALIterator (which yields decoded block entries). Deserialization is handled by the underlying -// generic iterator; this wrapper only repackages each (index, changesets) pair as an *Entry. -type walIterator struct { - inner seiwal.Iterator[[]*proto.NamedChangeSet] - entry *Entry -} - -// newStateIterator wraps a generic WAL iterator as a state WAL iterator. -func newStateIterator(inner seiwal.Iterator[[]*proto.NamedChangeSet]) *walIterator { - return &walIterator{inner: inner} -} - -func (it *walIterator) Next() (bool, error) { - ok, err := it.inner.Next() - if err != nil { - it.entry = nil - return false, err - } - if !ok { - it.entry = nil - return false, nil - } - index, changeset := it.inner.Entry() - it.entry = &Entry{BlockNumber: index, Changeset: changeset} - return true, nil -} - -func (it *walIterator) Entry() *Entry { - return it.entry -} - -func (it *walIterator) Close() error { - return it.inner.Close() -} diff --git a/sei-db/state_db/statewal/state_wal_iterator_test.go b/sei-db/state_db/statewal/state_wal_iterator_test.go index 2126ff96cf..47bd423d1f 100644 --- a/sei-db/state_db/statewal/state_wal_iterator_test.go +++ b/sei-db/state_db/statewal/state_wal_iterator_test.go @@ -47,12 +47,12 @@ func TestIteratorYieldsChangesetContents(t *testing.T) { ok, err := it.Next() require.NoError(t, err) require.True(t, ok) - entry := it.Entry() - require.Equal(t, uint64(1), entry.BlockNumber) - require.Len(t, entry.Changeset, 1) - require.Equal(t, "evm", entry.Changeset[0].Name) - require.Equal(t, []byte("key"), entry.Changeset[0].Changeset.Pairs[0].Key) - require.Equal(t, []byte("value"), entry.Changeset[0].Changeset.Pairs[0].Value) + blockNumber, changeset := it.Entry() + require.Equal(t, uint64(1), blockNumber) + require.Len(t, changeset, 1) + require.Equal(t, "evm", changeset[0].Name) + require.Equal(t, []byte("key"), changeset[0].Changeset.Pairs[0].Key) + require.Equal(t, []byte("value"), changeset[0].Changeset.Pairs[0].Value) ok, err = it.Next() require.NoError(t, err) @@ -81,13 +81,13 @@ func TestIteratorCombinesMultipleWritesInOrder(t *testing.T) { require.NoError(t, err) require.True(t, ok) - entry := it.Entry() - require.Equal(t, uint64(1), entry.BlockNumber) + blockNumber, changeset := it.Entry() + require.Equal(t, uint64(1), blockNumber) // Three changesets total (1 from the first Write, 2 from the second), in write order. - require.Len(t, entry.Changeset, 3) - require.Equal(t, "a", entry.Changeset[0].Name) - require.Equal(t, "b", entry.Changeset[1].Name) - require.Equal(t, "c", entry.Changeset[2].Name) + require.Len(t, changeset, 3) + require.Equal(t, "a", changeset[0].Name) + require.Equal(t, "b", changeset[1].Name) + require.Equal(t, "c", changeset[2].Name) ok, err = it.Next() require.NoError(t, err) @@ -133,7 +133,8 @@ func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { if !ok { break } - got = append(got, it.Entry().BlockNumber) + blockNumber, _ := it.Entry() + got = append(got, blockNumber) } require.Equal(t, []uint64{1, 2, 3}, got, "post-construction block 4 must not be iterated") } diff --git a/sei-db/state_db/statewal/state_wal_entry.go b/sei-db/state_db/statewal/state_wal_serialization.go similarity index 63% rename from sei-db/state_db/statewal/state_wal_entry.go rename to sei-db/state_db/statewal/state_wal_serialization.go index 78d2533451..8a149c8053 100644 --- a/sei-db/state_db/statewal/state_wal_entry.go +++ b/sei-db/state_db/statewal/state_wal_serialization.go @@ -7,23 +7,9 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" ) -// A decoded block entry from the state WAL: a block number and the changesets written for that block. -type Entry struct { - - // The block number associated with this entry. - BlockNumber uint64 - - // The changesets associated with this block, in write order. - Changeset []*proto.NamedChangeSet -} - -// NewEntry constructs an entry for the given block number and changesets. -func NewEntry(blockNumber uint64, changeset []*proto.NamedChangeSet) *Entry { - return &Entry{ - BlockNumber: blockNumber, - Changeset: changeset, - } -} +// The version byte prefixed to every serialized changeset payload. Bumped if the changeset encoding changes, +// so deserialization can detect and reject an unknown format rather than misparsing it. +const changesetFormatVersion = byte(1) // appendChangeset appends the framing [uvarint marshaled length][marshaled NamedChangeSet] for ncs to buf and // returns the extended buffer. This is the incremental unit the serializer goroutine accumulates across the @@ -43,10 +29,11 @@ func appendChangeset(buf []byte, ncs *proto.NamedChangeSet) ([]byte, error) { return buf, nil } -// serializeChangesets encodes a changeset list as the concatenation ([uvarint length][marshaled])* — the -// payload of a single block's WAL record. The block number is not encoded: it is the WAL record's index. +// serializeChangesets encodes a changeset list as a version byte followed by the concatenation +// [version]([uvarint length][marshaled])* — the payload of a single block's WAL record. The block number is +// not encoded: it is the WAL record's index. func serializeChangesets(cs []*proto.NamedChangeSet) ([]byte, error) { - var buf []byte + buf := []byte{changesetFormatVersion} var err error for _, ncs := range cs { buf, err = appendChangeset(buf, ncs) @@ -57,12 +44,19 @@ func serializeChangesets(cs []*proto.NamedChangeSet) ([]byte, error) { return buf, nil } -// deserializeChangesets decodes the payload produced by serializeChangesets. Because the enclosing WAL record -// is length-delimited and CRC-verified by the underlying WAL, any truncation encountered here indicates -// corruption and is reported as an error rather than tolerated. +// deserializeChangesets decodes the payload produced by serializeChangesets, after checking its leading +// version byte. Because the enclosing WAL record is length-delimited and CRC-verified by the underlying WAL, +// any truncation encountered here indicates corruption and is reported as an error rather than tolerated. func deserializeChangesets(data []byte) ([]*proto.NamedChangeSet, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty changeset payload: missing version byte") + } + if version := data[0]; version != changesetFormatVersion { + return nil, fmt.Errorf("unsupported changeset format version %d", version) + } + var result []*proto.NamedChangeSet - rest := data + rest := data[1:] for len(rest) > 0 { length, n := binary.Uvarint(rest) if n <= 0 { diff --git a/sei-db/state_db/statewal/state_wal_entry_test.go b/sei-db/state_db/statewal/state_wal_serialization_test.go similarity index 57% rename from sei-db/state_db/statewal/state_wal_entry_test.go rename to sei-db/state_db/statewal/state_wal_serialization_test.go index 52a50b5edc..30c3c995df 100644 --- a/sei-db/state_db/statewal/state_wal_entry_test.go +++ b/sei-db/state_db/statewal/state_wal_serialization_test.go @@ -25,6 +25,7 @@ func TestChangesetsRoundTrip(t *testing.T) { data, err := serializeChangesets(cs) require.NoError(t, err) + require.Equal(t, changesetFormatVersion, data[0], "payload must begin with the format version") got, err := deserializeChangesets(data) require.NoError(t, err) @@ -34,7 +35,7 @@ func TestChangesetsRoundTrip(t *testing.T) { t.Run("empty changeset list", func(t *testing.T) { data, err := serializeChangesets([]*proto.NamedChangeSet{}) require.NoError(t, err) - require.Empty(t, data) + require.Equal(t, []byte{changesetFormatVersion}, data) // just the version byte got, err := deserializeChangesets(data) require.NoError(t, err) @@ -42,6 +43,16 @@ func TestChangesetsRoundTrip(t *testing.T) { }) } +func TestDeserializeUnknownVersion(t *testing.T) { + // A payload whose leading version byte is not recognized must be rejected before any decoding. + _, err := deserializeChangesets([]byte{changesetFormatVersion + 1}) + require.Error(t, err) + + // An empty payload is missing the version byte entirely. + _, err = deserializeChangesets(nil) + require.Error(t, err) +} + func TestDeserializeChangesetsTruncated(t *testing.T) { cs := []*proto.NamedChangeSet{ makeChangeSet("bank", []byte("hello"), []byte("world")), @@ -49,9 +60,10 @@ func TestDeserializeChangesetsTruncated(t *testing.T) { data, err := serializeChangesets(cs) require.NoError(t, err) - // Every strict prefix is truncated. Because the enclosing record is length-delimited by the underlying - // WAL, a truncated payload here is corruption and must surface an error, never a silent partial decode. - for length := 1; length < len(data); length++ { + // Every strict prefix that reaches past the version byte is truncated. Because the enclosing record is + // length-delimited by the underlying WAL, a truncated payload here is corruption and must surface an + // error, never a silent partial decode. (Length 1 is the bare version byte, a valid empty payload.) + for length := 2; length < len(data); length++ { _, err := deserializeChangesets(data[:length]) require.Error(t, err) } @@ -59,9 +71,9 @@ func TestDeserializeChangesetsTruncated(t *testing.T) { func TestDeserializeCorruptChangeset(t *testing.T) { // A length prefix pointing at bytes that are not a valid NamedChangeSet protobuf must surface an error. - // Layout: [len=2][0x08 0xFF] where 0x08 is a varint field tag (field 1, wire type 0) followed by a - // truncated varint, which the protobuf decoder rejects. - payload := []byte{0x02, 0x08, 0xFF} + // Layout: [version][len=2][0x08 0xFF] where 0x08 is a varint field tag (field 1, wire type 0) followed by + // a truncated varint, which the protobuf decoder rejects. + payload := []byte{changesetFormatVersion, 0x02, 0x08, 0xFF} _, err := deserializeChangesets(payload) require.Error(t, err) } From 6ebc75a626bad51ace9b5d9572b32f26485c3f4d Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 9 Jul 2026 11:00:43 -0500 Subject: [PATCH 15/44] document thread safety better --- sei-db/seiwal/seiwal.go | 4 ++++ sei-db/seiwal/seiwal_impl.go | 25 +++++++++++++++++++++++-- sei-db/seiwal/seiwal_impl_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index 3444a724d9..07a89175a1 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -5,6 +5,10 @@ package seiwal // Each record is tagged with a caller-provided monotonic index. The index is what makes garbage // collection ("drop everything below N"), iteration ("start at N"), and rollback ("drop everything // above N") expressible without the WAL ever interpreting a payload. +// +// A WAL instance is not safe for concurrent use: its methods must not be called from multiple +// goroutines simultaneously. Callers that share a WAL across goroutines must serialize access +// themselves. type WAL[T any] interface { // Append a record with the given index and payload. diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 94bcdd4084..23ee2f2917 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -119,8 +119,10 @@ type walImpl struct { // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). asyncErr atomic.Pointer[error] - // Guards the append-ordering state below, which is read/written synchronously in Append (not on the - // writer goroutine). + // Guards the caller-side append-ordering state below, which is read/written synchronously in Append + // (not on the writer goroutine). This gate is a best-effort, time-of-call convenience for the + // (contractually single-threaded) caller; it is not the authoritative ordering guard, since concurrent + // callers could still reorder records past it. The writer goroutine holds the authoritative check. appendMu sync.Mutex // The index of the most recently appended record. lastAppendIndex uint64 @@ -129,6 +131,14 @@ type walImpl struct { // The following fields are owned exclusively by the writer goroutine. + // The index of the most recently written record. A writer-owned backstop that rejects out-of-order + // records that slip past the caller-side gate (e.g. under concurrent misuse), turning silent + // corruption into a fatal error. + lastWrittenIndex uint64 + + // Whether any record has been written (this session or recovered from disk). + hasWritten bool + // The current mutable file accepting records. mutableFile *walFile @@ -212,6 +222,8 @@ func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { if r := w.bounds(); r.ok { w.lastAppendIndex = r.last w.hasAppended = true + w.lastWrittenIndex = r.last + w.hasWritten = true } w.wg.Add(1) @@ -418,9 +430,18 @@ func (w *walImpl) writerLoop() { // appendRecord appends a record to the mutable file, updates bookkeeping, and rotates once the file exceeds // the target size. Every record is complete, so any record is a valid rotation boundary. func (w *walImpl) appendRecord(m dataToBeWritten) error { + // Authoritative ordering check: the caller-side gate in Append can be bypassed by concurrent callers + // (the WAL is documented single-threaded), so re-assert strict increase here on the one writer + // goroutine to reject a reordered record rather than write a file with inverted index bounds. + if w.hasWritten && m.index <= w.lastWrittenIndex { + return fmt.Errorf("append out of order: index %d is not greater than last written index %d", + m.index, w.lastWrittenIndex) + } if err := w.mutableFile.writeRecord(m.record, m.index); err != nil { return fmt.Errorf("failed to append record for index %d: %w", m.index, err) } + w.lastWrittenIndex = m.index + w.hasWritten = true walBytesWritten.Add(w.ctx, int64(len(m.record)), w.metricAttrs) walRecordsWritten.Add(w.ctx, 1, w.metricAttrs) diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 5865ec7f91..843382d1d4 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -1,6 +1,7 @@ package seiwal import ( + "context" "fmt" "os" "path/filepath" @@ -167,6 +168,33 @@ func TestAppendOrdering(t *testing.T) { }) } +// TestWriterRejectsOutOfOrderRecord exercises the writer-goroutine backstop directly. The caller-side gate +// in Append can be bypassed by concurrent misuse (the reordering race is non-deterministic), so appendRecord +// re-asserts strict index increase itself. Driving appendRecord on a standalone walImpl (no running writer +// loop) verifies that a non-increasing index is rejected rather than written with inverted bounds. +func TestWriterRejectsOutOfOrderRecord(t *testing.T) { + dir := t.TempDir() + mf, err := newWalFile(dir, 0) + require.NoError(t, err) + w := &walImpl{ + config: testConfig(dir), + metricAttrs: walNameAttr("test"), + ctx: context.Background(), + mutableFile: mf, + } + defer func() { _, _ = w.mutableFile.seal() }() + + write := func(index uint64) error { + return w.appendRecord(dataToBeWritten{record: frameRecord(index, recordPayload(index)), index: index}) + } + + require.NoError(t, write(5)) + require.Error(t, write(4)) // lower than last written + require.Error(t, write(5)) // equal to last written + require.NoError(t, write(6)) + require.Equal(t, uint64(6), w.lastWrittenIndex) +} + func TestOrphanFileRecovery(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) From 6191630cf0843cf47dddf68a1096444a5e4e24d6 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 9 Jul 2026 12:13:59 -0500 Subject: [PATCH 16/44] bugfixes --- sei-db/seiwal/seiwal_file.go | 11 ++++++ sei-db/seiwal/seiwal_impl.go | 5 ++- sei-db/seiwal/seiwal_impl_test.go | 23 ++++++++++++ sei-db/seiwal/seiwal_iterator.go | 17 +++++++++ sei-db/seiwal/seiwal_iterator_test.go | 45 ++++++++++++++++++++++++ sei-db/seiwal/seiwal_serializing.go | 3 ++ sei-db/seiwal/seiwal_serializing_test.go | 22 ++++++++++++ 7 files changed, 125 insertions(+), 1 deletion(-) diff --git a/sei-db/seiwal/seiwal_file.go b/sei-db/seiwal/seiwal_file.go index 817d88bcc0..bd855d26c6 100644 --- a/sei-db/seiwal/seiwal_file.go +++ b/sei-db/seiwal/seiwal_file.go @@ -177,6 +177,17 @@ func (f *walFile) writeRecord(record []byte, index uint64) error { return nil } +// close releases the file handle without sealing. Used on the fatal-error path, where the file is left +// unsealed for recoverOrphans to seal (truncating any torn tail) on the next open. Idempotent. +func (f *walFile) close() error { + if f.file == nil { + return nil + } + err := f.file.Close() + f.file = nil + return err +} + // Flush buffered data to the OS. When fsync is true, also fsync the file so the data survives power loss. func (f *walFile) flush(fsync bool) error { if f.writer != nil { diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 23ee2f2917..39d8ef37a8 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -135,7 +135,7 @@ type walImpl struct { // records that slip past the caller-side gate (e.g. under concurrent misuse), turning silent // corruption into a fatal error. lastWrittenIndex uint64 - + // Whether any record has been written (this session or recovered from disk). hasWritten bool @@ -619,6 +619,9 @@ func (w *walImpl) bounds() storedRange { func (w *walImpl) fail(err error) { w.asyncErr.CompareAndSwap(nil, &err) w.cancel() + if cerr := w.mutableFile.close(); cerr != nil { + logger.Error("failed to close mutable WAL file after fatal error", "err", cerr) + } logger.Error("WAL encountered a fatal error", "err", err) } diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 843382d1d4..64c8835dd0 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -195,6 +195,29 @@ func TestWriterRejectsOutOfOrderRecord(t *testing.T) { require.Equal(t, uint64(6), w.lastWrittenIndex) } +// TestFailReleasesMutableFile verifies that a fatal error releases the mutable file's handle (rather than +// leaking the fd until process exit) and that the release is idempotent. +func TestFailReleasesMutableFile(t *testing.T) { + dir := t.TempDir() + mf, err := newWalFile(dir, 0) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + w := &walImpl{ + config: testConfig(dir), + metricAttrs: walNameAttr("test"), + ctx: ctx, + cancel: cancel, + mutableFile: mf, + } + require.NoError(t, w.appendRecord(dataToBeWritten{record: frameRecord(1, recordPayload(1)), index: 1})) + + w.fail(fmt.Errorf("boom")) + + require.Nil(t, w.mutableFile.file) // fd released + require.Error(t, w.asyncError()) // failure recorded + require.NoError(t, w.mutableFile.close()) // idempotent +} + func TestOrphanFileRecovery(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index 6da6b82f83..59d7b63cad 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -236,6 +236,23 @@ func (it *walIterator) loadNextFile() (bool, error) { if err != nil { return false, fmt.Errorf("failed to read WAL file (sequence %d) during iteration: %w", f.fileSeq, err) } + + // A sealed file is durable and complete, so its content must span the [first, last] range its name + // promises. parseWalFileData tolerates a torn trailing record — correct for a crashed mutable file, but + // a sealed file read here should have none. A shortfall means interior corruption (e.g. bit-rot), which + // would otherwise silently drop every record between the corruption point and the name-promised last + // index while Bounds/GetStoredRange keep reporting the full range. Fail loudly instead of under-yielding. + if !contents.hasRecords { + return false, fmt.Errorf( + "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but no intact records were read", + f.fileSeq, f.firstIndex, f.lastIndex) + } + if contents.firstIndex != f.firstIndex || contents.lastIndex != f.lastIndex { + return false, fmt.Errorf( + "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but content holds [%d, %d]", + f.fileSeq, f.firstIndex, f.lastIndex, contents.firstIndex, contents.lastIndex) + } + for _, record := range contents.records { if record.index < it.start { continue diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index 975f75e583..ee7f2282c0 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -2,6 +2,8 @@ package seiwal import ( "fmt" + "os" + "path/filepath" "sync" "testing" "time" @@ -9,6 +11,49 @@ import ( "github.com/stretchr/testify/require" ) +// TestIteratorRejectsCorruptSealedFile verifies that interior corruption in a sealed file is surfaced as an +// error rather than silently truncating iteration short of the file's name-promised last index. A sealed file +// is durable and complete, so any shortfall between its content and its name is corruption, not a torn tail. +func TestIteratorRejectsCorruptSealedFile(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) // seals records 1..5 into a single file + + names := sealedFileNames(t, dir) + require.Len(t, names, 1) + path := filepath.Join(dir, names[0]) + data, err := os.ReadFile(path) + require.NoError(t, err) + data[len(data)-1] ^= 0xFF // corrupt the last record's CRC so the parser stops short of index 5 + require.NoError(t, os.WriteFile(path, data, 0o600)) + + w2 := openWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + + it, err := w2.Iterator(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + var iterErr error + for { + ok, err := it.Next() + if err != nil { + iterErr = err + break + } + if !ok { + break + } + } + require.Error(t, iterErr) + require.Contains(t, iterErr.Error(), "corrupt") +} + func TestIteratorEmpty(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index 3219fa00d5..aa3c9b4f72 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -356,6 +356,9 @@ func (s *serializingWAL[T]) serializerLoop() { func (s *serializingWAL[T]) fail(err error) { s.asyncErr.CompareAndSwap(nil, &err) s.cancel() + if cerr := s.inner.Close(); cerr != nil { + logger.Error("failed to close inner WAL after fatal error", "err", cerr) + } logger.Error("serializing WAL encountered a fatal error", "err", err) } diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go index c81a6a7e73..5b9ef4605a 100644 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -57,6 +57,28 @@ func collectStrings(t *testing.T, w WAL[string], start uint64) []indexedString { return out } +// TestSerializeFailureClosesInnerWAL verifies that a serialize error tears the inner byte WAL down instead of +// orphaning its writer goroutine and mutable-file handle. The inner WAL is healthy (only serialization failed), +// so fail() closes it gracefully — observable as the inner mutable file being sealed. +func TestSerializeFailureClosesInnerWAL(t *testing.T) { + cfg := testConfig(t.TempDir()) + boom := errors.New("serialize boom") + serialize := func(s string) ([]byte, error) { return nil, boom } + w, err := NewGenericWAL[string](cfg, serialize, stringDeserialize) + require.NoError(t, err) + + require.NoError(t, w.Append(1, "one")) // scheduling succeeds; serialize fails on the serializer goroutine + + // Close drains the serializer goroutine, which by now has run fail() -> inner.Close(). + err = w.Close() + require.Error(t, err) + require.ErrorIs(t, err, boom) + + sw := w.(*serializingWAL[string]) + inner := sw.inner.(*walImpl) + require.True(t, inner.mutableFile.sealed) // inner cleanly closed by fail(), not orphaned +} + func TestGenericWALRoundTrip(t *testing.T) { w := openStringWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() From 888cc8d1ad76c2d09eb85ddfb971e178ba3a6645 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 9 Jul 2026 12:16:57 -0500 Subject: [PATCH 17/44] minor fixes --- sei-db/seiwal/seiwal.go | 4 ++++ sei-db/seiwal/seiwal_iterator.go | 6 ++++-- sei-db/seiwal/seiwal_serializing.go | 2 ++ sei-db/state_db/statewal/state_wal_serialization.go | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index 07a89175a1..78d6147d0b 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -57,6 +57,10 @@ type WAL[T any] interface { } // Iterator iterates over the records of a WAL in ascending index order. +// +// An Iterator is single-consumer and not safe for concurrent use: all of its methods, including Close, must +// be called from a single goroutine (or with external serialization). In particular, Close must not be +// called concurrently with Next from another goroutine. type Iterator[T any] interface { // Next advances the iterator to the next record. It returns false when iteration is complete (no more // records), and returns an error if advancing failed. After Next returns (false, nil), iteration is diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index 59d7b63cad..3e5f7bc671 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -58,11 +58,13 @@ type walIterator struct { // Ensures the shutdown sequence runs at most once. closeOnce sync.Once - // The index and payload returned by Entry, set by the most recent successful Next. Consumer-owned. + // The index and payload returned by Entry, set by the most recent successful Next. Owned by the single + // consumer goroutine (see the Iterator concurrency contract); never touched by Close or the reader. resultIndex uint64 resultPayload []byte - // Set once iteration is complete. Consumer-owned. + // Set once iteration is complete. Owned by the single consumer goroutine (see the Iterator concurrency + // contract): Next and Close both set it, which is why they must not run concurrently. done bool // The following fields are owned exclusively by the reader goroutine. diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index aa3c9b4f72..4e34895a8e 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -373,6 +373,8 @@ func (s *serializingWAL[T]) asyncError() error { var _ Iterator[[]byte] = (*serializingIterator[[]byte])(nil) // serializingIterator adapts an inner byte iterator to a typed iterator by running deserialize inline in Next. +// Like the inner iterator, it is single-consumer and not safe for concurrent use (see the Iterator +// concurrency contract). type serializingIterator[T any] struct { inner Iterator[[]byte] deserialize func([]byte) (T, error) diff --git a/sei-db/state_db/statewal/state_wal_serialization.go b/sei-db/state_db/statewal/state_wal_serialization.go index 8a149c8053..6fd667ed8d 100644 --- a/sei-db/state_db/statewal/state_wal_serialization.go +++ b/sei-db/state_db/statewal/state_wal_serialization.go @@ -12,8 +12,8 @@ import ( const changesetFormatVersion = byte(1) // appendChangeset appends the framing [uvarint marshaled length][marshaled NamedChangeSet] for ncs to buf and -// returns the extended buffer. This is the incremental unit the serializer goroutine accumulates across the -// multiple Write calls of a single block before appending the whole block as one WAL record. +// returns the extended buffer. It frames a single changeset; serializeChangesets calls it once per changeset +// to build a block's WAL record payload. func appendChangeset(buf []byte, ncs *proto.NamedChangeSet) ([]byte, error) { if ncs == nil { return nil, fmt.Errorf("changeset is nil") From 819fac9b28dc1e746091a0deb3d479810ad862d2 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 9 Jul 2026 12:37:55 -0500 Subject: [PATCH 18/44] bugfixes --- sei-db/seiwal/seiwal_impl.go | 7 ++++- sei-db/seiwal/seiwal_impl_test.go | 35 ++++++++++++++++++++++++ sei-db/seiwal/seiwal_serializing.go | 7 ++++- sei-db/seiwal/seiwal_serializing_test.go | 30 ++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 39d8ef37a8..25e7dc2dd4 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -404,7 +404,12 @@ func (w *walImpl) writerLoop() { return } case flushRequest: - m.done <- w.mutableFile.flush(w.config.FsyncOnFlush) + err := w.mutableFile.flush(w.config.FsyncOnFlush) + m.done <- err + if err != nil { + w.fail(err) + return + } case rangeQuery: m.reply <- w.bounds() case pruneRequest: diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 64c8835dd0..39406b895f 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -218,6 +218,41 @@ func TestFailReleasesMutableFile(t *testing.T) { require.NoError(t, w.mutableFile.close()) // idempotent } +// TestFlushIOFailureBricksWAL verifies that an IO error during Flush is fatal: the failure is surfaced to the +// flushing caller, the WAL then refuses all further work, and Close reports the original error — matching how +// every other writer IO error is handled, so a broken durability guarantee is never silently tolerated. +func TestFlushIOFailureBricksWAL(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + + impl, ok := w.(*walImpl) + require.True(t, ok) + + // Force the next flush to fail by closing the mutable file's descriptor out from under the writer. The + // writer is idle (blocked awaiting a message) and never reassigns the handle, and appending only buffers + // bytes, so this affects nothing until the flush attempts to write/fsync the closed descriptor. + require.NoError(t, impl.mutableFile.file.Close()) + + require.NoError(t, w.Append(1, recordPayload(1))) + require.Error(t, w.Flush(), "flush must surface the IO failure") + + // Bricking cancels the context; wait for it so the "refuses further work" assertions are deterministic + // (Flush may return the moment the error is sent, a hair before fail() finishes tearing down). + select { + case <-impl.ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("WAL did not brick after flush failure") + } + + require.Error(t, w.Append(2, recordPayload(2)), "appends must fail on a bricked WAL") + require.Error(t, w.Flush(), "flush must fail on a bricked WAL") + _, _, _, err := w.Bounds() + require.Error(t, err, "bounds must fail on a bricked WAL") + + require.Error(t, w.Close(), "Close must surface the fatal flush error") + require.Error(t, impl.asyncError()) +} + func TestOrphanFileRecovery(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index 4e34895a8e..2aae5bb3ed 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -330,7 +330,12 @@ func (s *serializingWAL[T]) serializerLoop() { return } case serFlush: - m.done <- s.inner.Flush() + err := s.inner.Flush() + m.done <- err + if err != nil { + s.fail(fmt.Errorf("failed to flush: %w", err)) + return + } case serBounds: ok, first, last, err := s.inner.Bounds() m.reply <- serBoundsResult{ok: ok, first: first, last: last, err: err} diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go index 5b9ef4605a..be8900d225 100644 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -79,6 +79,36 @@ func TestSerializeFailureClosesInnerWAL(t *testing.T) { require.True(t, inner.mutableFile.sealed) // inner cleanly closed by fail(), not orphaned } +// TestGenericWALFlushIOFailureBricksWAL verifies that an inner flush IO failure bricks the serializing WAL too: +// the inner byte engine tears itself down, and the serializing layer mirrors that rather than delegating +// subsequent appends to a dead inner WAL. +func TestGenericWALFlushIOFailureBricksWAL(t *testing.T) { + cfg := testConfig(t.TempDir()) + w := openStringWAL(t, cfg) + + ser, ok := w.(*serializingWAL[string]) + require.True(t, ok) + inner, ok := ser.inner.(*walImpl) + require.True(t, ok) + + // Close the inner mutable file's descriptor so the flush the inner engine performs fails. + require.NoError(t, inner.mutableFile.file.Close()) + + require.NoError(t, w.Append(1, "one")) + require.Error(t, w.Flush(), "flush must surface the inner IO failure") + + // Bricking cancels the serializing layer's context; wait for it so the assertions below are deterministic. + select { + case <-ser.ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("serializing WAL did not brick after flush failure") + } + + require.Error(t, w.Append(2, "two"), "appends must fail on a bricked WAL") + require.Error(t, w.Flush(), "flush must fail on a bricked WAL") + require.Error(t, w.Close(), "Close must surface the fatal flush error") +} + func TestGenericWALRoundTrip(t *testing.T) { w := openStringWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() From bc70257a97d80e4c8ef4422d01b0f211eb3d0b7e Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 9 Jul 2026 12:54:33 -0500 Subject: [PATCH 19/44] bugfixes --- sei-db/seiwal/seiwal_file.go | 2 +- sei-db/seiwal/seiwal_impl.go | 7 +++++++ sei-db/seiwal/seiwal_serializing.go | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sei-db/seiwal/seiwal_file.go b/sei-db/seiwal/seiwal_file.go index bd855d26c6..c73d42a5ba 100644 --- a/sei-db/seiwal/seiwal_file.go +++ b/sei-db/seiwal/seiwal_file.go @@ -222,7 +222,7 @@ func (f *walFile) seal() (string, error) { return "", fmt.Errorf("failed to close WAL file: %w", err) } } - if err := os.Remove(unsealedPath); err != nil && !os.IsNotExist(err) { + if err := removeAndSyncDir(f.directory, unsealedFileName(f.fileSeq)); err != nil { return "", fmt.Errorf("failed to remove empty WAL file: %w", err) } f.sealed = true diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 25e7dc2dd4..1acd4af7f8 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -261,6 +261,13 @@ func (w *walImpl) Append(index uint64, data []byte) error { if w.closed.Load() { return fmt.Errorf("WAL is closed") } + // Fail fast on a bricked WAL. Without this, a fire-and-forget append can win the sendToWriter select + // against the already-cancelled senderCtx and enqueue onto a dead writer's buffer, silently dropping the + // record and returning nil. asyncErr is recorded before the cancel, so any caller that has observed the + // shutdown also observes the error here. + if err := w.asyncError(); err != nil { + return fmt.Errorf("WAL failed: %w", err) + } w.appendMu.Lock() if w.hasAppended && index <= w.lastAppendIndex { diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index 2aae5bb3ed..80a6470987 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -189,6 +189,11 @@ func (s *serializingWAL[T]) Append(index uint64, data T) error { if s.closed.Load() { return fmt.Errorf("WAL is closed") } + // Fail fast on a bricked WAL, so a fire-and-forget append cannot win the submit select against the + // cancelled senderCtx and be silently dropped onto a dead serializer (see walImpl.Append for detail). + if err := s.asyncError(); err != nil { + return fmt.Errorf("WAL failed: %w", err) + } req := serAppend{ index: index, serialize: func() ([]byte, error) { return s.serialize(data) }, From 781c4c0c48a8662a804a1eeb81d97042c39377e7 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 10 Jul 2026 09:26:41 -0500 Subject: [PATCH 20/44] make suggested changes --- sei-db/seiwal/seiwal_file.go | 43 +++++++++++ sei-db/seiwal/seiwal_impl.go | 21 ++++++ sei-db/seiwal/seiwal_impl_test.go | 84 ++++++++++++++++++++++ sei-db/seiwal/seiwal_iterator.go | 18 ++--- sei-db/seiwal/seiwal_iterator_test.go | 18 +++-- sei-db/state_db/statewal/state_wal.go | 3 + sei-db/state_db/statewal/state_wal_impl.go | 14 ++-- 7 files changed, 172 insertions(+), 29 deletions(-) diff --git a/sei-db/seiwal/seiwal_file.go b/sei-db/seiwal/seiwal_file.go index c73d42a5ba..99a8582da6 100644 --- a/sei-db/seiwal/seiwal_file.go +++ b/sei-db/seiwal/seiwal_file.go @@ -316,20 +316,41 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile } contents.validEnd = walHeaderSize + // A torn trailing record is expected only in the mutable file after a crash, where discarding it is correct. + // A sealed file is durable, fsync'd, and truncated to a record boundary on seal, so any framing or checksum + // failure in it is corruption (e.g. bit-rot) that must be surfaced rather than silently discarded — otherwise + // records past the fault vanish while the file's name keeps promising them. torn() turns each tolerated break + // into a hard error for a sealed file. + torn := func(offset int, reason string) error { + if !parsed.sealed { + return nil + } + return fmt.Errorf("sealed WAL file %s is corrupt: %s at offset %d", name, reason, offset) + } + offset := walHeaderSize for offset < len(data) { index, idxN := binary.Uvarint(data[offset:]) if idxN <= 0 { + if err := torn(offset, "torn record index prefix"); err != nil { + return nil, err + } break // torn or incomplete index prefix } lenStart := offset + idxN length, lenN := binary.Uvarint(data[lenStart:]) if lenN <= 0 { + if err := torn(offset, "torn record length prefix"); err != nil { + return nil, err + } break // torn or incomplete length prefix } payloadStart := lenStart + lenN remaining := uint64(len(data) - payloadStart) //nolint:gosec // payloadStart <= len(data), so non-negative if remaining < 4 || length > remaining-4 { + if err := torn(offset, "truncated record payload or checksum"); err != nil { + return nil, err + } break // torn record: payload or checksum truncated (4 trailing bytes are the CRC32) } payloadLen := int(length) //nolint:gosec // bounded above by remaining-4, which is <= len(data) @@ -337,6 +358,9 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile recordEnd := payloadEnd + 4 gotCRC := binary.BigEndian.Uint32(data[payloadEnd:recordEnd]) if gotCRC != crc32.ChecksumIEEE(data[offset:payloadEnd]) { + if err := torn(offset, "record checksum mismatch"); err != nil { + return nil, err + } break // torn or corrupt record } @@ -355,6 +379,25 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile return contents, nil } +// verifySealedContents confirms that a sealed file's intact content exactly covers the [first, last] index +// range promised by its name. A sealed file is durable and complete, so a shortfall means interior corruption +// (e.g. a record truncated to a clean boundary) that leaves parseWalFileData reading fewer records than the +// name promises, while Bounds/GetStoredRange keep reporting the full range. fileSeq is used only for error +// messages. +func verifySealedContents(contents *walFileContents, fileSeq uint64, first uint64, last uint64) error { + if !contents.hasRecords { + return fmt.Errorf( + "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but no intact records were read", + fileSeq, first, last) + } + if contents.firstIndex != first || contents.lastIndex != last { + return fmt.Errorf( + "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but content holds [%d, %d]", + fileSeq, first, last, contents.firstIndex, contents.lastIndex) + } + return nil +} + // truncateAndSync truncates the file at path to size and fsyncs it, so the shorter length is durable on its // own — before any subsequent rename. Without the fsync, a crash could persist a rename while losing the // truncation, leaving a file whose name promises more records than its content actually holds. diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 1acd4af7f8..751ffe36ee 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -195,6 +195,9 @@ func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { if err != nil { return nil, fmt.Errorf("failed to scan sealed WAL files: %w", err) } + if err := validateSealedFiles(config.Path, sealedFiles); err != nil { + return nil, fmt.Errorf("corrupt sealed WAL file: %w", err) + } mutable, err := newWalFile(config.Path, nextFileSeq) if err != nil { @@ -752,3 +755,21 @@ func scanSealedFiles(directory string) (*util.RandomAccessDeque[*sealedFileInfo] } return sealedFiles, nextFileSeq, nil } + +// validateSealedFiles reads and checksums every sealed file, confirming each file's content exactly covers the +// [first, last] index range its name promises. This surfaces corruption (bit-rot, truncation) at open — where +// it demands human intervention — rather than lazily at iteration time, by which point Bounds/GetStoredRange +// would already be reporting a range that iteration cannot deliver. Cost is O(total sealed bytes): every sealed +// file is read and CRC-verified on open. +func validateSealedFiles(directory string, sealedFiles *util.RandomAccessDeque[*sealedFileInfo]) error { + for _, info := range sealedFiles.Iterator() { + contents, err := readWalFile(filepath.Join(directory, info.name)) + if err != nil { + return fmt.Errorf("failed to read sealed WAL file %s: %w", info.name, err) + } + if err := verifySealedContents(contents, info.fileSeq, info.firstIndex, info.lastIndex); err != nil { + return err + } + } + return nil +} diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 39406b895f..83e6168dd3 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -552,6 +552,90 @@ func TestScanRejectsGapInSealedFiles(t *testing.T) { require.Contains(t, err.Error(), "not contiguous") } +// TestNewWALRejectsMidStreamCorruptSealedFile verifies that a checksum mismatch in a non-final record of a +// sealed file is surfaced as a hard error at open. Corrupted durable data demands human intervention, so the +// WAL must refuse to open rather than silently serving a view truncated at the corruption point. +func TestNewWALRejectsMidStreamCorruptSealedFile(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) // seals records 1..5 into a single file + + names := sealedFileNames(t, dir) + require.Len(t, names, 1) + path := filepath.Join(dir, names[0]) + data, err := os.ReadFile(path) + require.NoError(t, err) + // Flip a byte in the first record's payload so the fault is mid-stream, not a torn trailing record. The + // first record's payload begins just past the header and its two single-byte uvarint prefixes (index 1, + // length 9), so walHeaderSize+2 lands inside the payload. + data[walHeaderSize+2] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + + _, err = NewWAL(cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "corrupt") +} + +// TestNewWALRejectsTruncatedSealedFile verifies that a sealed file truncated at a clean record boundary — all +// remaining records checksum correctly, but the content stops short of the last index its name promises — is +// rejected at open. This is the case parse-strictness alone cannot catch (no torn record remains); the +// content/name range check must. +func TestNewWALRejectsTruncatedSealedFile(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) // seals records 1..5 into a single file named 0-1-5 + + names := sealedFileNames(t, dir) + require.Len(t, names, 1) + path := filepath.Join(dir, names[0]) + + // Truncate at the boundary just past the 4th record, leaving indices 1..4 intact while the name still + // promises [1, 5]. + contents, err := readWalFile(path) + require.NoError(t, err) + require.Len(t, contents.records, 5) + require.NoError(t, os.Truncate(path, contents.records[3].end)) + + _, err = NewWAL(cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "corrupt") +} + +// TestNewWALRejectsSealedFileBadMagic verifies that a sealed file with a clobbered header (invalid magic +// prefix) is rejected at open rather than treated as empty. +func TestNewWALRejectsSealedFileBadMagic(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for index := uint64(1); index <= 3; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + names := sealedFileNames(t, dir) + require.Len(t, names, 1) + path := filepath.Join(dir, names[0]) + data, err := os.ReadFile(path) + require.NoError(t, err) + data[0] ^= 0xFF // clobber the magic prefix + require.NoError(t, os.WriteFile(path, data, 0o600)) + + _, err = NewWAL(cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "corrupt") +} + func TestBoundsEmpty(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index 3e5f7bc671..e83006b830 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -240,19 +240,11 @@ func (it *walIterator) loadNextFile() (bool, error) { } // A sealed file is durable and complete, so its content must span the [first, last] range its name - // promises. parseWalFileData tolerates a torn trailing record — correct for a crashed mutable file, but - // a sealed file read here should have none. A shortfall means interior corruption (e.g. bit-rot), which - // would otherwise silently drop every record between the corruption point and the name-promised last - // index while Bounds/GetStoredRange keep reporting the full range. Fail loudly instead of under-yielding. - if !contents.hasRecords { - return false, fmt.Errorf( - "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but no intact records were read", - f.fileSeq, f.firstIndex, f.lastIndex) - } - if contents.firstIndex != f.firstIndex || contents.lastIndex != f.lastIndex { - return false, fmt.Errorf( - "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but content holds [%d, %d]", - f.fileSeq, f.firstIndex, f.lastIndex, contents.firstIndex, contents.lastIndex) + // promises. Fail loudly on any shortfall (interior corruption) instead of silently under-yielding while + // Bounds/GetStoredRange keep reporting the full range. This mirrors the check run eagerly at open by + // validateSealedFiles. + if err := verifySealedContents(contents, f.fileSeq, f.firstIndex, f.lastIndex); err != nil { + return false, err } for _, record := range contents.records { diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index ee7f2282c0..05afd4af5b 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -11,9 +11,10 @@ import ( "github.com/stretchr/testify/require" ) -// TestIteratorRejectsCorruptSealedFile verifies that interior corruption in a sealed file is surfaced as an -// error rather than silently truncating iteration short of the file's name-promised last index. A sealed file -// is durable and complete, so any shortfall between its content and its name is corruption, not a torn tail. +// TestIteratorRejectsCorruptSealedFile verifies that interior corruption appearing in a sealed file after the +// WAL is already open (e.g. bit-rot on disk after a clean startup validation) is surfaced as an error rather +// than silently truncating iteration short of the file's name-promised last index. The corruption is +// introduced after open so it slips past validateSealedFiles and must be caught by the iterator's re-read. func TestIteratorRejectsCorruptSealedFile(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -27,14 +28,17 @@ func TestIteratorRejectsCorruptSealedFile(t *testing.T) { names := sealedFileNames(t, dir) require.Len(t, names, 1) path := filepath.Join(dir, names[0]) + + w2 := openWAL(t, cfg) // healthy at open; validation passes + defer func() { require.NoError(t, w2.Close()) }() + + // Corrupt the last record's CRC only now, after open, so the parser stops short of index 5 on the + // iterator's re-read of the file from disk. data, err := os.ReadFile(path) require.NoError(t, err) - data[len(data)-1] ^= 0xFF // corrupt the last record's CRC so the parser stops short of index 5 + data[len(data)-1] ^= 0xFF require.NoError(t, os.WriteFile(path, data, 0o600)) - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - it, err := w2.Iterator(1) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index 588542f6bb..b183b4eff8 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -6,6 +6,9 @@ import ( ) // A WAL for state. +// +// A StateWAL is not safe for concurrent use. Callers must serialize their calls to a single instance; +// in particular Write and SignalEndOfBlock share write-ordering state that is not internally locked. type StateWAL interface { // Write a set of changes to the WAL. diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 11e663e3e6..5ddcb42c91 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -2,7 +2,6 @@ package statewal import ( "fmt" - "sync" "sync/atomic" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -12,6 +11,8 @@ import ( var _ StateWAL = (*stateWALImpl)(nil) // A WAL for storing state changesets by block number. +// +// Not safe for concurrent use; see the StateWAL interface doc. type stateWALImpl struct { // The underlying generic WAL, keyed by block number, whose payload is a block's changesets. wal seiwal.WAL[[]*proto.NamedChangeSet] @@ -19,8 +20,9 @@ type stateWALImpl struct { // Set by Close() so subsequent Write/SignalEndOfBlock calls fail fast. closed atomic.Bool - // Guards the write-ordering contract state and the accumulation buffer below. - mu sync.Mutex + // The write-ordering contract state and the accumulation buffer below are mutated by Write and + // SignalEndOfBlock, which callers must not invoke concurrently. + // The block number of the most recent Write or SignalEndOfBlock. currentBlock uint64 // Whether currentBlock has been finalized by SignalEndOfBlock. @@ -77,8 +79,6 @@ func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) err if w.closed.Load() { return fmt.Errorf("state WAL is closed") } - w.mu.Lock() - defer w.mu.Unlock() if err := w.enforceWriteOrdering(blockNumber); err != nil { return fmt.Errorf("write rejected: %w", err) } @@ -92,16 +92,13 @@ func (w *stateWALImpl) SignalEndOfBlock() error { return fmt.Errorf("state WAL is closed") } - w.mu.Lock() if !w.hasCurrentBlock || w.currentBlockEnded { - w.mu.Unlock() return fmt.Errorf("no block in progress to end") } blockNumber := w.currentBlock w.currentBlockEnded = true changeset := w.buf w.buf = nil // hand ownership to the WAL; the next block starts a fresh buffer - w.mu.Unlock() if err := w.wal.Append(blockNumber, changeset); err != nil { return fmt.Errorf("failed to append block %d: %w", blockNumber, err) @@ -111,7 +108,6 @@ func (w *stateWALImpl) SignalEndOfBlock() error { // enforceWriteOrdering rejects a Write that violates the block-ordering rules (no decreasing block numbers; no // advancing to a new block before the current one is ended) and records the new position when it is allowed. -// The caller must hold w.mu. func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { if !w.hasCurrentBlock { w.currentBlock = blockNumber From fea1112e1ec0734a8a7770a6f51b61eac73e303d Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 10 Jul 2026 09:52:09 -0500 Subject: [PATCH 21/44] made suggested changes --- sei-db/seiwal/seiwal_impl.go | 7 ++++++- sei-db/seiwal/seiwal_impl_test.go | 33 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 751ffe36ee..a3b0c0f221 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -428,7 +428,12 @@ func (w *walImpl) writerLoop() { return } case iteratorStartRequest: - m.reply <- w.startIterator(m.startIndex) + resp := w.startIterator(m.startIndex) + m.reply <- resp + if resp.err != nil { + w.fail(resp.err) + return + } case unpinRequest: w.releaseIndex(m.index) case closeRequest: diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 83e6168dd3..4e438b724f 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -253,6 +253,39 @@ func TestFlushIOFailureBricksWAL(t *testing.T) { require.Error(t, impl.asyncError()) } +// TestIteratorRotateFailureBricksWAL verifies that when the rotation performed during iterator creation fails +// at opening the fresh mutable file (after the current file was already sealed), the WAL bricks itself rather +// than limping on with an inconsistent mutable file and later staging a phantom sealed entry. +func TestIteratorRotateFailureBricksWAL(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + + impl, ok := w.(*walImpl) + require.True(t, ok) + + require.NoError(t, w.Append(1, recordPayload(1))) + require.NoError(t, w.Flush()) + + // Make the rotation's newWalFile step fail while its seal step still succeeds: occupy the exact path the + // next mutable file wants (fileSeq 1 -> "1.wal.u") with a directory, so os.Create there fails with EISDIR. + // The seal renames the current file to "0-1-1.wal", unaffected by this blocker. + blocker := filepath.Join(dir, unsealedFileName(1)) + require.NoError(t, os.Mkdir(blocker, 0o755)) + + _, err := w.Iterator(1) + require.Error(t, err, "iterator creation must surface the rotation failure") + + select { + case <-impl.ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("WAL did not brick after rotation failure during iterator creation") + } + + require.Error(t, w.Append(2, recordPayload(2)), "appends must fail on a bricked WAL") + require.Error(t, w.Close(), "Close must surface the fatal error") + require.Error(t, impl.asyncError()) +} + func TestOrphanFileRecovery(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) From d9a978be0ef03181c12d459c2c8a83b79e07e7d0 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 10 Jul 2026 09:53:54 -0500 Subject: [PATCH 22/44] godoc thread safety issues --- sei-db/seiwal/seiwal.go | 13 +++++++++++++ sei-db/state_db/statewal/state_wal.go | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index 78d6147d0b..b76a1e15f0 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -9,6 +9,12 @@ package seiwal // A WAL instance is not safe for concurrent use: its methods must not be called from multiple // goroutines simultaneously. Callers that share a WAL across goroutines must serialize access // themselves. +// +// Slices are not copied at the call boundary. Any slice passed into a WAL method — the payload and every +// slice reachable through it — must not be modified after the call: the WAL may retain it and read it +// asynchronously, so mutating it races the WAL and can corrupt what is persisted. Likewise every slice +// returned from a WAL or its iterator is owned by the WAL and must be treated as read-only. Callers that +// need to mutate such data must copy it first. type WAL[T any] interface { // Append a record with the given index and payload. @@ -18,6 +24,10 @@ type WAL[T any] interface { // // This method only schedules the append; it does not block until the record is durable. Durability is // achieved by a subsequent Flush. + // + // data, and every slice reachable through it, must not be modified after this call: the payload may be + // retained and serialized asynchronously, so mutating it races the WAL and can corrupt what is + // persisted. Callers that need to reuse or mutate the buffer must copy it first. Append(index uint64, data T) error // Flush blocks until all previously scheduled appends are durable. @@ -61,6 +71,9 @@ type WAL[T any] interface { // An Iterator is single-consumer and not safe for concurrent use: all of its methods, including Close, must // be called from a single goroutine (or with external serialization). In particular, Close must not be // called concurrently with Next from another goroutine. +// +// Every payload returned by an Iterator — and every slice reachable through it — is owned by the WAL and +// must be treated as read-only. Callers that need to retain or mutate the data must copy it first. type Iterator[T any] interface { // Next advances the iterator to the next record. It returns false when iteration is complete (no more // records), and returns an error if advancing failed. After Next returns (false, nil), iteration is diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index b183b4eff8..35e87dc7b3 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -9,6 +9,12 @@ import ( // // A StateWAL is not safe for concurrent use. Callers must serialize their calls to a single instance; // in particular Write and SignalEndOfBlock share write-ordering state that is not internally locked. +// +// Slices are not copied at the call boundary. Changesets passed to Write — and every byte slice reachable +// through them — must not be modified after the call: the WAL retains them and serializes them +// asynchronously, so mutating them races the WAL and can corrupt what is persisted. Likewise the changesets +// returned by the iterator are owned by the WAL and must be treated as read-only. Callers that need to +// mutate such data must copy it first. type StateWAL interface { // Write a set of changes to the WAL. From 88c06cfe5b41d84210d2e28509567997944b9b70 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 10 Jul 2026 10:47:14 -0500 Subject: [PATCH 23/44] made suggested changes --- sei-db/seiwal/seiwal_impl.go | 41 ++++++++---------- sei-db/seiwal/seiwal_impl_test.go | 71 +++++++++++++++++++++++++++++++ sei-db/seiwal/seiwal_iterator.go | 20 ++++++--- 3 files changed, 104 insertions(+), 28 deletions(-) diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index a3b0c0f221..d8ce0ebfac 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -119,13 +119,9 @@ type walImpl struct { // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). asyncErr atomic.Pointer[error] - // Guards the caller-side append-ordering state below, which is read/written synchronously in Append - // (not on the writer goroutine). This gate is a best-effort, time-of-call convenience for the - // (contractually single-threaded) caller; it is not the authoritative ordering guard, since concurrent - // callers could still reorder records past it. The writer goroutine holds the authoritative check. - appendMu sync.Mutex // The index of the most recently appended record. lastAppendIndex uint64 + // Whether any record has been appended (this session or recovered from disk). hasAppended bool @@ -272,15 +268,12 @@ func (w *walImpl) Append(index uint64, data []byte) error { return fmt.Errorf("WAL failed: %w", err) } - w.appendMu.Lock() if w.hasAppended && index <= w.lastAppendIndex { - last := w.lastAppendIndex - w.appendMu.Unlock() - return fmt.Errorf("append rejected: index %d is not greater than last appended index %d", index, last) + return fmt.Errorf( + "append rejected: index %d is not greater than last appended index %d", index, w.lastAppendIndex) } w.lastAppendIndex = index w.hasAppended = true - w.appendMu.Unlock() record := frameRecord(index, data) if err := w.sendToWriter(dataToBeWritten{record: record, index: index}); err != nil { @@ -556,8 +549,8 @@ func (w *walImpl) startIterator(startIndex uint64) iteratorStartResponse { }) } - pinned := w.pinLowestReadableIndex(startIndex) - it := newWalIterator(w, startIndex, pinned, files, w.config.IteratorPrefetchSize) + pinned, hasPin := w.pinLowestReadableIndex(startIndex) + it := newWalIterator(w, startIndex, pinned, hasPin, files, w.config.IteratorPrefetchSize) return iteratorStartResponse{iterator: it} } @@ -575,18 +568,20 @@ func (w *walImpl) sealForIterator() error { return nil } -// pinLowestReadableIndex records a read lease and returns the pinned index. An iterator reads records at or -// above startIndex but never below the oldest record actually stored, so the lease is clamped up to that: a -// stale low start must not pin files that no longer exist (or wedge pruning forever). Clamping to the oldest -// stored index also establishes the invariant pruneSealedFiles relies on: a reservation never falls below the -// lowest stored index, so a file entirely below the lowest reservation is one every iterator has moved past. -func (w *walImpl) pinLowestReadableIndex(startIndex uint64) uint64 { - pinned := startIndex - if r := w.bounds(); r.ok && r.first > pinned { - pinned = r.first +// pinLowestReadableIndex records a read lease at index, clamped up to the oldest stored index so no reservation +// ever falls below it (the invariant pruneSealedFiles relies on). pinned is false when nothing is stored, in +// which case no lease is registered and the caller must not release index. +func (w *walImpl) pinLowestReadableIndex(startIndex uint64) (index uint64, pinned bool) { + r := w.bounds() + if !r.ok { + return 0, false + } + index = startIndex + if r.first > index { + index = r.first } - w.indexRefs[pinned]++ - return pinned + w.indexRefs[index]++ + return index, true } // releaseIndex drops one reference to a leased index, forgetting it once the count reaches zero. diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 4e438b724f..32f2e21372 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -432,6 +432,77 @@ func TestPrunePastAllRecordsEmptiesRange(t *testing.T) { require.False(t, ok) } +// TestIteratorOnEmptyWALDoesNotBlockPruning covers an iterator created over a fresh, empty WAL: its file +// snapshot is empty, so it protects nothing and must register no read lease. If it did (pinning the raw +// startIndex 0), that reservation would sit below every future record and wedge all pruning while the iterator +// stayed open. Here the iterator is deliberately held open across later appends and a prune, and pruning must +// still make progress. +func TestIteratorOnEmptyWALDoesNotBlockPruning(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per sealed file, so pruning works file-by-file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + // Create the iterator while the WAL is empty and hold it open (never closed until the end). + it, err := w.Iterator(0) + require.NoError(t, err) + ok, err := it.Next() + require.NoError(t, err) + require.False(t, ok, "an iterator over an empty WAL yields no records") + + for index := uint64(1); index <= 10; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + require.NoError(t, w.Prune(5)) + stored, first, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, stored) + require.Equal(t, uint64(5), first, "the empty-snapshot iterator must not pin index 0 and block pruning") + require.Equal(t, uint64(10), last) + + require.NoError(t, it.Close()) +} + +// TestEmptyWALIteratorCloseDoesNotReleaseAnotherLease guards the release path for the empty-snapshot iterator: +// because it registers no lease, its Close must not decrement indexRefs, or it could release a lease another +// iterator legitimately holds at the same index (index 0 is a valid start). Here a live iterator pins index 0; +// closing the empty-WAL iterator must leave that lease intact so index 0's file survives pruning. +func TestEmptyWALIteratorCloseDoesNotReleaseAnotherLease(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per sealed file + + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + // Created while empty: no lease registered. + empty, err := w.Iterator(0) + require.NoError(t, err) + + for index := uint64(0); index <= 9; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + // Created after data exists: this one legitimately pins index 0. + pinned, err := w.Iterator(0) + require.NoError(t, err) + defer func() { require.NoError(t, pinned.Close()) }() + + // Closing the empty-snapshot iterator must not disturb the live lease at index 0. + require.NoError(t, empty.Close()) + + require.NoError(t, w.Prune(100)) + stored, first, _, err := w.Bounds() + require.NoError(t, err) + require.True(t, stored, "the live lease at index 0 must keep records from being fully pruned") + require.Equal(t, uint64(0), first, "closing the empty-WAL iterator must not release the other iterator's lease") +} + func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index e83006b830..b47e80e898 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -43,9 +43,14 @@ type walIterator struct { // The lowest index the consumer asked for; records below it are skipped. start uint64 - // The index pinned as this iterator's read lease, released on Close. + // The index pinned as this iterator's read lease, released on Close. Only meaningful when hasPin is true. pinnedIndex uint64 + // Whether a read lease was registered for this iterator. False when the iterator was created over an empty + // file snapshot (nothing to protect from pruning); Close then skips the release so it cannot decrement a + // lease another iterator holds at the same index. + hasPin bool + // Records produced by the reader goroutine. Closed by the reader on clean EOF. results chan iteratorResult @@ -80,13 +85,15 @@ type walIterator struct { } // newWalIterator creates an iterator over wal starting at startIndex and launches its reader goroutine. -// pinnedIndex is the read lease registered on the iterator's behalf, released by Close. files is the snapshot -// of files to read (captured on the writer goroutine). prefetch is the number of records the reader may buffer -// ahead of the consumer. +// pinnedIndex is the read lease registered on the iterator's behalf, released by Close; hasPin reports whether +// a lease was actually registered (false for an empty file snapshot, in which case Close registers no release). +// files is the snapshot of files to read (captured on the writer goroutine). prefetch is the number of records +// the reader may buffer ahead of the consumer. func newWalIterator( wal *walImpl, startIndex uint64, pinnedIndex uint64, + hasPin bool, files []iteratorFile, prefetch uint, ) *walIterator { @@ -94,6 +101,7 @@ func newWalIterator( wal: wal, start: startIndex, pinnedIndex: pinnedIndex, + hasPin: hasPin, results: make(chan iteratorResult, prefetch), stop: make(chan struct{}), readerExited: make(chan struct{}), @@ -150,7 +158,9 @@ func (it *walIterator) Close() error { it.closeOnce.Do(func() { close(it.stop) // tell the reader to stop if it is mid-read <-it.readerExited // wait for it to actually exit before releasing resources - it.wal.unpinIndex(it.pinnedIndex) + if it.hasPin { + it.wal.unpinIndex(it.pinnedIndex) + } }) it.done = true return nil From f61ec795889e016437c600ec76dec522a3541c51 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 10 Jul 2026 14:02:03 -0500 Subject: [PATCH 24/44] make suggested change --- sei-db/state_db/statewal/state_wal.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index 35e87dc7b3..be010560cc 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -44,8 +44,8 @@ type StateWAL interface { // crash durable. SignalEndOfBlock() error - // Flush the WAL to disk. All data previously passed to Write() before this call will be crash durable - // after this call returns. + // Flush the WAL to disk. Only completed blocks — those for which SignalEndOfBlock has been called — are + // made crash durable; changes for a block that has not yet been ended remain buffered and are not flushed. Flush() error // Get the range of block numbers stored in the WAL. From a7836a375d278904fb7c68fadee7294e0bcbd65d Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 10 Jul 2026 15:20:07 -0500 Subject: [PATCH 25/44] brick on first failure --- .../state_db/statewal/state_wal_brick_test.go | 163 ++++++++++++++++++ sei-db/state_db/statewal/state_wal_impl.go | 89 ++++++++-- 2 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 sei-db/state_db/statewal/state_wal_brick_test.go diff --git a/sei-db/state_db/statewal/state_wal_brick_test.go b/sei-db/state_db/statewal/state_wal_brick_test.go new file mode 100644 index 0000000000..749e6ec8c3 --- /dev/null +++ b/sei-db/state_db/statewal/state_wal_brick_test.go @@ -0,0 +1,163 @@ +package statewal + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/seiwal" +) + +// errInjected is the sentinel failure returned by fakeWAL to simulate a fatal underlying-WAL error. +var errInjected = errors.New("injected failure") + +// fakeWAL is a seiwal.WAL whose methods return an injected error when the corresponding field is set, +// letting tests drive each of the wrapper's fatal error paths. Errors are set after construction so the +// initial Bounds call in newStateWAL succeeds. +type fakeWAL struct { + appendErr error + flushErr error + boundsErr error + pruneErr error + iteratorErr error + + // The indices successfully appended, so tests can assert that a failed append persisted nothing. + appended []uint64 +} + +var _ seiwal.WAL[[]*proto.NamedChangeSet] = (*fakeWAL)(nil) + +func (f *fakeWAL) Append(index uint64, data []*proto.NamedChangeSet) error { + if f.appendErr != nil { + return f.appendErr + } + f.appended = append(f.appended, index) + return nil +} + +func (f *fakeWAL) Flush() error { + return f.flushErr +} + +func (f *fakeWAL) Bounds() (bool, uint64, uint64, error) { + if f.boundsErr != nil { + return false, 0, 0, f.boundsErr + } + return false, 0, 0, nil +} + +func (f *fakeWAL) Prune(lowestIndexToKeep uint64) error { + return f.pruneErr +} + +func (f *fakeWAL) Iterator(startIndex uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { + if f.iteratorErr != nil { + return nil, f.iteratorErr + } + return nil, nil +} + +func (f *fakeWAL) Close() error { + return nil +} + +func newFakeStateWAL(t *testing.T, f *fakeWAL) StateWAL { + t.Helper() + w, err := newStateWAL(f) + require.NoError(t, err) + return w +} + +// requireBricked asserts that every operation fails with the fatal error the WAL was bricked by. +func requireBricked(t *testing.T, w StateWAL) { + t.Helper() + require.ErrorIs(t, w.Write(999, nil), errInjected) + require.ErrorIs(t, w.SignalEndOfBlock(), errInjected) + require.ErrorIs(t, w.Flush(), errInjected) + _, _, _, err := w.GetStoredRange() + require.ErrorIs(t, err, errInjected) + require.ErrorIs(t, w.Prune(1), errInjected) + _, err = w.Iterator(0) + require.ErrorIs(t, err, errInjected) +} + +// TestFatalErrorsBrickWAL verifies that a fatal error from any underlying-WAL operation permanently +// bricks the wrapper, so every subsequent operation fails fast rather than limping onward. +func TestFatalErrorsBrickWAL(t *testing.T) { + t.Run("append", func(t *testing.T) { + f := &fakeWAL{} + w := newFakeStateWAL(t, f) + f.appendErr = errInjected + require.NoError(t, w.Write(1, nil)) + require.ErrorIs(t, w.SignalEndOfBlock(), errInjected) + requireBricked(t, w) + }) + + t.Run("flush", func(t *testing.T) { + f := &fakeWAL{} + w := newFakeStateWAL(t, f) + f.flushErr = errInjected + require.ErrorIs(t, w.Flush(), errInjected) + requireBricked(t, w) + }) + + t.Run("bounds", func(t *testing.T) { + f := &fakeWAL{} + w := newFakeStateWAL(t, f) + f.boundsErr = errInjected + _, _, _, err := w.GetStoredRange() + require.ErrorIs(t, err, errInjected) + requireBricked(t, w) + }) + + t.Run("prune", func(t *testing.T) { + f := &fakeWAL{} + w := newFakeStateWAL(t, f) + f.pruneErr = errInjected + require.ErrorIs(t, w.Prune(1), errInjected) + requireBricked(t, w) + }) + + t.Run("iterator", func(t *testing.T) { + f := &fakeWAL{} + w := newFakeStateWAL(t, f) + f.iteratorErr = errInjected + _, err := w.Iterator(0) + require.ErrorIs(t, err, errInjected) + requireBricked(t, w) + }) +} + +// TestAppendFailureDoesNotSilentlyAdvance verifies the Bugbot scenario: when the append for a block fails, +// the block is not silently finalized. The WAL is bricked, so a write to the next block is rejected rather +// than skipping the lost block. +func TestAppendFailureDoesNotSilentlyAdvance(t *testing.T) { + f := &fakeWAL{appendErr: errInjected} + w := newFakeStateWAL(t, f) + + require.NoError(t, w.Write(1, nil)) + require.ErrorIs(t, w.SignalEndOfBlock(), errInjected) + + require.ErrorIs(t, w.Write(2, nil), errInjected) + require.Empty(t, f.appended) +} + +// TestCallerViolationsDoNotBrick verifies that a caller-contract rejection (an out-of-order write) leaves +// the WAL fully usable, since it corrupts no state. +func TestCallerViolationsDoNotBrick(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + writeBlock(t, w, 5) + require.Error(t, w.Write(4, nil)) // block numbers may not decrease + + // The WAL is not bricked: a subsequent valid write still succeeds and is durable. + writeBlock(t, w, 6) + require.NoError(t, w.Flush()) + ok, _, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(6), end) +} diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 5ddcb42c91..398c83098c 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -20,6 +20,11 @@ type stateWALImpl struct { // Set by Close() so subsequent Write/SignalEndOfBlock calls fail fast. closed atomic.Bool + // The first fatal error from the underlying WAL that bricked this one, surfaced to the caller by every + // subsequent operation. Once set, no operation touches the underlying WAL, so a corrupt WAL never + // limps onward. + asyncErr atomic.Pointer[error] + // The write-ordering contract state and the accumulation buffer below are mutated by Write and // SignalEndOfBlock, which callers must not invoke concurrently. @@ -79,6 +84,9 @@ func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) err if w.closed.Load() { return fmt.Errorf("state WAL is closed") } + if err := w.asyncError(); err != nil { + return fmt.Errorf("state WAL failed: %w", err) + } if err := w.enforceWriteOrdering(blockNumber); err != nil { return fmt.Errorf("write rejected: %w", err) } @@ -91,18 +99,21 @@ func (w *stateWALImpl) SignalEndOfBlock() error { if w.closed.Load() { return fmt.Errorf("state WAL is closed") } + if err := w.asyncError(); err != nil { + return fmt.Errorf("state WAL failed: %w", err) + } if !w.hasCurrentBlock || w.currentBlockEnded { return fmt.Errorf("no block in progress to end") } - blockNumber := w.currentBlock - w.currentBlockEnded = true - changeset := w.buf - w.buf = nil // hand ownership to the WAL; the next block starts a fresh buffer - if err := w.wal.Append(blockNumber, changeset); err != nil { - return fmt.Errorf("failed to append block %d: %w", blockNumber, err) + // Commit the finalization state only after the append succeeds; a failed append bricks the WAL and + // leaves the block in progress rather than silently finalizing a block whose changesets were lost. + if err := w.wal.Append(w.currentBlock, w.buf); err != nil { + return w.fail(fmt.Errorf("failed to append block %d: %w", w.currentBlock, err)) } + w.currentBlockEnded = true + w.buf = nil // hand ownership to the WAL; the next block starts a fresh buffer return nil } @@ -136,28 +147,84 @@ func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { // Flush blocks until all previously scheduled writes are durable. func (w *stateWALImpl) Flush() error { - return w.wal.Flush() + if w.closed.Load() { + return fmt.Errorf("state WAL is closed") + } + if err := w.asyncError(); err != nil { + return fmt.Errorf("state WAL failed: %w", err) + } + if err := w.wal.Flush(); err != nil { + return w.fail(fmt.Errorf("failed to flush state WAL: %w", err)) + } + return nil } // GetStoredRange reports the range of complete blocks stored in the WAL. func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { - return w.wal.Bounds() + if w.closed.Load() { + return false, 0, 0, fmt.Errorf("state WAL is closed") + } + if err := w.asyncError(); err != nil { + return false, 0, 0, fmt.Errorf("state WAL failed: %w", err) + } + ok, first, last, err := w.wal.Bounds() + if err != nil { + return false, 0, 0, w.fail(fmt.Errorf("failed to read WAL bounds: %w", err)) + } + return ok, first, last, nil } // Prune schedules removal of whole underlying files below lowestBlockNumberToKeep. It does not block on // completion. func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { - return w.wal.Prune(lowestBlockNumberToKeep) + if w.closed.Load() { + return fmt.Errorf("state WAL is closed") + } + if err := w.asyncError(); err != nil { + return fmt.Errorf("state WAL failed: %w", err) + } + if err := w.wal.Prune(lowestBlockNumberToKeep); err != nil { + return w.fail(fmt.Errorf("failed to prune state WAL: %w", err)) + } + return nil } // Iterator returns an iterator over the WAL starting at startingBlockNumber. It yields (blockNumber, // changesets) directly from the underlying generic WAL. func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { - return w.wal.Iterator(startingBlockNumber) + if w.closed.Load() { + return nil, fmt.Errorf("state WAL is closed") + } + if err := w.asyncError(); err != nil { + return nil, fmt.Errorf("state WAL failed: %w", err) + } + it, err := w.wal.Iterator(startingBlockNumber) + if err != nil { + return nil, w.fail(fmt.Errorf("failed to create WAL iterator: %w", err)) + } + return it, nil } // Close flushes pending writes, closes the underlying WAL, and releases resources. func (w *stateWALImpl) Close() error { w.closed.Store(true) - return w.wal.Close() + if err := w.wal.Close(); err != nil { + return fmt.Errorf("failed to close state WAL: %w", err) + } + return nil +} + +// fail records err as the first fatal error that bricks the WAL and returns it. Once set, every +// subsequent operation fails fast rather than touching the underlying WAL. +func (w *stateWALImpl) fail(err error) error { + w.asyncErr.CompareAndSwap(nil, &err) + return err +} + +// asyncError returns the first fatal error that bricked the WAL, or nil if none occurred. +func (w *stateWALImpl) asyncError() error { + if p := w.asyncErr.Load(); p != nil { + return *p + } + return nil } From 70ec467e4465c2411147d28e8e3bdfd396196c24 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 13 Jul 2026 09:44:45 -0500 Subject: [PATCH 26/44] threading model fixes --- sei-db/seiwal/seiwal_impl.go | 72 ++++++++++++---------- sei-db/seiwal/seiwal_impl_test.go | 2 +- sei-db/seiwal/seiwal_iterator_test.go | 2 +- sei-db/seiwal/seiwal_serializing.go | 70 +++++++++++---------- sei-db/state_db/statewal/state_wal_impl.go | 60 ++++++++---------- 5 files changed, 105 insertions(+), 101 deletions(-) diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index d8ce0ebfac..0532f4e0d7 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -7,7 +7,6 @@ import ( "path/filepath" "sort" "sync" - "sync/atomic" "time" "go.opentelemetry.io/otel/metric" @@ -92,17 +91,18 @@ type walImpl struct { // the writer goroutine. writerChan chan any - // The hard-stop context the writer watches. Cancelled by fail() on a fatal error and by Close() once - // everything has drained. + // The hard-stop context the writer watches. Cancelled by fail() with the fatal error as its cause, and + // by Close() (with a nil cause) once everything has drained. The cause carries the fatal error to + // callers, so no separate error field is needed. ctx context.Context - // Cancels ctx, tearing down the writer goroutine. - cancel context.CancelFunc + // Cancels ctx, tearing down the writer goroutine, recording the fatal error (or nil) as the cause. + cancel context.CancelCauseFunc // A child of ctx that the writerChan producers watch, cancelled once the writer stops reading so an // in-flight or future push aborts rather than deadlocking. senderCtx context.Context // Cancels senderCtx. - senderCancel context.CancelFunc + senderCancel context.CancelCauseFunc // Tracks the writer and queue-depth sampler goroutines so Close() can wait for them to exit. wg sync.WaitGroup @@ -113,11 +113,9 @@ type walImpl struct { // Guarantees the Close() shutdown sequence runs at most once. closeOnce sync.Once - // Set by Close() so subsequent scheduling calls fail fast. - closed atomic.Bool - - // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). - asyncErr atomic.Pointer[error] + // Set by Close() so subsequent scheduling calls fail fast. Plain: calling any method after Close is a + // contract violation, so this need not be atomic. + closed bool // The index of the most recently appended record. lastAppendIndex uint64 @@ -200,8 +198,8 @@ func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { return nil, fmt.Errorf("failed to open mutable WAL file: %w", err) } - ctx, cancel := context.WithCancel(context.Background()) - senderCtx, senderCancel := context.WithCancel(ctx) + ctx, cancel := context.WithCancelCause(context.Background()) + senderCtx, senderCancel := context.WithCancelCause(ctx) w := &walImpl{ config: config, @@ -257,16 +255,9 @@ func (w *walImpl) sampleQueueDepth(interval time.Duration) { // Append frames a record and schedules it for the writer, after enforcing that indices strictly increase. func (w *walImpl) Append(index uint64, data []byte) error { - if w.closed.Load() { + if w.closed { return fmt.Errorf("WAL is closed") } - // Fail fast on a bricked WAL. Without this, a fire-and-forget append can win the sendToWriter select - // against the already-cancelled senderCtx and enqueue onto a dead writer's buffer, silently dropping the - // record and returning nil. asyncErr is recorded before the cancel, so any caller that has observed the - // shutdown also observes the error here. - if err := w.asyncError(); err != nil { - return fmt.Errorf("WAL failed: %w", err) - } if w.hasAppended && index <= w.lastAppendIndex { return fmt.Errorf( @@ -356,7 +347,7 @@ func (w *walImpl) unpinIndex(index uint64) { func (w *walImpl) Close() error { var closeErr error w.closeOnce.Do(func() { - w.closed.Store(true) + w.closed = true close(w.samplerStop) // stop the queue-depth sampler before waiting for goroutines done := make(chan error, 1) if err := w.sendToWriter(closeRequest{done: done}); err == nil { @@ -366,7 +357,7 @@ func (w *walImpl) Close() error { } } w.wg.Wait() - w.cancel() + w.cancel(nil) // a clean close carries no fatal cause; a prior fail() already recorded one }) if err := w.asyncError(); err != nil { return fmt.Errorf("WAL closed with error: %w", err) @@ -377,15 +368,28 @@ func (w *walImpl) Close() error { // sendToWriter enqueues a message onto the writer's input channel, aborting if the WAL is shutting down or has // failed. func (w *walImpl) sendToWriter(msg any) error { + // Prioritize shutdown: if the sender context is already done, never race the send case of the select + // below, which could otherwise enqueue onto a stopped writer's buffer and silently drop the record. + select { + case <-w.senderCtx.Done(): + return w.senderErr() + default: + } select { case w.writerChan <- msg: return nil case <-w.senderCtx.Done(): - if err := w.asyncError(); err != nil { - return fmt.Errorf("WAL failed: %w", err) - } - return fmt.Errorf("WAL is closed") + return w.senderErr() + } +} + +// senderErr reports why a send was aborted: the fatal cause if the WAL bricked, or a plain closed error if it +// was shut down normally. +func (w *walImpl) senderErr() error { + if cause := context.Cause(w.senderCtx); cause != nil && cause != context.Canceled { + return fmt.Errorf("WAL failed: %w", cause) } + return fmt.Errorf("WAL is closed") } // writerLoop consumes messages, appending records to the mutable file and handling control messages. It owns @@ -434,7 +438,7 @@ func (w *walImpl) writerLoop() { m.done <- err // FIFO guarantees every prior append has been processed. Forbid further pushes so any // racing/future schedule aborts instead of deadlocking against the now-exiting writer. - w.senderCancel() + w.senderCancel(nil) // normal shutdown, not a failure return } } @@ -630,20 +634,20 @@ func (w *walImpl) bounds() storedRange { return r } -// fail records the first fatal background error and triggers shutdown of the pipeline. +// fail records the first fatal background error and triggers shutdown of the pipeline. The error is recorded +// as the cancellation cause of ctx, so callers observe it via asyncError / context.Cause. func (w *walImpl) fail(err error) { - w.asyncErr.CompareAndSwap(nil, &err) - w.cancel() + w.cancel(err) // the first cancel wins, so the first fatal error is the one retained if cerr := w.mutableFile.close(); cerr != nil { logger.Error("failed to close mutable WAL file after fatal error", "err", cerr) } logger.Error("WAL encountered a fatal error", "err", err) } -// asyncError returns the first fatal background error, or nil if none occurred. +// asyncError returns the first fatal background error, or nil if the WAL is healthy or was closed normally. func (w *walImpl) asyncError() error { - if p := w.asyncErr.Load(); p != nil { - return *p + if cause := context.Cause(w.ctx); cause != nil && cause != context.Canceled { + return cause } return nil } diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 32f2e21372..d553cb852a 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -201,7 +201,7 @@ func TestFailReleasesMutableFile(t *testing.T) { dir := t.TempDir() mf, err := newWalFile(dir, 0) require.NoError(t, err) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancelCause(context.Background()) w := &walImpl{ config: testConfig(dir), metricAttrs: walNameAttr("test"), diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index 05afd4af5b..882a8db242 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -151,7 +151,7 @@ func TestIteratorReaderExitsWhenWALTornDownWhileOrphaned(t *testing.T) { // Do not consume the iterator: the reader fills the prefetch buffer (size 1) and blocks on send. Tear // down the WAL context out from under it, as fail() or Close() would. - w.(*walImpl).cancel() + w.(*walImpl).cancel(nil) select { case <-iter.readerExited: diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index 80a6470987..a9ae523c48 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "sync" - "sync/atomic" "time" "go.opentelemetry.io/otel/metric" @@ -76,17 +75,18 @@ type serializingWAL[T any] struct { // Caller entry points funnel through serializerChan as a single ordered stream to the serializer. serializerChan chan any - // The hard-stop context the serializer watches. Cancelled by fail() on a fatal error and by Close() once - // everything has drained. + // The hard-stop context the serializer watches. Cancelled by fail() with the fatal error as its cause, + // and by Close() (with a nil cause) once everything has drained. The cause carries the fatal error to + // callers, so no separate error field is needed. ctx context.Context - // Cancels ctx, tearing down the serializer goroutine. - cancel context.CancelFunc + // Cancels ctx, tearing down the serializer goroutine, recording the fatal error (or nil) as the cause. + cancel context.CancelCauseFunc // A child of ctx that the serializerChan producers watch, cancelled once the serializer stops reading so // an in-flight or future push aborts rather than deadlocking. senderCtx context.Context // Cancels senderCtx. - senderCancel context.CancelFunc + senderCancel context.CancelCauseFunc // Tracks the serializer and queue-depth sampler goroutines so Close() can wait for them to exit. wg sync.WaitGroup @@ -97,11 +97,9 @@ type serializingWAL[T any] struct { // Guarantees the Close() shutdown sequence runs at most once. closeOnce sync.Once - // Set by Close() so subsequent scheduling calls fail fast. - closed atomic.Bool - - // The first unrecoverable background-goroutine error, surfaced to the caller by Close(). - asyncErr atomic.Pointer[error] + // Set by Close() so subsequent scheduling calls fail fast. Plain: calling any method after Close is a + // contract violation, so this need not be atomic. + closed bool } // NewGenericWAL opens a WAL over payloads of type T that does serialization on a background goroutine. @@ -138,8 +136,8 @@ func newSerializingWAL[T any]( serialize func(T) ([]byte, error), deserialize func([]byte) (T, error), ) *serializingWAL[T] { - ctx, cancel := context.WithCancel(context.Background()) - senderCtx, senderCancel := context.WithCancel(ctx) + ctx, cancel := context.WithCancelCause(context.Background()) + senderCtx, senderCancel := context.WithCancelCause(ctx) s := &serializingWAL[T]{ inner: inner, @@ -186,14 +184,9 @@ func (s *serializingWAL[T]) sampleQueueDepth(name string, interval time.Duration // Append schedules a payload to be serialized and appended at the given index. func (s *serializingWAL[T]) Append(index uint64, data T) error { - if s.closed.Load() { + if s.closed { return fmt.Errorf("WAL is closed") } - // Fail fast on a bricked WAL, so a fire-and-forget append cannot win the submit select against the - // cancelled senderCtx and be silently dropped onto a dead serializer (see walImpl.Append for detail). - if err := s.asyncError(); err != nil { - return fmt.Errorf("WAL failed: %w", err) - } req := serAppend{ index: index, serialize: func() ([]byte, error) { return s.serialize(data) }, @@ -274,7 +267,7 @@ func (s *serializingWAL[T]) Iterator(startIndex uint64) (Iterator[T], error) { func (s *serializingWAL[T]) Close() error { var closeErr error s.closeOnce.Do(func() { - s.closed.Store(true) + s.closed = true close(s.samplerStop) // stop the queue-depth sampler before waiting for goroutines done := make(chan error, 1) if err := s.submit(serClose{done: done}); err == nil { @@ -284,7 +277,7 @@ func (s *serializingWAL[T]) Close() error { } } s.wg.Wait() - s.cancel() + s.cancel(nil) // a clean close carries no fatal cause; a prior fail() already recorded one }) if err := s.asyncError(); err != nil { return fmt.Errorf("WAL closed with error: %w", err) @@ -295,15 +288,28 @@ func (s *serializingWAL[T]) Close() error { // submit enqueues a message onto the serializer's input channel, aborting if the WAL is shutting down or has // failed. func (s *serializingWAL[T]) submit(msg any) error { + // Prioritize shutdown: if the sender context is already done, never race the send case of the select + // below, which could otherwise enqueue onto a stopped serializer's buffer and silently drop the record. + select { + case <-s.senderCtx.Done(): + return s.senderErr() + default: + } select { case s.serializerChan <- msg: return nil case <-s.senderCtx.Done(): - if err := s.asyncError(); err != nil { - return fmt.Errorf("WAL failed: %w", err) - } - return fmt.Errorf("WAL is closed") + return s.senderErr() + } +} + +// senderErr reports why a submit was aborted: the fatal cause if the WAL bricked, or a plain closed error if +// it was shut down normally. +func (s *serializingWAL[T]) senderErr() error { + if cause := context.Cause(s.senderCtx); cause != nil && cause != context.Canceled { + return fmt.Errorf("WAL failed: %w", cause) } + return fmt.Errorf("WAL is closed") } // serializerLoop serializes each append's payload and delegates it to the inner WAL, handling control @@ -356,26 +362,26 @@ func (s *serializingWAL[T]) serializerLoop() { m.done <- s.inner.Close() // FIFO guarantees every prior append has been delegated. Forbid further pushes so any // racing/future schedule aborts instead of deadlocking against the now-exiting serializer. - s.senderCancel() + s.senderCancel(nil) // normal shutdown, not a failure return } } } -// fail records the first fatal background error and triggers shutdown of the pipeline. +// fail records the first fatal background error and triggers shutdown of the pipeline. The error is recorded +// as the cancellation cause of ctx, so callers observe it via asyncError / context.Cause. func (s *serializingWAL[T]) fail(err error) { - s.asyncErr.CompareAndSwap(nil, &err) - s.cancel() + s.cancel(err) // the first cancel wins, so the first fatal error is the one retained if cerr := s.inner.Close(); cerr != nil { logger.Error("failed to close inner WAL after fatal error", "err", cerr) } logger.Error("serializing WAL encountered a fatal error", "err", err) } -// asyncError returns the first fatal background error, or nil if none occurred. +// asyncError returns the first fatal background error, or nil if the WAL is healthy or was closed normally. func (s *serializingWAL[T]) asyncError() error { - if p := s.asyncErr.Load(); p != nil { - return *p + if cause := context.Cause(s.ctx); cause != nil && cause != context.Canceled { + return cause } return nil } diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 398c83098c..d025590b73 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -2,7 +2,6 @@ package statewal import ( "fmt" - "sync/atomic" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/seiwal" @@ -17,13 +16,14 @@ type stateWALImpl struct { // The underlying generic WAL, keyed by block number, whose payload is a block's changesets. wal seiwal.WAL[[]*proto.NamedChangeSet] - // Set by Close() so subsequent Write/SignalEndOfBlock calls fail fast. - closed atomic.Bool + // Set by Close() so subsequent calls fail fast. A plain field: like the write-ordering state below, it + // is only ever touched by the single caller, which must not invoke methods concurrently. + closed bool // The first fatal error from the underlying WAL that bricked this one, surfaced to the caller by every // subsequent operation. Once set, no operation touches the underlying WAL, so a corrupt WAL never - // limps onward. - asyncErr atomic.Pointer[error] + // limps onward. Caller-serialized like closed. + fatalErr error // The write-ordering contract state and the accumulation buffer below are mutated by Write and // SignalEndOfBlock, which callers must not invoke concurrently. @@ -81,11 +81,11 @@ func newStateWAL(wal seiwal.WAL[[]*proto.NamedChangeSet]) (StateWAL, error) { // Write accumulates a set of changes for the given block number in memory. func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { - if w.closed.Load() { + if w.closed { return fmt.Errorf("state WAL is closed") } - if err := w.asyncError(); err != nil { - return fmt.Errorf("state WAL failed: %w", err) + if w.fatalErr != nil { + return fmt.Errorf("state WAL failed: %w", w.fatalErr) } if err := w.enforceWriteOrdering(blockNumber); err != nil { return fmt.Errorf("write rejected: %w", err) @@ -96,11 +96,11 @@ func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) err // SignalEndOfBlock appends the current block's accumulated changesets to the WAL as a single record. func (w *stateWALImpl) SignalEndOfBlock() error { - if w.closed.Load() { + if w.closed { return fmt.Errorf("state WAL is closed") } - if err := w.asyncError(); err != nil { - return fmt.Errorf("state WAL failed: %w", err) + if w.fatalErr != nil { + return fmt.Errorf("state WAL failed: %w", w.fatalErr) } if !w.hasCurrentBlock || w.currentBlockEnded { @@ -147,11 +147,11 @@ func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { // Flush blocks until all previously scheduled writes are durable. func (w *stateWALImpl) Flush() error { - if w.closed.Load() { + if w.closed { return fmt.Errorf("state WAL is closed") } - if err := w.asyncError(); err != nil { - return fmt.Errorf("state WAL failed: %w", err) + if w.fatalErr != nil { + return fmt.Errorf("state WAL failed: %w", w.fatalErr) } if err := w.wal.Flush(); err != nil { return w.fail(fmt.Errorf("failed to flush state WAL: %w", err)) @@ -161,11 +161,11 @@ func (w *stateWALImpl) Flush() error { // GetStoredRange reports the range of complete blocks stored in the WAL. func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { - if w.closed.Load() { + if w.closed { return false, 0, 0, fmt.Errorf("state WAL is closed") } - if err := w.asyncError(); err != nil { - return false, 0, 0, fmt.Errorf("state WAL failed: %w", err) + if w.fatalErr != nil { + return false, 0, 0, fmt.Errorf("state WAL failed: %w", w.fatalErr) } ok, first, last, err := w.wal.Bounds() if err != nil { @@ -177,11 +177,11 @@ func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { // Prune schedules removal of whole underlying files below lowestBlockNumberToKeep. It does not block on // completion. func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { - if w.closed.Load() { + if w.closed { return fmt.Errorf("state WAL is closed") } - if err := w.asyncError(); err != nil { - return fmt.Errorf("state WAL failed: %w", err) + if w.fatalErr != nil { + return fmt.Errorf("state WAL failed: %w", w.fatalErr) } if err := w.wal.Prune(lowestBlockNumberToKeep); err != nil { return w.fail(fmt.Errorf("failed to prune state WAL: %w", err)) @@ -192,11 +192,11 @@ func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { // Iterator returns an iterator over the WAL starting at startingBlockNumber. It yields (blockNumber, // changesets) directly from the underlying generic WAL. func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { - if w.closed.Load() { + if w.closed { return nil, fmt.Errorf("state WAL is closed") } - if err := w.asyncError(); err != nil { - return nil, fmt.Errorf("state WAL failed: %w", err) + if w.fatalErr != nil { + return nil, fmt.Errorf("state WAL failed: %w", w.fatalErr) } it, err := w.wal.Iterator(startingBlockNumber) if err != nil { @@ -207,7 +207,7 @@ func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]* // Close flushes pending writes, closes the underlying WAL, and releases resources. func (w *stateWALImpl) Close() error { - w.closed.Store(true) + w.closed = true if err := w.wal.Close(); err != nil { return fmt.Errorf("failed to close state WAL: %w", err) } @@ -217,14 +217,8 @@ func (w *stateWALImpl) Close() error { // fail records err as the first fatal error that bricks the WAL and returns it. Once set, every // subsequent operation fails fast rather than touching the underlying WAL. func (w *stateWALImpl) fail(err error) error { - w.asyncErr.CompareAndSwap(nil, &err) - return err -} - -// asyncError returns the first fatal error that bricked the WAL, or nil if none occurred. -func (w *stateWALImpl) asyncError() error { - if p := w.asyncErr.Load(); p != nil { - return *p + if w.fatalErr == nil { + w.fatalErr = err } - return nil + return err } From 5eef620d6b3136fbfc2a72861da2aece6680054d Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 13 Jul 2026 12:12:47 -0500 Subject: [PATCH 27/44] check gaps --- sei-db/seiwal/seiwal.go | 10 ++- sei-db/seiwal/seiwal_config.go | 7 ++ sei-db/seiwal/seiwal_impl.go | 33 +++++-- sei-db/seiwal/seiwal_impl_test.go | 85 ++++++++++++++++--- sei-db/seiwal/seiwal_serializing.go | 6 +- sei-db/seiwal/seiwal_serializing_test.go | 8 +- .../state_db/statewal/state_wal_brick_test.go | 2 +- sei-db/state_db/statewal/state_wal_config.go | 14 +-- sei-db/state_db/statewal/state_wal_impl.go | 6 +- .../state_db/statewal/state_wal_impl_test.go | 8 ++ 10 files changed, 139 insertions(+), 40 deletions(-) diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index b76a1e15f0..ca1301746b 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -19,8 +19,10 @@ type WAL[T any] interface { // Append a record with the given index and payload. // - // The index must be strictly greater than the index of the most recently appended record (indices - // need not be contiguous, but they must strictly increase). + // The required relationship between successive indices depends on Config.PermitGaps. When PermitGaps is + // false (the default), each index must be exactly one greater than the previous (strictly contiguous). + // When PermitGaps is true, each index need only be strictly greater than the previous, so gaps are + // allowed. In either case the first append on a fresh WAL may use any index, which sets the baseline. // // This method only schedules the append; it does not block until the record is durable. Durability is // achieved by a subsequent Flush. @@ -46,13 +48,13 @@ type WAL[T any] interface { err error, ) - // Prune removes all records with an index less than lowestIndexToKeep. + // PruneBefore removes all records with an index less than lowestIndexToKeep. // // This method merely schedules the prune; it does not block until the prune is complete. Pruning is // async and lazy, and implementations are free to delay it arbitrarily long. Pruning removes whole // sealed files only, so records may survive above the requested threshold until their containing file // is fully below it. - Prune(lowestIndexToKeep uint64) error + PruneBefore(lowestIndexToKeep uint64) error // Iterator returns an iterator over the WAL starting at the given index. // diff --git a/sei-db/seiwal/seiwal_config.go b/sei-db/seiwal/seiwal_config.go index 3487522e0d..eb9882cb2b 100644 --- a/sei-db/seiwal/seiwal_config.go +++ b/sei-db/seiwal/seiwal_config.go @@ -38,6 +38,12 @@ type Config struct { // merely a process crash. When false, Flush only flushes the in-process buffer to the OS. FsyncOnFlush bool + // When false, appended indices must be strictly contiguous: each index must equal the + // previous index plus one. The first append on a fresh WAL may start at any index and establishes the + // baseline; after recovery the baseline is the highest stored index. When true, indices need only be + // strictly increasing, so gaps between consecutive indices are permitted. + PermitGaps bool + // The number of records an iterator's reader thread may prefetch ahead of the consumer. A larger value // keeps the reader busy while the consumer processes records, which matters for startup replay speed. // Must be greater than 0. @@ -57,6 +63,7 @@ func DefaultConfig(path string, name string) *Config { SerializerBufferSize: 16, TargetFileSize: 64 * unit.MB, FsyncOnFlush: true, + PermitGaps: false, IteratorPrefetchSize: 32, MetricsSampleInterval: 15 * time.Second, } diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 0532f4e0d7..28f8cb1bef 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -259,9 +259,18 @@ func (w *walImpl) Append(index uint64, data []byte) error { return fmt.Errorf("WAL is closed") } - if w.hasAppended && index <= w.lastAppendIndex { - return fmt.Errorf( - "append rejected: index %d is not greater than last appended index %d", index, w.lastAppendIndex) + if w.hasAppended { + if w.config.PermitGaps { + if index <= w.lastAppendIndex { + return fmt.Errorf( + "append rejected: index %d is not greater than last appended index %d", + index, w.lastAppendIndex) + } + } else if index != w.lastAppendIndex+1 { + return fmt.Errorf( + "append rejected: index %d is not contiguous with last appended index %d (expected %d)", + index, w.lastAppendIndex, w.lastAppendIndex+1) + } } w.lastAppendIndex = index w.hasAppended = true @@ -307,8 +316,8 @@ func (w *walImpl) Bounds() (bool, uint64, uint64, error) { } } -// Prune schedules removal of whole sealed files below lowestIndexToKeep. It does not block on completion. -func (w *walImpl) Prune(lowestIndexToKeep uint64) error { +// PruneBefore schedules removal of whole sealed files below lowestIndexToKeep. It does not block on completion. +func (w *walImpl) PruneBefore(lowestIndexToKeep uint64) error { if err := w.sendToWriter(pruneRequest{through: lowestIndexToKeep}); err != nil { return fmt.Errorf("failed to schedule prune below index %d: %w", lowestIndexToKeep, err) } @@ -450,9 +459,17 @@ func (w *walImpl) appendRecord(m dataToBeWritten) error { // Authoritative ordering check: the caller-side gate in Append can be bypassed by concurrent callers // (the WAL is documented single-threaded), so re-assert strict increase here on the one writer // goroutine to reject a reordered record rather than write a file with inverted index bounds. - if w.hasWritten && m.index <= w.lastWrittenIndex { - return fmt.Errorf("append out of order: index %d is not greater than last written index %d", - m.index, w.lastWrittenIndex) + if w.hasWritten { + if w.config.PermitGaps { + if m.index <= w.lastWrittenIndex { + return fmt.Errorf("append out of order: index %d is not greater than last written index %d", + m.index, w.lastWrittenIndex) + } + } else if m.index != w.lastWrittenIndex+1 { + return fmt.Errorf( + "append out of order: index %d is not contiguous with last written index %d (expected %d)", + m.index, w.lastWrittenIndex, w.lastWrittenIndex+1) + } } if err := w.mutableFile.writeRecord(m.record, m.index); err != nil { return fmt.Errorf("failed to append record for index %d: %w", m.index, err) diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index d553cb852a..e16652fe6c 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -131,17 +131,45 @@ func TestAppendFlushReopenBounds(t *testing.T) { } func TestAppendOrdering(t *testing.T) { - t.Run("index must strictly increase", func(t *testing.T) { + t.Run("contiguous indices are required by default", func(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Append(5, recordPayload(5))) - require.Error(t, w.Append(4, recordPayload(4))) - require.Error(t, w.Append(5, recordPayload(5))) + require.NoError(t, w.Append(5, recordPayload(5))) // first append sets the baseline + require.Error(t, w.Append(4, recordPayload(4))) // lower than the last index + require.Error(t, w.Append(5, recordPayload(5))) // equal to the last index + require.Error(t, w.Append(7, recordPayload(7))) // a gap: not exactly last+1 + require.NoError(t, w.Append(6, recordPayload(6))) // contiguous }) - t.Run("non-contiguous indices are allowed", func(t *testing.T) { + t.Run("first append may start at any index", func(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() + require.NoError(t, w.Append(12345, recordPayload(12345))) + require.NoError(t, w.Append(12346, recordPayload(12346))) + require.Error(t, w.Append(12348, recordPayload(12348))) + }) + + t.Run("contiguity resumes after reopen", func(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + w := openWAL(t, cfg) + for index := uint64(1); index <= 3; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + require.NoError(t, w.Close()) + + w2 := openWAL(t, cfg) + defer func() { require.NoError(t, w2.Close()) }() + require.Error(t, w2.Append(5, recordPayload(5))) // a gap after the recovered baseline of 3 + require.NoError(t, w2.Append(4, recordPayload(4))) // contiguous with the recovered baseline + }) + + t.Run("non-contiguous indices are allowed when gaps permitted", func(t *testing.T) { + cfg := testConfig(t.TempDir()) + cfg.PermitGaps = true + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() require.NoError(t, w.Append(1, recordPayload(1))) require.NoError(t, w.Append(3, recordPayload(3))) require.NoError(t, w.Append(100, recordPayload(100))) @@ -192,9 +220,37 @@ func TestWriterRejectsOutOfOrderRecord(t *testing.T) { require.Error(t, write(4)) // lower than last written require.Error(t, write(5)) // equal to last written require.NoError(t, write(6)) + require.Error(t, write(8)) // a gap: not exactly last+1 (the default forbids gaps) require.Equal(t, uint64(6), w.lastWrittenIndex) } +// TestWriterBackstopPermitsGapsWhenConfigured verifies that with PermitGaps enabled the writer-goroutine +// backstop only rejects non-increasing indices, allowing gaps. +func TestWriterBackstopPermitsGapsWhenConfigured(t *testing.T) { + dir := t.TempDir() + mf, err := newWalFile(dir, 0) + require.NoError(t, err) + cfg := testConfig(dir) + cfg.PermitGaps = true + w := &walImpl{ + config: cfg, + metricAttrs: walNameAttr("test"), + ctx: context.Background(), + mutableFile: mf, + } + defer func() { _, _ = w.mutableFile.seal() }() + + write := func(index uint64) error { + return w.appendRecord(dataToBeWritten{record: frameRecord(index, recordPayload(index)), index: index}) + } + + require.NoError(t, write(5)) + require.NoError(t, write(9)) // a gap is allowed when gaps are permitted + require.Error(t, write(9)) // equal to last written + require.Error(t, write(3)) // lower than last written + require.Equal(t, uint64(9), w.lastWrittenIndex) +} + // TestFailReleasesMutableFile verifies that a fatal error releases the mutable file's handle (rather than // leaking the fd until process exit) and that the release is idempotent. func TestFailReleasesMutableFile(t *testing.T) { @@ -403,7 +459,7 @@ func TestPruneDropsWholeFiles(t *testing.T) { } require.NoError(t, w.Flush()) - require.NoError(t, w.Prune(5)) + require.NoError(t, w.PruneBefore(5)) ok, first, last, err := w.Bounds() require.NoError(t, err) @@ -425,7 +481,7 @@ func TestPrunePastAllRecordsEmptiesRange(t *testing.T) { } require.NoError(t, w.Flush()) - require.NoError(t, w.Prune(100)) + require.NoError(t, w.PruneBefore(100)) ok, _, _, err := w.Bounds() require.NoError(t, err) @@ -457,7 +513,7 @@ func TestIteratorOnEmptyWALDoesNotBlockPruning(t *testing.T) { } require.NoError(t, w.Flush()) - require.NoError(t, w.Prune(5)) + require.NoError(t, w.PruneBefore(5)) stored, first, last, err := w.Bounds() require.NoError(t, err) require.True(t, stored) @@ -496,7 +552,7 @@ func TestEmptyWALIteratorCloseDoesNotReleaseAnotherLease(t *testing.T) { // Closing the empty-snapshot iterator must not disturb the live lease at index 0. require.NoError(t, empty.Close()) - require.NoError(t, w.Prune(100)) + require.NoError(t, w.PruneBefore(100)) stored, first, _, err := w.Bounds() require.NoError(t, err) require.True(t, stored, "the live lease at index 0 must keep records from being fully pruned") @@ -519,7 +575,7 @@ func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { it, err := w.Iterator(1) require.NoError(t, err) - require.NoError(t, w.Prune(5)) + require.NoError(t, w.PruneBefore(5)) ok, first, last, err := w.Bounds() require.NoError(t, err) require.True(t, ok) @@ -531,7 +587,7 @@ func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { // Releasing the lease lets the same prune make progress. require.NoError(t, it.Close()) - require.NoError(t, w.Prune(5)) + require.NoError(t, w.PruneBefore(5)) ok, first, _, err = w.Bounds() require.NoError(t, err) require.True(t, ok) @@ -555,7 +611,7 @@ func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() - require.NoError(t, w.Prune(5)) + require.NoError(t, w.PruneBefore(5)) ok, first, _, err := w.Bounds() require.NoError(t, err) require.True(t, ok) @@ -571,6 +627,7 @@ func TestIteratorInGapBlocksPruningAcrossGap(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) cfg.TargetFileSize = 1 // one record per sealed file + cfg.PermitGaps = true // this test exercises index gaps w := openWAL(t, cfg) defer func() { require.NoError(t, w.Close()) }() @@ -586,7 +643,7 @@ func TestIteratorInGapBlocksPruningAcrossGap(t *testing.T) { // Prune(12) would remove every file with last index < 12, but the live lease at 5 must keep the files for // indices 10 and 11 (both >= 5). Only the files entirely below the lease (indices 1,2,3) may be dropped. - require.NoError(t, w.Prune(12)) + require.NoError(t, w.PruneBefore(12)) _, _, _, err = w.Bounds() // synchronous round-trip forces the async prune to complete require.NoError(t, err) @@ -617,7 +674,7 @@ func TestIteratorLeaseInsideFileRangeBlocksPruning(t *testing.T) { require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() - require.NoError(t, w.Prune(8)) + require.NoError(t, w.PruneBefore(8)) ok, first, last, err := w.Bounds() require.NoError(t, err) require.True(t, ok) diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index a9ae523c48..add7c360df 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -234,8 +234,8 @@ func (s *serializingWAL[T]) Bounds() (bool, uint64, uint64, error) { } } -// Prune schedules removal of whole inner files below lowestIndexToKeep. It does not block on completion. -func (s *serializingWAL[T]) Prune(lowestIndexToKeep uint64) error { +// PruneBefore schedules removal of whole inner files below lowestIndexToKeep. It does not block on completion. +func (s *serializingWAL[T]) PruneBefore(lowestIndexToKeep uint64) error { if err := s.submit(serPrune{through: lowestIndexToKeep}); err != nil { return fmt.Errorf("failed to schedule prune below index %d: %w", lowestIndexToKeep, err) } @@ -351,7 +351,7 @@ func (s *serializingWAL[T]) serializerLoop() { ok, first, last, err := s.inner.Bounds() m.reply <- serBoundsResult{ok: ok, first: first, last: last, err: err} case serPrune: - if err := s.inner.Prune(m.through); err != nil { + if err := s.inner.PruneBefore(m.through); err != nil { s.fail(fmt.Errorf("failed to prune below index %d: %w", m.through, err)) return } diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go index be8900d225..dc311ba474 100644 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -110,12 +110,14 @@ func TestGenericWALFlushIOFailureBricksWAL(t *testing.T) { } func TestGenericWALRoundTrip(t *testing.T) { - w := openStringWAL(t, testConfig(t.TempDir())) + cfg := testConfig(t.TempDir()) + cfg.PermitGaps = true + w := openStringWAL(t, cfg) defer func() { require.NoError(t, w.Close()) }() require.NoError(t, w.Append(1, "one")) require.NoError(t, w.Append(2, "two")) - require.NoError(t, w.Append(5, "five")) // non-contiguous index is allowed + require.NoError(t, w.Append(5, "five")) // non-contiguous index is allowed when gaps are permitted require.NoError(t, w.Flush()) ok, first, last, err := w.Bounds() @@ -161,7 +163,7 @@ func TestGenericWALPrune(t *testing.T) { } require.NoError(t, w.Flush()) - require.NoError(t, w.Prune(5)) + require.NoError(t, w.PruneBefore(5)) ok, first, last, err := w.Bounds() require.NoError(t, err) diff --git a/sei-db/state_db/statewal/state_wal_brick_test.go b/sei-db/state_db/statewal/state_wal_brick_test.go index 749e6ec8c3..2e3e578d7c 100644 --- a/sei-db/state_db/statewal/state_wal_brick_test.go +++ b/sei-db/state_db/statewal/state_wal_brick_test.go @@ -48,7 +48,7 @@ func (f *fakeWAL) Bounds() (bool, uint64, uint64, error) { return false, 0, 0, nil } -func (f *fakeWAL) Prune(lowestIndexToKeep uint64) error { +func (f *fakeWAL) PruneBefore(lowestIndexToKeep uint64) error { return f.pruneErr } diff --git a/sei-db/state_db/statewal/state_wal_config.go b/sei-db/state_db/statewal/state_wal_config.go index 7b5bfa1700..6aa7eec9bc 100644 --- a/sei-db/state_db/statewal/state_wal_config.go +++ b/sei-db/state_db/statewal/state_wal_config.go @@ -64,12 +64,14 @@ func (c *Config) Validate() error { // toSeiwalConfig maps this configuration onto the underlying generic WAL's configuration. func (c *Config) toSeiwalConfig() *seiwal.Config { return &seiwal.Config{ - Path: c.Path, - Name: c.Name, - WriteBufferSize: c.WriteBufferSize, - SerializerBufferSize: c.RequestBufferSize, - TargetFileSize: c.TargetFileSize, - FsyncOnFlush: c.FsyncOnFlush, + Path: c.Path, + Name: c.Name, + WriteBufferSize: c.WriteBufferSize, + SerializerBufferSize: c.RequestBufferSize, + TargetFileSize: c.TargetFileSize, + FsyncOnFlush: c.FsyncOnFlush, + // State blocks must be contiguous — no skipped blocks — so the underlying WAL rejects gaps. + PermitGaps: false, IteratorPrefetchSize: c.IteratorPrefetchSize, MetricsSampleInterval: c.MetricsSampleInterval, } diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index d025590b73..ba11f45ae2 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -140,6 +140,10 @@ func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { return fmt.Errorf( "cannot write block %d before calling SignalEndOfBlock for block %d", blockNumber, w.currentBlock) } + if blockNumber != w.currentBlock+1 { + return fmt.Errorf("block number %d is not contiguous with the current block number %d (expected %d)", + blockNumber, w.currentBlock, w.currentBlock+1) + } w.currentBlock = blockNumber w.currentBlockEnded = false return nil @@ -183,7 +187,7 @@ func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { if w.fatalErr != nil { return fmt.Errorf("state WAL failed: %w", w.fatalErr) } - if err := w.wal.Prune(lowestBlockNumberToKeep); err != nil { + if err := w.wal.PruneBefore(lowestBlockNumberToKeep); err != nil { return w.fail(fmt.Errorf("failed to prune state WAL: %w", err)) } return nil diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go index 6a20f6da38..8c5481a54d 100644 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ b/sei-db/state_db/statewal/state_wal_impl_test.go @@ -95,6 +95,14 @@ func TestContractViolations(t *testing.T) { require.Error(t, w.Write(2, nil)) }) + t.Run("cannot skip a block", func(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + writeBlock(t, w, 1) + require.Error(t, w.Write(3, nil)) // block 2 was skipped + require.NoError(t, w.Write(2, nil)) + }) + t.Run("cannot write to an ended block", func(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() From 869379e5bc7262f0ee29f0a635245f0c584a516f Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 13 Jul 2026 13:00:14 -0500 Subject: [PATCH 28/44] static setup methods --- sei-db/seiwal/seiwal_impl.go | 67 +++++++++++-------- sei-db/seiwal/seiwal_impl_test.go | 66 +++++++++++++++--- sei-db/seiwal/seiwal_offline.go | 54 +++++++++++++++ sei-db/seiwal/seiwal_serializing.go | 15 ----- sei-db/seiwal/seiwal_serializing_test.go | 4 +- sei-db/state_db/statewal/state_wal_impl.go | 33 +++++++-- .../state_db/statewal/state_wal_impl_test.go | 35 ++++++++-- 7 files changed, 205 insertions(+), 69 deletions(-) create mode 100644 sei-db/seiwal/seiwal_offline.go diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 28f8cb1bef..4a78954f99 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -150,47 +150,56 @@ type walImpl struct { // NewWAL opens (or creates) a byte-oriented WAL in the configured directory, recovering any files left // behind by a previous session. Operates on []byte payloads. func NewWAL(config *Config) (WAL[[]byte], error) { - return newWAL(config, nil) + return newWAL(config) } -// NewWALWithRollback opens a byte-oriented WAL and deletes all records with an index greater than -// rollbackIndex before returning, so the WAL contains no record with an index greater than rollbackIndex. -func NewWALWithRollback(config *Config, rollbackIndex uint64) (WAL[[]byte], error) { - return newWAL(config, &rollbackIndex) -} - -func newWAL(config *Config, rollbackThrough *uint64) (WAL[[]byte], error) { - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("invalid WAL config: %w", err) - } - if err := util.EnsureDirectoryExists(config.Path, true); err != nil { - return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) +// recoverDirectory brings a WAL directory into a clean, consistent on-disk state: it removes crash remnants +// from an interrupted rollback and seals any unsealed file left behind by a prior session. After it returns, +// every record lives in a sealed file whose name matches its content, with no orphans remaining. Shared by +// the WAL constructor and the offline GetRange/PruneAfter utilities, so all three run the same sanity pass. +func recoverDirectory(path string) error { + if err := util.EnsureDirectoryExists(path, true); err != nil { + return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) } - // Clean up remnants of a rollback swap interrupted by a crash before scanning (see rollbackStraddlingFile): // a leftover swap file from an unfinished AtomicWrite, or two sealed files sharing a sequence because the old // file was not yet removed. This leaves a set where every sealed sequence is unique and name matches content. - if err := util.DeleteOrphanedSwapFiles(config.Path); err != nil { - return nil, fmt.Errorf("failed to delete orphaned swap files: %w", err) - } - if err := reconcileRollbackRemnants(config.Path); err != nil { - return nil, fmt.Errorf("failed to reconcile rollback remnants: %w", err) + if err := util.DeleteOrphanedSwapFiles(path); err != nil { + return fmt.Errorf("failed to delete orphaned swap files: %w", err) } - if err := recoverOrphans(config.Path); err != nil { - return nil, fmt.Errorf("failed to recover orphaned WAL files: %w", err) + if err := reconcileRollbackRemnants(path); err != nil { + return fmt.Errorf("failed to reconcile rollback remnants: %w", err) } - if rollbackThrough != nil { - if err := rollbackDirectory(config.Path, *rollbackThrough); err != nil { - return nil, fmt.Errorf("failed to roll back WAL beyond index %d: %w", *rollbackThrough, err) - } + if err := recoverOrphans(path); err != nil { + return fmt.Errorf("failed to recover orphaned WAL files: %w", err) } + return nil +} - sealedFiles, nextFileSeq, err := scanSealedFiles(config.Path) +// scanAndValidate loads the sealed files in a directory (ascending) and verifies each one's content covers +// exactly the index range its name promises, returning the sequence to assign the next mutable file. Call it +// after recoverDirectory, when no unsealed files remain. +func scanAndValidate(path string) (*util.RandomAccessDeque[*sealedFileInfo], uint64, error) { + sealedFiles, nextFileSeq, err := scanSealedFiles(path) if err != nil { - return nil, fmt.Errorf("failed to scan sealed WAL files: %w", err) + return nil, 0, fmt.Errorf("failed to scan sealed WAL files: %w", err) } - if err := validateSealedFiles(config.Path, sealedFiles); err != nil { - return nil, fmt.Errorf("corrupt sealed WAL file: %w", err) + if err := validateSealedFiles(path, sealedFiles); err != nil { + return nil, 0, fmt.Errorf("corrupt sealed WAL file: %w", err) + } + return sealedFiles, nextFileSeq, nil +} + +func newWAL(config *Config) (WAL[[]byte], error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid WAL config: %w", err) + } + if err := recoverDirectory(config.Path); err != nil { + return nil, err + } + sealedFiles, nextFileSeq, err := scanAndValidate(config.Path) + if err != nil { + return nil, err } mutable, err := newWalFile(config.Path, nextFileSeq) diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index e16652fe6c..8d7381abc9 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -806,7 +806,55 @@ func TestBoundsEmpty(t *testing.T) { require.False(t, ok) } -func TestRollbackConstructor(t *testing.T) { +func TestGetRange(t *testing.T) { + t.Run("empty directory reports no records", func(t *testing.T) { + ok, _, _, err := GetRange(t.TempDir()) + require.NoError(t, err) + require.False(t, ok) + }) + + t.Run("reports the range of a cleanly closed WAL", func(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + ok, first, last, err := GetRange(dir) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(5), last) + }) + + t.Run("seals an unsealed orphan then reports the range", func(t *testing.T) { + dir := t.TempDir() + // Fabricate an orphaned unsealed file (a crash before sealing), records 1..3 intact. + f, err := newWalFile(dir, 0) + require.NoError(t, err) + writeRecordTo(t, f, 1, recordPayload(1)) + writeRecordTo(t, f, 2, recordPayload(2)) + writeRecordTo(t, f, 3, recordPayload(3)) + require.NoError(t, f.flush(true)) + require.NoError(t, f.file.Close()) + + ok, first, last, err := GetRange(dir) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(3), last) + // GetRange sealed the orphan, so the directory now holds only the sealed file. + require.Equal(t, []string{sealedFileName(0, 1, 3)}, sealedFileNames(t, dir)) + + // A subsequent normal open round-trips cleanly against the sealed range. + w := openWAL(t, testConfig(dir)) + defer func() { require.NoError(t, w.Close()) }() + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w, 1)) + }) +} + +func TestPruneAfter(t *testing.T) { t.Run("drops whole files beyond the rollback point", func(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -818,8 +866,8 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewWALWithRollback(cfg, 3) - require.NoError(t, err) + require.NoError(t, PruneAfter(cfg.Path, 3)) + w2 := openWAL(t, cfg) defer func() { require.NoError(t, w2.Close()) }() ok, first, last, err := w2.Bounds() @@ -840,8 +888,8 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewWALWithRollback(cfg, 3) - require.NoError(t, err) + require.NoError(t, PruneAfter(cfg.Path, 3)) + w2 := openWAL(t, cfg) defer func() { require.NoError(t, w2.Close()) }() ok, first, last, err := w2.Bounds() @@ -883,9 +931,7 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewWALWithRollback(cfg, 3) - require.NoError(t, err) - require.NoError(t, w2.Close()) + require.NoError(t, PruneAfter(cfg.Path, 3)) // Reopen normally; the rollback must have durably and consistently reduced the range to [1,3]. w3 := openWAL(t, cfg) @@ -995,9 +1041,7 @@ func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { require.NoError(t, w2.Close()) // The subsequent rollback completes cleanly, and a normal reopen sees the consistent rolled-back range. - w3, err := NewWALWithRollback(cfg, 3) - require.NoError(t, err) - require.NoError(t, w3.Close()) + require.NoError(t, PruneAfter(cfg.Path, 3)) w4 := openWAL(t, cfg) defer func() { require.NoError(t, w4.Close()) }() diff --git a/sei-db/seiwal/seiwal_offline.go b/sei-db/seiwal/seiwal_offline.go new file mode 100644 index 0000000000..029d1e96ce --- /dev/null +++ b/sei-db/seiwal/seiwal_offline.go @@ -0,0 +1,54 @@ +package seiwal + +import "fmt" + +// GetRange reports the range of record indices stored in the WAL directory at path, without constructing a +// live WAL instance. It first runs the standard recovery/sanity pass (the same one the constructor uses), +// which SEALS any unsealed file left behind by a prior session and validates every sealed file — so this +// function mutates the directory on disk even though its name suggests a read. +// +// ok is false (and first/last are undefined) when the directory holds no records. +// +// NOT SAFE FOR CONCURRENT USE with a live WAL: it seals and validates files that a running WAL instance +// owns, so it must only be called while no WAL is open on the same directory (e.g. offline, at startup +// before NewWAL). Callers must serialize it against any other GetRange/PruneAfter on the same directory. +func GetRange(path string) (ok bool, first uint64, last uint64, err error) { + if err := recoverDirectory(path); err != nil { + return false, 0, 0, fmt.Errorf("failed to recover WAL directory: %w", err) + } + sealedFiles, _, err := scanAndValidate(path) + if err != nil { + return false, 0, 0, err + } + front, ok := sealedFiles.TryPeekFront() + if !ok { + return false, 0, 0, nil + } + back, _ := sealedFiles.TryPeekBack() + return true, front.firstIndex, back.lastIndex, nil +} + +// PruneAfter deletes every record with an index greater than highestIndexToKeep from the WAL directory at +// path — the offline rollback operation — without constructing a live WAL instance. It runs the standard +// recovery/sanity pass first (sealing any orphaned file), applies the rollback, then re-validates the result. +// +// This mirrors PruneBefore on the other end of the log: PruneBefore(lowestIndexToKeep) keeps indices >= its +// argument, while PruneAfter(highestIndexToKeep) keeps indices <= its argument. A crash mid-prune leaves a +// contiguous prefix of records, never a gap. +// +// NOT SAFE FOR CONCURRENT USE with a live WAL: it seals, rewrites, and removes files that a running WAL +// instance owns, so it must only be called while no WAL is open on the same directory (e.g. offline, at +// startup before NewWAL). Callers must serialize it against any other GetRange/PruneAfter on the same +// directory. +func PruneAfter(path string, highestIndexToKeep uint64) error { + if err := recoverDirectory(path); err != nil { + return fmt.Errorf("failed to recover WAL directory: %w", err) + } + if err := rollbackDirectory(path, highestIndexToKeep); err != nil { + return fmt.Errorf("failed to prune WAL entries after index %d: %w", highestIndexToKeep, err) + } + if _, _, err := scanAndValidate(path); err != nil { + return fmt.Errorf("WAL is corrupt after pruning: %w", err) + } + return nil +} diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index add7c360df..a205b7badc 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -115,21 +115,6 @@ func NewGenericWAL[T any]( return newSerializingWAL(config, inner, serialize, deserialize), nil } -// NewGenericWALWithRollback is like NewGenericWAL but first rolls the inner WAL back so it contains no record -// with an index greater than rollbackIndex. -func NewGenericWALWithRollback[T any]( - config *Config, - rollbackIndex uint64, - serialize func(T) ([]byte, error), - deserialize func([]byte) (T, error), -) (WAL[T], error) { - inner, err := NewWALWithRollback(config, rollbackIndex) - if err != nil { - return nil, fmt.Errorf("failed to open inner WAL: %w", err) - } - return newSerializingWAL(config, inner, serialize, deserialize), nil -} - func newSerializingWAL[T any]( config *Config, inner WAL[[]byte], diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go index dc311ba474..ad6dbcce82 100644 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -183,8 +183,8 @@ func TestGenericWALRollback(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewGenericWALWithRollback[string](cfg, 3, stringSerialize, stringDeserialize) - require.NoError(t, err) + require.NoError(t, PruneAfter(cfg.Path, 3)) + w2 := openStringWAL(t, cfg) defer func() { require.NoError(t, w2.Close()) }() ok, first, last, err := w2.Bounds() diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index ba11f45ae2..4da604b957 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -51,15 +51,34 @@ func New(config *Config) (StateWAL, error) { return newStateWAL(wal) } -// NewWithRollback opens a state WAL and deletes all data for blocks beyond rollbackBlockNumber before -// returning, so the WAL contains no block greater than rollbackBlockNumber. -func NewWithRollback(config *Config, rollbackBlockNumber uint64) (StateWAL, error) { - wal, err := seiwal.NewGenericWALWithRollback[[]*proto.NamedChangeSet]( - config.toSeiwalConfig(), rollbackBlockNumber, serializeChangesets, deserializeChangesets) +// GetRange reports the range of block numbers stored in the state WAL directory configured by config, +// without constructing a live StateWAL. Like the seiwal function it wraps, it runs the recovery/sanity pass +// (which seals any unsealed file left by a prior session) before reading, so it mutates the directory. +// +// NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with another GetRange/PruneAfter, on the same +// directory: it seals and validates files a running WAL owns. Call it only while no StateWAL is open there +// (e.g. offline, at startup before New). For a range query against a live WAL use the instance method +// GetStoredRange instead. +func GetRange(config *Config) (bool, uint64, uint64, error) { + ok, first, last, err := seiwal.GetRange(config.Path) if err != nil { - return nil, fmt.Errorf("failed to open state WAL: %w", err) + return false, 0, 0, fmt.Errorf("failed to read state WAL range: %w", err) } - return newStateWAL(wal) + return ok, first, last, nil +} + +// PruneAfter deletes all data for blocks after highestBlockToKeep from the state WAL directory configured by +// config, without constructing a live StateWAL. It runs the recovery/sanity pass, applies the rollback, and +// re-validates; blocks with a number <= highestBlockToKeep are kept. +// +// NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with another GetRange/PruneAfter, on the same +// directory: it seals, rewrites, and removes files a running WAL owns. Call it only while no StateWAL is open +// there (e.g. offline, at startup before New). +func PruneAfter(config *Config, highestBlockToKeep uint64) error { + if err := seiwal.PruneAfter(config.Path, highestBlockToKeep); err != nil { + return fmt.Errorf("failed to prune state WAL: %w", err) + } + return nil } func newStateWAL(wal seiwal.WAL[[]*proto.NamedChangeSet]) (StateWAL, error) { diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go index 8c5481a54d..6e7db7fd06 100644 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ b/sei-db/state_db/statewal/state_wal_impl_test.go @@ -215,9 +215,34 @@ func TestPruneDropsOldBlocks(t *testing.T) { require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 0)) } -// TestRollbackConstructor is a wrapper-level smoke test that NewWithRollback drops blocks beyond the rollback -// point end to end (the crash-safety details are exercised in the seiwal package). -func TestRollbackConstructor(t *testing.T) { +// TestGetRange is a wrapper-level smoke test that the standalone GetRange reports the stored block range of a +// directory without a live StateWAL (the seal/recovery details are exercised in the seiwal package). +func TestGetRange(t *testing.T) { + t.Run("empty directory reports no blocks", func(t *testing.T) { + ok, _, _, err := GetRange(testConfig(t.TempDir())) + require.NoError(t, err) + require.False(t, ok) + }) + + t.Run("reports the range of a cleanly closed WAL", func(t *testing.T) { + cfg := testConfig(t.TempDir()) + w := openWAL(t, cfg) + for block := uint64(1); block <= 4; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + ok, start, end, err := GetRange(cfg) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), start) + require.Equal(t, uint64(4), end) + }) +} + +// TestPruneAfter is a wrapper-level smoke test that PruneAfter drops blocks beyond the rollback point end to +// end (the crash-safety details are exercised in the seiwal package). +func TestPruneAfter(t *testing.T) { for _, tc := range []struct { name string targetSize uint @@ -236,8 +261,8 @@ func TestRollbackConstructor(t *testing.T) { } require.NoError(t, w.Close()) - w2, err := NewWithRollback(cfg, 3) - require.NoError(t, err) + require.NoError(t, PruneAfter(cfg, 3)) + w2 := openWAL(t, cfg) defer func() { require.NoError(t, w2.Close()) }() ok, start, end, err := w2.GetStoredRange() From 9c696be9ec382ab48fd89555d73b9e5b4d87da25 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 13 Jul 2026 13:09:09 -0500 Subject: [PATCH 29/44] static functions --- sei-db/seiwal/seiwal.go | 21 +++++++++++++++++++++ sei-db/seiwal/seiwal_impl.go | 6 ------ sei-db/seiwal/seiwal_serializing.go | 13 ------------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index ca1301746b..54c39aed6f 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -1,5 +1,7 @@ package seiwal +import "fmt" + // WAL is a generic, index-keyed, append-only write-ahead log over payloads of type T. // // Each record is tagged with a caller-provided monotonic index. The index is what makes garbage @@ -92,3 +94,22 @@ type Iterator[T any] interface { // Close releases the resources held by the iterator. Close() error } + +// NewWAL opens (or creates) a byte-oriented WAL in the configured directory, recovering any files left +// behind by a previous session. Operates on []byte payloads. +func NewWAL(config *Config) (WAL[[]byte], error) { + return newWAL(config) +} + +// NewGenericWAL opens a WAL over payloads of type T that does serialization on a background goroutine. +func NewGenericWAL[T any]( + config *Config, + serialize func(T) ([]byte, error), + deserialize func([]byte) (T, error), +) (WAL[T], error) { + inner, err := NewWAL(config) + if err != nil { + return nil, fmt.Errorf("failed to open inner WAL: %w", err) + } + return newSerializingWAL(config, inner, serialize, deserialize), nil +} diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 4a78954f99..89f34fba2a 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -147,12 +147,6 @@ type walImpl struct { indexRefs map[uint64]int } -// NewWAL opens (or creates) a byte-oriented WAL in the configured directory, recovering any files left -// behind by a previous session. Operates on []byte payloads. -func NewWAL(config *Config) (WAL[[]byte], error) { - return newWAL(config) -} - // recoverDirectory brings a WAL directory into a clean, consistent on-disk state: it removes crash remnants // from an interrupted rollback and seals any unsealed file left behind by a prior session. After it returns, // every record lives in a sealed file whose name matches its content, with no orphans remaining. Shared by diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index a205b7badc..68d41b6252 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -102,19 +102,6 @@ type serializingWAL[T any] struct { closed bool } -// NewGenericWAL opens a WAL over payloads of type T that does serialization on a background goroutine. -func NewGenericWAL[T any]( - config *Config, - serialize func(T) ([]byte, error), - deserialize func([]byte) (T, error), -) (WAL[T], error) { - inner, err := NewWAL(config) - if err != nil { - return nil, fmt.Errorf("failed to open inner WAL: %w", err) - } - return newSerializingWAL(config, inner, serialize, deserialize), nil -} - func newSerializingWAL[T any]( config *Config, inner WAL[[]byte], From 3a0d8262dd2cd9e2a4c2eb86baa525e77255fe51 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 13 Jul 2026 14:25:20 -0500 Subject: [PATCH 30/44] better iterators --- sei-db/seiwal/seiwal_file.go | 25 +++ sei-db/seiwal/seiwal_impl.go | 176 +++++++++------------ sei-db/seiwal/seiwal_impl_test.go | 246 +++++++++++++++++------------- sei-db/seiwal/seiwal_iterator.go | 78 ++++++---- 4 files changed, 287 insertions(+), 238 deletions(-) diff --git a/sei-db/seiwal/seiwal_file.go b/sei-db/seiwal/seiwal_file.go index 99a8582da6..0b4d02c474 100644 --- a/sei-db/seiwal/seiwal_file.go +++ b/sei-db/seiwal/seiwal_file.go @@ -31,6 +31,9 @@ const ( walSealedExtension = ".wal" // The serialization version written into each file's header. Bumped if the on-disk format changes. walFormatVersion = byte(1) + // The subdirectory of the WAL directory holding per-iterator hard-link snapshots. Each live iterator owns + // iterator//; the whole tree is blasted at startup (see recoverDirectory) and per-iterator on Close. + walIteratorDirName = "iterator" ) // The magic prefix written at the start of every WAL file, followed by a single format-version byte. @@ -44,6 +47,28 @@ var ( sealedFileRegex = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)\.wal$`) ) +// iteratorRoot is the directory under a WAL directory that holds every live iterator's hard-link snapshot. It +// is skipped by every WAL scanner because they ignore directory entries, so its contents never look like WAL +// files. +func iteratorRoot(directory string) string { + return filepath.Join(directory, walIteratorDirName) +} + +// iteratorLinkDir is the directory holding one iterator's hard-link snapshot, identified by its serial number. +func iteratorLinkDir(directory string, serial uint64) string { + return filepath.Join(iteratorRoot(directory), strconv.FormatUint(serial, 10)) +} + +// deleteIteratorLinks blasts the entire iterator hard-link tree. Called at startup to clear links left by a +// prior session: the links are ephemeral (never fsynced), so any survivor of a crash is safe to remove, and +// dropping a link to an already-pruned file simply reclaims its inode. +func deleteIteratorLinks(directory string) error { + if err := os.RemoveAll(iteratorRoot(directory)); err != nil { + return fmt.Errorf("failed to delete iterator link tree: %w", err) + } + return nil +} + // The result of parsing a WAL file name. type parsedFileName struct { fileSeq uint64 diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 89f34fba2a..0ac7d8ef79 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -45,14 +45,9 @@ type closeRequest struct { done chan error } -// unpinRequest releases a read lease previously registered when an iterator was created. -type unpinRequest struct { - index uint64 -} - -// iteratorStartRequest asks the writer to construct an iterator. The writer flushes the mutable file (so the -// iterator observes all prior appends), snapshots the current set of files, registers the read lease, and -// builds the iterator, all on its own goroutine so construction is serialized with rotation/seal/prune. +// iteratorStartRequest asks the writer to construct an iterator. The writer hard-links a point-in-time +// snapshot of the files to read and builds the iterator, all on its own goroutine so the snapshot is +// serialized with rotation/seal/prune. type iteratorStartRequest struct { startIndex uint64 reply chan iteratorStartResponse @@ -142,9 +137,9 @@ type walImpl struct { // Sealed files in ascending index order. Rotation appends to the back; pruning removes from the front. sealedFiles *util.RandomAccessDeque[*sealedFileInfo] - // Read leases held by live iterators: record index -> reference count. Pruning will not delete a file - // whose index range contains a leased index. Mutated only by the writer goroutine. - indexRefs map[uint64]int + // The serial number to assign the next iterator, naming its hard-link snapshot directory + // (iterator//). Mutated only by the writer goroutine. + nextIteratorSeq uint64 } // recoverDirectory brings a WAL directory into a clean, consistent on-disk state: it removes crash remnants @@ -155,6 +150,11 @@ func recoverDirectory(path string) error { if err := util.EnsureDirectoryExists(path, true); err != nil { return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) } + // Blast any hard-link snapshots left by iterators of a prior session; they are ephemeral read-side leases, + // never part of the durable WAL, so a crash survivor is always safe to remove. + if err := deleteIteratorLinks(path); err != nil { + return err + } // Clean up remnants of a rollback swap interrupted by a crash before scanning (see rollbackStraddlingFile): // a leftover swap file from an unfinished AtomicWrite, or two sealed files sharing a sequence because the old // file was not yet removed. This leaves a set where every sealed sequence is unique and name matches content. @@ -215,7 +215,6 @@ func newWAL(config *Config) (WAL[[]byte], error) { mutableFile: mutable, nextFileSeq: nextFileSeq + 1, sealedFiles: sealedFiles, - indexRefs: make(map[uint64]int), samplerStop: make(chan struct{}), } // Recover the append-ordering position from the highest index already on disk. @@ -328,9 +327,9 @@ func (w *walImpl) PruneBefore(lowestIndexToKeep uint64) error { } // Iterator returns an iterator over the WAL starting at startIndex. Construction runs on the writer goroutine -// (see iteratorStartRequest): the writer flushes so all previously scheduled appends are visible, registers a -// read lease so pruning cannot delete files out from under the iterator, and builds the iterator. The lease is -// released by the iterator's Close. +// (see iteratorStartRequest): the writer captures a hard-link snapshot of the files to read so later rotation +// and pruning cannot pull them out from under the iterator, and builds the iterator. The snapshot is removed +// by the iterator's Close. func (w *walImpl) Iterator(startIndex uint64) (Iterator[[]byte], error) { reply := make(chan iteratorStartResponse, 1) if err := w.sendToWriter(iteratorStartRequest{startIndex: startIndex, reply: reply}); err != nil { @@ -350,11 +349,6 @@ func (w *walImpl) Iterator(startIndex uint64) (Iterator[[]byte], error) { } } -// unpinIndex releases a read lease. Best-effort: if the WAL is already shutting down the lease is moot. -func (w *walImpl) unpinIndex(index uint64) { - _ = w.sendToWriter(unpinRequest{index: index}) -} - // Close flushes pending appends, seals the mutable file, and releases resources. func (w *walImpl) Close() error { var closeErr error @@ -443,8 +437,6 @@ func (w *walImpl) writerLoop() { w.fail(resp.err) return } - case unpinRequest: - w.releaseIndex(m.index) case closeRequest: _, err := w.mutableFile.seal() m.done <- err @@ -491,8 +483,8 @@ func (w *walImpl) appendRecord(m dataToBeWritten) error { } // rotate seals the current mutable file, records its bookkeeping, and opens a fresh mutable file. It is only -// called when the mutable file holds at least one record (immediately after an append, or from sealForIterator -// when it has records), so the seal always produces a sealed file rather than removing an empty one. +// called when the mutable file holds at least one record (immediately after a size-triggering append), so the +// seal always produces a sealed file rather than removing an empty one. func (w *walImpl) rotate() error { fileSeq := w.mutableFile.fileSeq first := w.mutableFile.firstIndex @@ -517,28 +509,18 @@ func (w *walImpl) rotate() error { // oldest-first (from the front of the deque) with a directory fsync after each removal, so a crash mid-prune // leaves a contiguous suffix of files rather than a gap in the sequence. The mutable file is never pruned. // -// A live iterator holds a read lease at some index R and may still read every record from R onward, so no file -// whose range reaches R or higher may be removed. A file [first, last] is needed iff it overlaps [R, ∞), i.e. -// iff last >= R. Comparing the lowest live reservation against each file's last index (rather than testing -// whether the reservation falls inside a file's range) protects exactly the files an iterator can still open — -// even when the reservation lands in a gap between files or strictly inside a file's range. Because -// reservations never fall below the lowest stored index (see pinLowestReadableIndex), a file left below the -// lowest reservation is one the iterator has already moved past and can safely be dropped. +// Pruning is unconditional with respect to live iterators: an iterator holds its own hard links to every file +// it reads (see startIterator), so removing a file's canonical name here only drops one link — the inode +// survives until the iterator's link is removed on Close. Pruning therefore need not know iterators exist. // -// Iteration stops at the first retained file: index ranges grow toward the back, so once a file is kept (by -// pruneThrough or by the lowest reservation) every later file is kept too. +// Iteration stops at the first retained file: index ranges grow toward the back, so once a file reaches +// pruneThrough every later file is kept too. func (w *walImpl) pruneSealedFiles(pruneThrough uint64) error { - // Reservations are mutated only on this (the writer) goroutine, so the lowest reservation is stable for the - // duration of this prune and can be computed once. - reservation, hasReservation := w.lowestReservation() for { front, ok := w.sealedFiles.TryPeekFront() if !ok || front.lastIndex >= pruneThrough { break } - if hasReservation && front.lastIndex >= reservation { - break // a live iterator may still read this file (or a later one); keep it and everything after - } path := filepath.Join(w.config.Path, front.name) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to prune WAL file %s: %w", path, err) @@ -552,84 +534,74 @@ func (w *walImpl) pruneSealedFiles(pruneThrough uint64) error { return nil } -// startIterator builds an iterator on the writer goroutine. It first seals the mutable file (see -// sealForIterator) so every record written so far lives in an immutable sealed file, then snapshots the sealed -// files in ascending index order, registers the read lease, and constructs the iterator (which launches its -// reader goroutine). Running here serializes construction with rotation, seal, and prune, so the snapshot is a -// consistent point-in-time view: every file the iterator reads is sealed and immutable, opened lazily by name -// and protected from pruning by the lease, so its contents cannot change underneath the reader. +// startIterator builds an iterator on the writer goroutine. It captures a point-in-time snapshot by +// hard-linking every file the iterator will read into a private directory (iterator//) and bounding it +// at the highest index stored now. Running here serializes the snapshot with rotation, seal, and prune. The +// live WAL may then rotate, seal, and prune freely: the iterator reads only its own hard links, which keep the +// underlying inodes alive until Close removes them. The mutable file is flushed (not fsynced) so its records +// are readable through the reader's separate handle — no crash can intervene between creation and use, so +// durability is irrelevant here. func (w *walImpl) startIterator(startIndex uint64) iteratorStartResponse { - if err := w.sealForIterator(); err != nil { - return iteratorStartResponse{err: fmt.Errorf("failed to seal mutable file before creating iterator: %w", err)} + r := w.bounds() + if !r.ok { + // Nothing stored: an empty iterator with no snapshot directory. + return iteratorStartResponse{iterator: newWalIterator(w, startIndex, 0, "", nil, w.config.IteratorPrefetchSize)} } + maxIndex := r.last - files := make([]iteratorFile, 0, w.sealedFiles.Size()) + // Gather the files to read: sealed files reaching startIndex, then the mutable file if it holds records at + // or above startIndex. Files entirely below startIndex are never opened, so they are not linked. The + // mutable snapshot's range is capped at maxIndex; the reader drops anything the writer appends past it. + var sources []iteratorFile for _, info := range w.sealedFiles.Iterator() { - files = append(files, iteratorFile{ - fileSeq: info.fileSeq, - name: info.name, - firstIndex: info.firstIndex, - lastIndex: info.lastIndex, + if info.lastIndex < startIndex { + continue + } + sources = append(sources, iteratorFile{ + fileSeq: info.fileSeq, name: info.name, + firstIndex: info.firstIndex, lastIndex: info.lastIndex, sealed: true, }) } - - pinned, hasPin := w.pinLowestReadableIndex(startIndex) - it := newWalIterator(w, startIndex, pinned, hasPin, files, w.config.IteratorPrefetchSize) - return iteratorStartResponse{iterator: it} -} - -// sealForIterator seals the mutable file so a newly-created iterator sees a snapshot that cannot change -// underneath it: after this call every record lives in an immutable sealed file. It is a no-op when the -// mutable file holds no records — the iterator reads only sealed files, so an empty mutable file is simply -// left in place. -func (w *walImpl) sealForIterator() error { - if !w.mutableFile.hasRecords { - return nil - } - if err := w.rotate(); err != nil { - return fmt.Errorf("failed to seal mutable file: %w", err) + if w.mutableFile.hasRecords && w.mutableFile.lastIndex >= startIndex { + if err := w.mutableFile.flush(false); err != nil { + return iteratorStartResponse{err: fmt.Errorf("failed to flush mutable file for iterator: %w", err)} + } + sources = append(sources, iteratorFile{ + fileSeq: w.mutableFile.fileSeq, name: unsealedFileName(w.mutableFile.fileSeq), + firstIndex: w.mutableFile.firstIndex, lastIndex: maxIndex, sealed: false, + }) } - return nil -} -// pinLowestReadableIndex records a read lease at index, clamped up to the oldest stored index so no reservation -// ever falls below it (the invariant pruneSealedFiles relies on). pinned is false when nothing is stored, in -// which case no lease is registered and the caller must not release index. -func (w *walImpl) pinLowestReadableIndex(startIndex uint64) (index uint64, pinned bool) { - r := w.bounds() - if !r.ok { - return 0, false - } - index = startIndex - if r.first > index { - index = r.first + if len(sources) == 0 { + // Nothing at or above startIndex to read; no snapshot directory needed. + it := newWalIterator(w, startIndex, maxIndex, "", nil, w.config.IteratorPrefetchSize) + return iteratorStartResponse{iterator: it} } - w.indexRefs[index]++ - return index, true -} -// releaseIndex drops one reference to a leased index, forgetting it once the count reaches zero. -func (w *walImpl) releaseIndex(index uint64) { - if w.indexRefs[index] <= 1 { - delete(w.indexRefs, index) - return + dir := iteratorLinkDir(w.config.Path, w.nextIteratorSeq) + if err := w.linkSnapshot(dir, sources); err != nil { + _ = os.RemoveAll(dir) + return iteratorStartResponse{err: err} } - w.indexRefs[index]-- + w.nextIteratorSeq++ + it := newWalIterator(w, startIndex, maxIndex, dir, sources, w.config.IteratorPrefetchSize) + return iteratorStartResponse{iterator: it} } -// lowestReservation returns the smallest index currently leased by a live iterator, and ok=false when no lease -// is held. A lease at index R means some iterator may still read records at or above R, so every sealed file -// whose range reaches R or higher must be retained by pruning. -func (w *walImpl) lowestReservation() (uint64, bool) { - var lowest uint64 - found := false - for index := range w.indexRefs { - if !found || index < lowest { - lowest = index - found = true +// linkSnapshot creates dir and hard-links each source file into it under the source's basename. The links keep +// the underlying inodes alive for the iterator even after the WAL rotates or prunes the originals. No fsync: +// the links are ephemeral read-side leases, reclaimed on Close or, after a crash, at the next open. +func (w *walImpl) linkSnapshot(dir string, sources []iteratorFile) error { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("failed to create iterator snapshot directory %s: %w", dir, err) + } + for _, f := range sources { + src := filepath.Join(w.config.Path, f.name) + if err := os.Link(src, filepath.Join(dir, f.name)); err != nil { + return fmt.Errorf("failed to hard-link %s for iterator: %w", f.name, err) } } - return lowest, found + return nil } // bounds reports the range of record indices across all files. Owned by the writer goroutine. diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 8d7381abc9..dd07d0ed8e 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -309,39 +309,6 @@ func TestFlushIOFailureBricksWAL(t *testing.T) { require.Error(t, impl.asyncError()) } -// TestIteratorRotateFailureBricksWAL verifies that when the rotation performed during iterator creation fails -// at opening the fresh mutable file (after the current file was already sealed), the WAL bricks itself rather -// than limping on with an inconsistent mutable file and later staging a phantom sealed entry. -func TestIteratorRotateFailureBricksWAL(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - - impl, ok := w.(*walImpl) - require.True(t, ok) - - require.NoError(t, w.Append(1, recordPayload(1))) - require.NoError(t, w.Flush()) - - // Make the rotation's newWalFile step fail while its seal step still succeeds: occupy the exact path the - // next mutable file wants (fileSeq 1 -> "1.wal.u") with a directory, so os.Create there fails with EISDIR. - // The seal renames the current file to "0-1-1.wal", unaffected by this blocker. - blocker := filepath.Join(dir, unsealedFileName(1)) - require.NoError(t, os.Mkdir(blocker, 0o755)) - - _, err := w.Iterator(1) - require.Error(t, err, "iterator creation must surface the rotation failure") - - select { - case <-impl.ctx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("WAL did not brick after rotation failure during iterator creation") - } - - require.Error(t, w.Append(2, recordPayload(2)), "appends must fail on a bricked WAL") - require.Error(t, w.Close(), "Close must surface the fatal error") - require.Error(t, impl.asyncError()) -} - func TestOrphanFileRecovery(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -489,10 +456,8 @@ func TestPrunePastAllRecordsEmptiesRange(t *testing.T) { } // TestIteratorOnEmptyWALDoesNotBlockPruning covers an iterator created over a fresh, empty WAL: its file -// snapshot is empty, so it protects nothing and must register no read lease. If it did (pinning the raw -// startIndex 0), that reservation would sit below every future record and wedge all pruning while the iterator -// stayed open. Here the iterator is deliberately held open across later appends and a prune, and pruning must -// still make progress. +// snapshot is empty (no hard links, no private directory), so it holds nothing on disk. Held open across later +// appends and a prune, it neither yields anything nor impedes pruning, which proceeds unconditionally. func TestIteratorOnEmptyWALDoesNotBlockPruning(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -523,43 +488,27 @@ func TestIteratorOnEmptyWALDoesNotBlockPruning(t *testing.T) { require.NoError(t, it.Close()) } -// TestEmptyWALIteratorCloseDoesNotReleaseAnotherLease guards the release path for the empty-snapshot iterator: -// because it registers no lease, its Close must not decrement indexRefs, or it could release a lease another -// iterator legitimately holds at the same index (index 0 is a valid start). Here a live iterator pins index 0; -// closing the empty-WAL iterator must leave that lease intact so index 0's file survives pruning. -func TestEmptyWALIteratorCloseDoesNotReleaseAnotherLease(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per sealed file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - // Created while empty: no lease registered. - empty, err := w.Iterator(0) - require.NoError(t, err) - - for index := uint64(0); index <= 9; index++ { - appendRecord(t, w, index) +// drainIndices reads an already-open iterator to exhaustion and returns the indices it yields. +func drainIndices(t *testing.T, it Iterator[[]byte]) []uint64 { + t.Helper() + var indices []uint64 + for { + ok, err := it.Next() + require.NoError(t, err) + if !ok { + break + } + index, _ := it.Entry() + indices = append(indices, index) } - require.NoError(t, w.Flush()) - - // Created after data exists: this one legitimately pins index 0. - pinned, err := w.Iterator(0) - require.NoError(t, err) - defer func() { require.NoError(t, pinned.Close()) }() - - // Closing the empty-snapshot iterator must not disturb the live lease at index 0. - require.NoError(t, empty.Close()) - - require.NoError(t, w.PruneBefore(100)) - stored, first, _, err := w.Bounds() - require.NoError(t, err) - require.True(t, stored, "the live lease at index 0 must keep records from being fully pruned") - require.Equal(t, uint64(0), first, "closing the empty-WAL iterator must not release the other iterator's lease") + return indices } -func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { +// TestActiveIteratorReadsThroughUnconditionalPruning verifies the hard-link snapshot model: pruning is +// unconditional (it advances Bounds and removes canonical files immediately, without regard for live +// iterators), yet an iterator opened before the prune still yields its full snapshot, because it reads through +// its own hard links whose inodes survive the prune. +func TestActiveIteratorReadsThroughUnconditionalPruning(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) cfg.TargetFileSize = 1 // one record per sealed file, so pruning works file-by-file @@ -571,27 +520,24 @@ func TestActiveIteratorBlocksPruningOfNeededFiles(t *testing.T) { } require.NoError(t, w.Flush()) - // Hold an iterator anchored at index 1 (the oldest). Its read lease must keep index 1's file alive. + // Snapshot indices 1..10 (hard-linked) at creation. it, err := w.Iterator(1) require.NoError(t, err) + // Pruning proceeds unconditionally: Bounds advances even though the iterator is live. require.NoError(t, w.PruneBefore(5)) ok, first, last, err := w.Bounds() require.NoError(t, err) require.True(t, ok) - require.Equal(t, uint64(1), first, "index 1 must survive pruning while a live iterator pins it") + require.Equal(t, uint64(5), first, "pruning advances the range immediately; it no longer waits for iterators") require.Equal(t, uint64(10), last) - // The iterator still sees the full, intact sequence. - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, collectIndices(t, w, 1)) - - // Releasing the lease lets the same prune make progress. + // The live iterator still yields the full intact sequence from its hard-link snapshot. + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, drainIndices(t, it)) require.NoError(t, it.Close()) - require.NoError(t, w.PruneBefore(5)) - ok, first, _, err = w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), first) + + // A fresh iterator sees only the post-prune range. + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0)) } func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { @@ -618,12 +564,10 @@ func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { require.Equal(t, uint64(5), first) } -// TestIteratorInGapBlocksPruningAcrossGap covers the index gap case: indices may jump, so an iterator's read -// lease can land in a gap between stored files. Pruning must still protect every file the iterator will read -// (those reaching the lease index or higher), even though no file's range contains the lease index itself. -// The directory is inspected directly rather than relying on iterator output, since the reader goroutine may -// have buffered the files into memory before an unsafe delete. -func TestIteratorInGapBlocksPruningAcrossGap(t *testing.T) { +// TestIteratorAcrossGapReadsThroughPruning covers the index-gap case: indices may jump, so an iterator's +// snapshot spans a gap between files. Unconditional pruning removes the canonical files, but the iterator +// reads its gap-spanning snapshot through its hard links. +func TestIteratorAcrossGapReadsThroughPruning(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) cfg.TargetFileSize = 1 // one record per sealed file @@ -631,34 +575,36 @@ func TestIteratorInGapBlocksPruningAcrossGap(t *testing.T) { w := openWAL(t, cfg) defer func() { require.NoError(t, w.Close()) }() - // Indices 1,2,3 then a legal jump to 10,11,12. The lease index 5 falls in the gap (3, 10). + // Indices 1,2,3 then a legal jump to 10,11,12. The start index 5 falls in the gap (3, 10). for _, index := range []uint64{1, 2, 3, 10, 11, 12} { appendRecord(t, w, index) } require.NoError(t, w.Flush()) + // Snapshot links the files reaching index 5 or higher: those for 10, 11, 12. Files 1,2,3 are below the + // start and are not linked. it, err := w.Iterator(5) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() - // Prune(12) would remove every file with last index < 12, but the live lease at 5 must keep the files for - // indices 10 and 11 (both >= 5). Only the files entirely below the lease (indices 1,2,3) may be dropped. + // Prune(12) removes every canonical file with last index < 12 (indices 1,2,3,10,11); only 12 remains named. require.NoError(t, w.PruneBefore(12)) - _, _, _, err = w.Bounds() // synchronous round-trip forces the async prune to complete + ok, first, last, err := w.Bounds() // synchronous round-trip forces the async prune to complete require.NoError(t, err) - + require.True(t, ok) + require.Equal(t, uint64(12), first) + require.Equal(t, uint64(12), last) names := sealedFileNames(t, dir) - require.Contains(t, names, sealedFileName(3, 10, 10), "file for index 10 must survive while iterator(5) is live") - require.Contains(t, names, sealedFileName(4, 11, 11), "file for index 11 must survive while iterator(5) is live") - require.NotContains(t, names, sealedFileName(0, 1, 1), "file for index 1 is below the lease and should be pruned") + require.NotContains(t, names, sealedFileName(3, 10, 10), "canonical file for index 10 is pruned") - require.Equal(t, []uint64{10, 11, 12}, collectIndices(t, w, 5)) + // The live iterator still yields the gap-spanning snapshot through its hard links. + require.Equal(t, []uint64{10, 11, 12}, drainIndices(t, it)) } -// TestIteratorLeaseInsideFileRangeBlocksPruning checks the boundary where the lease index sits within the kept -// window: an iterator anchored at 5 must keep indices 5..10 even as pruning is asked to drop through a higher -// point, because those files reach the lease index or higher. -func TestIteratorLeaseInsideFileRangeBlocksPruning(t *testing.T) { +// TestIteratorReadsThroughPruningPastAnchor checks the boundary where the start index sits within the kept +// window: an iterator anchored at 5 keeps yielding 5..10 through its hard links even as pruning removes the +// canonical files up through a higher point. +func TestIteratorReadsThroughPruningPastAnchor(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) cfg.TargetFileSize = 1 // one record per sealed file @@ -678,9 +624,103 @@ func TestIteratorLeaseInsideFileRangeBlocksPruning(t *testing.T) { ok, first, last, err := w.Bounds() require.NoError(t, err) require.True(t, ok) - require.Equal(t, uint64(5), first, "lease at 5 must keep records from 5 onward") + require.Equal(t, uint64(8), first, "pruning advances past the iterator's anchor unconditionally") require.Equal(t, uint64(10), last) - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 5)) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, drainIndices(t, it)) +} + +// TestIteratorDoesNotSealMutableFile verifies the core of the change: opening an iterator over records still +// in the mutable file reads them via a hard-link snapshot without sealing or rotating, so frequent iteration +// creates no sealed files and the mutable file keeps accepting contiguous appends. +func TestIteratorDoesNotSealMutableFile(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) // large default target: no size-based rotation + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 3; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + require.Equal(t, 0, countSealedFiles(t, dir), "records stay in the mutable file; nothing sealed yet") + + it, err := w.Iterator(1) + require.NoError(t, err) + require.Equal(t, 0, countSealedFiles(t, dir), "opening an iterator must not seal the mutable file") + require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) + require.NoError(t, it.Close()) + + // The mutable file is untouched, so appends continue contiguously. + appendRecord(t, w, 4) + require.NoError(t, w.Flush()) + require.Equal(t, []uint64{1, 2, 3, 4}, collectIndices(t, w, 1)) +} + +// TestIteratorExcludesRecordsAppendedAfterCreation verifies the point-in-time cap: records appended (and +// durably flushed) into the mutable file after the iterator was created are excluded, even though the reader's +// hard link points at the same, now-larger inode. +func TestIteratorExcludesRecordsAppendedAfterCreation(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 3; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) // snapshot caps at index 3 + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + appendRecord(t, w, 4) + appendRecord(t, w, 5) + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) +} + +// TestIteratorSnapshotDirLifecycle verifies that an iterator's private hard-link directory exists while it is +// open and is removed on Close. +func TestIteratorSnapshotDirLifecycle(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 3; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + + linkDir := iteratorLinkDir(dir, 0) // first iterator gets serial 0 + info, err := os.Stat(linkDir) + require.NoError(t, err, "the iterator's snapshot directory must exist while it is open") + require.True(t, info.IsDir()) + + require.NoError(t, it.Close()) + _, err = os.Stat(linkDir) + require.True(t, os.IsNotExist(err), "Close must remove the iterator's snapshot directory") +} + +// TestStartupBlastsIteratorLinks verifies that hard-link snapshots left by a crashed prior session are blasted +// at the next open. +func TestStartupBlastsIteratorLinks(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + for index := uint64(1); index <= 3; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + // Simulate iterator hard links left behind by a crash. + stray := iteratorLinkDir(dir, 7) + require.NoError(t, os.MkdirAll(stray, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(stray, "leftover"), []byte("x"), 0o600)) + + w2 := openWAL(t, testConfig(dir)) + defer func() { require.NoError(t, w2.Close()) }() + + _, err := os.Stat(iteratorRoot(dir)) + require.True(t, os.IsNotExist(err), "startup must blast the entire iterator link tree") } func TestScanRejectsGapInSealedFiles(t *testing.T) { diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index b47e80e898..51bf80a613 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -17,13 +17,16 @@ type iteratorResult struct { } // iteratorFile is one entry in an iterator's file snapshot, captured on the writer goroutine when the iterator -// is created (see startIterator). Every snapshot file is sealed and immutable: it carries its immutable name -// and is opened lazily by the reader, held against pruning by the iterator's read lease. +// is created (see startIterator). name is the file's basename inside the iterator's private hard-link +// directory. A sealed entry is immutable and verified against its [firstIndex, lastIndex] range; a non-sealed +// entry is the hard-linked mutable file, which may still be growing, so it is parsed torn-tolerantly and +// bounded by the iterator's maxIndex. type iteratorFile struct { fileSeq uint64 name string firstIndex uint64 lastIndex uint64 + sealed bool } // walIterator iterates the WAL a record at a time, in ascending index order. A dedicated reader goroutine @@ -32,10 +35,11 @@ type iteratorFile struct { // replay speed. The reader loads one file at a time, so its memory use is bounded by a single WAL file plus // the prefetch buffer. // -// The set of files to read is snapshotted once at creation (files), so the reader walks it in O(n) without -// re-scanning the directory. Every snapshot file is sealed and immutable (the mutable file is sealed at -// creation, see startIterator), so its contents cannot change under the reader. A read lease (pinnedIndex) -// holds the files the reader needs against concurrent pruning; Close releases it. +// The set of files to read is snapshotted once at creation as hard links in a private directory (dir); the +// reader walks that list in O(n) without re-scanning. The links keep their inodes alive against concurrent +// rotation and pruning, so the reader always finds its files; Close removes the directory. Records above +// maxIndex — the highest index stored at creation — are refused, giving a consistent point-in-time view even +// though the hard-linked mutable file may keep growing under the reader. type walIterator struct { // The WAL this iterator reads from. wal *walImpl @@ -43,13 +47,13 @@ type walIterator struct { // The lowest index the consumer asked for; records below it are skipped. start uint64 - // The index pinned as this iterator's read lease, released on Close. Only meaningful when hasPin is true. - pinnedIndex uint64 + // The highest index this iterator yields; records above it (appended after creation, or written to the + // hard-linked mutable file after the snapshot) are refused, fixing the point-in-time view. + maxIndex uint64 - // Whether a read lease was registered for this iterator. False when the iterator was created over an empty - // file snapshot (nothing to protect from pruning); Close then skips the release so it cannot decrement a - // lease another iterator holds at the same index. - hasPin bool + // The iterator's private directory of hard-link snapshots (iterator//), removed by Close. Empty + // when the iterator has no files to read, in which case Close removes nothing. + dir string // Records produced by the reader goroutine. Closed by the reader on clean EOF. results chan iteratorResult @@ -85,23 +89,22 @@ type walIterator struct { } // newWalIterator creates an iterator over wal starting at startIndex and launches its reader goroutine. -// pinnedIndex is the read lease registered on the iterator's behalf, released by Close; hasPin reports whether -// a lease was actually registered (false for an empty file snapshot, in which case Close registers no release). -// files is the snapshot of files to read (captured on the writer goroutine). prefetch is the number of records -// the reader may buffer ahead of the consumer. +// maxIndex is the highest index it will yield. files is the snapshot of hard-linked files to read (captured on +// the writer goroutine), living under dir; Close removes dir (empty dir means nothing to read and nothing to +// remove). prefetch is the number of records the reader may buffer ahead of the consumer. func newWalIterator( wal *walImpl, startIndex uint64, - pinnedIndex uint64, - hasPin bool, + maxIndex uint64, + dir string, files []iteratorFile, prefetch uint, ) *walIterator { it := &walIterator{ wal: wal, start: startIndex, - pinnedIndex: pinnedIndex, - hasPin: hasPin, + maxIndex: maxIndex, + dir: dir, results: make(chan iteratorResult, prefetch), stop: make(chan struct{}), readerExited: make(chan struct{}), @@ -157,9 +160,11 @@ func (it *walIterator) Entry() (uint64, []byte) { func (it *walIterator) Close() error { it.closeOnce.Do(func() { close(it.stop) // tell the reader to stop if it is mid-read - <-it.readerExited // wait for it to actually exit before releasing resources - if it.hasPin { - it.wal.unpinIndex(it.pinnedIndex) + <-it.readerExited // wait for it to actually exit before releasing its file handles + if it.dir != "" { + // Remove this iterator's hard-link snapshot, freeing any inode it was the last link to. Best-effort: + // a leftover is reclaimed by the startup blast. The reader has exited, so no handle is open here. + _ = os.RemoveAll(it.dir) } }) it.done = true @@ -243,34 +248,41 @@ func (it *walIterator) loadNextFile() (bool, error) { return false, err } - parsed := parsedFileName{fileSeq: f.fileSeq, firstIndex: f.firstIndex, lastIndex: f.lastIndex, sealed: true} + parsed := parsedFileName{fileSeq: f.fileSeq, firstIndex: f.firstIndex, lastIndex: f.lastIndex, sealed: f.sealed} contents, err := readWalFileFromHandle(handle, parsed) if err != nil { return false, fmt.Errorf("failed to read WAL file (sequence %d) during iteration: %w", f.fileSeq, err) } - // A sealed file is durable and complete, so its content must span the [first, last] range its name - // promises. Fail loudly on any shortfall (interior corruption) instead of silently under-yielding while - // Bounds/GetStoredRange keep reporting the full range. This mirrors the check run eagerly at open by - // validateSealedFiles. - if err := verifySealedContents(contents, f.fileSeq, f.firstIndex, f.lastIndex); err != nil { - return false, err + if f.sealed { + // A sealed file is durable and complete, so its content must span the [first, last] range its name + // promises. Fail loudly on any shortfall (interior corruption) instead of silently under-yielding while + // Bounds/GetStoredRange keep reporting the full range. This mirrors the check run eagerly at open by + // validateSealedFiles. The non-sealed mutable snapshot is skipped: it may hold records past maxIndex and + // a torn tail from concurrent writing, both handled below. + if err := verifySealedContents(contents, f.fileSeq, f.firstIndex, f.lastIndex); err != nil { + return false, err + } } for _, record := range contents.records { if record.index < it.start { continue } + if record.index > it.maxIndex { + break // beyond the point-in-time snapshot; records ascend, so nothing further qualifies + } it.buffer = append(it.buffer, record) } return true, nil } } -// openFile opens a snapshot file by its immutable sealed name. The read lease keeps the file alive against -// pruning, so the open cannot miss it. readWalFileFromHandle closes the returned handle after reading. +// openFile opens a snapshot file from the iterator's private hard-link directory. The hard link keeps the +// underlying inode alive against rotation and pruning, so the open cannot miss it even after the WAL removed +// the file's canonical name. readWalFileFromHandle closes the returned handle after reading. func (it *walIterator) openFile(f *iteratorFile) (*os.File, error) { - path := filepath.Join(it.wal.config.Path, f.name) + path := filepath.Join(it.dir, f.name) handle, err := os.Open(path) //nolint:gosec // path derived from the writer's file snapshot if err != nil { return nil, fmt.Errorf("failed to open WAL file %s during iteration: %w", f.name, err) From efb976e9ad4f8d7260d5952a68f9ffb870bdd4ed Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 13 Jul 2026 14:32:47 -0500 Subject: [PATCH 31/44] Added note about possible perf hotspot --- sei-db/seiwal/seiwal_iterator.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index 51bf80a613..aca9469f57 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -267,6 +267,9 @@ func (it *walIterator) loadNextFile() (bool, error) { for _, record := range contents.records { if record.index < it.start { + // Locating the start index is a linear scan over this file's records (and the whole file was just + // read into memory above). It's wasteful when start lands deep in a large file. If this becomes a + // hotspot, build a small per-file index (offset by index, like LittDB key files) and seek instead. continue } if record.index > it.maxIndex { From fe3cede4207d8bb8c2d0ee8213b20819d1717735 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 13 Jul 2026 15:16:08 -0500 Subject: [PATCH 32/44] don't scan DB at startup time --- sei-db/seiwal/seiwal_impl.go | 36 +---- sei-db/seiwal/seiwal_impl_test.go | 146 +++++++++++++++--- sei-db/seiwal/seiwal_iterator.go | 5 - sei-db/seiwal/seiwal_iterator_test.go | 8 +- sei-db/seiwal/seiwal_offline.go | 77 +++++++-- sei-db/state_db/statewal/state_wal_impl.go | 26 +++- .../state_db/statewal/state_wal_impl_test.go | 38 +++++ 7 files changed, 256 insertions(+), 80 deletions(-) diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 0ac7d8ef79..be61bbd9a3 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -170,20 +170,6 @@ func recoverDirectory(path string) error { return nil } -// scanAndValidate loads the sealed files in a directory (ascending) and verifies each one's content covers -// exactly the index range its name promises, returning the sequence to assign the next mutable file. Call it -// after recoverDirectory, when no unsealed files remain. -func scanAndValidate(path string) (*util.RandomAccessDeque[*sealedFileInfo], uint64, error) { - sealedFiles, nextFileSeq, err := scanSealedFiles(path) - if err != nil { - return nil, 0, fmt.Errorf("failed to scan sealed WAL files: %w", err) - } - if err := validateSealedFiles(path, sealedFiles); err != nil { - return nil, 0, fmt.Errorf("corrupt sealed WAL file: %w", err) - } - return sealedFiles, nextFileSeq, nil -} - func newWAL(config *Config) (WAL[[]byte], error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid WAL config: %w", err) @@ -191,7 +177,9 @@ func newWAL(config *Config) (WAL[[]byte], error) { if err := recoverDirectory(config.Path); err != nil { return nil, err } - sealedFiles, nextFileSeq, err := scanAndValidate(config.Path) + // Only the cheap directory-listing scan runs at open: it reads file names, not their contents, and still + // rejects structural corruption (a gap in the sealed sequence). + sealedFiles, nextFileSeq, err := scanSealedFiles(config.Path) if err != nil { return nil, err } @@ -751,21 +739,3 @@ func scanSealedFiles(directory string) (*util.RandomAccessDeque[*sealedFileInfo] } return sealedFiles, nextFileSeq, nil } - -// validateSealedFiles reads and checksums every sealed file, confirming each file's content exactly covers the -// [first, last] index range its name promises. This surfaces corruption (bit-rot, truncation) at open — where -// it demands human intervention — rather than lazily at iteration time, by which point Bounds/GetStoredRange -// would already be reporting a range that iteration cannot deliver. Cost is O(total sealed bytes): every sealed -// file is read and CRC-verified on open. -func validateSealedFiles(directory string, sealedFiles *util.RandomAccessDeque[*sealedFileInfo]) error { - for _, info := range sealedFiles.Iterator() { - contents, err := readWalFile(filepath.Join(directory, info.name)) - if err != nil { - return fmt.Errorf("failed to read sealed WAL file %s: %w", info.name, err) - } - if err := verifySealedContents(contents, info.fileSeq, info.firstIndex, info.lastIndex); err != nil { - return err - } - } - return nil -} diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index dd07d0ed8e..1af8871255 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -753,10 +753,10 @@ func TestScanRejectsGapInSealedFiles(t *testing.T) { require.Contains(t, err.Error(), "not contiguous") } -// TestNewWALRejectsMidStreamCorruptSealedFile verifies that a checksum mismatch in a non-final record of a -// sealed file is surfaced as a hard error at open. Corrupted durable data demands human intervention, so the -// WAL must refuse to open rather than silently serving a view truncated at the corruption point. -func TestNewWALRejectsMidStreamCorruptSealedFile(t *testing.T) { +// TestOpenIgnoresMidStreamCorruptSealedFile verifies that a checksum mismatch in a non-final record of a +// sealed file does NOT block open: open reads file names only, never sealed contents, so it must not scale +// with (or fault on) stored bytes. The fault is instead surfaced on demand by VerifyIntegrity. +func TestOpenIgnoresMidStreamCorruptSealedFile(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -777,16 +777,20 @@ func TestNewWALRejectsMidStreamCorruptSealedFile(t *testing.T) { data[walHeaderSize+2] ^= 0xFF require.NoError(t, os.WriteFile(path, data, 0o600)) - _, err = NewWAL(cfg) - require.Error(t, err) - require.Contains(t, err.Error(), "corrupt") + // Open succeeds despite the corruption — it never touched the sealed contents. + w2, err := NewWAL(cfg) + require.NoError(t, err) + require.NoError(t, w2.Close()) + + // The on-demand scan catches the mid-stream fault. + require.Error(t, VerifyIntegrity(dir)) } -// TestNewWALRejectsTruncatedSealedFile verifies that a sealed file truncated at a clean record boundary — all -// remaining records checksum correctly, but the content stops short of the last index its name promises — is -// rejected at open. This is the case parse-strictness alone cannot catch (no torn record remains); the -// content/name range check must. -func TestNewWALRejectsTruncatedSealedFile(t *testing.T) { +// TestOpenIgnoresTruncatedSealedFile verifies that a sealed file truncated at a clean record boundary — all +// remaining records checksum correctly, but the content stops short of the last index its name promises — +// does not block open (open trusts the name), and is caught by VerifyIntegrity's name-versus-content range +// check. This is the case parse-strictness alone cannot catch (no torn record remains). +func TestOpenIgnoresTruncatedSealedFile(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -807,14 +811,114 @@ func TestNewWALRejectsTruncatedSealedFile(t *testing.T) { require.Len(t, contents.records, 5) require.NoError(t, os.Truncate(path, contents.records[3].end)) - _, err = NewWAL(cfg) + w2, err := NewWAL(cfg) + require.NoError(t, err) + require.NoError(t, w2.Close()) + + require.Error(t, VerifyIntegrity(dir)) +} + +// TestVerifyIntegrityCleanLog verifies that VerifyIntegrity returns nil for an intact multi-file sealed log. +func TestVerifyIntegrityCleanLog(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // rotate after every record, producing one sealed file per index + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + require.Greater(t, countSealedFiles(t, dir), 1) + + require.NoError(t, VerifyIntegrity(dir)) +} + +// TestVerifyIntegrityDetectsSequenceGap verifies that a missing sealed file (a hole in the sequence) is +// reported by VerifyIntegrity even though every surviving file is itself intact. +func TestVerifyIntegrityDetectsSequenceGap(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one sealed file per record + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + names := sealedFileNames(t, dir) + require.Greater(t, len(names), 2) + // Remove a middle file to punch a hole in the sequence. + require.NoError(t, os.Remove(filepath.Join(dir, names[1]))) + + err := VerifyIntegrity(dir) require.Error(t, err) - require.Contains(t, err.Error(), "corrupt") + require.Contains(t, err.Error(), "gap") } -// TestNewWALRejectsSealedFileBadMagic verifies that a sealed file with a clobbered header (invalid magic -// prefix) is rejected at open rather than treated as empty. -func TestNewWALRejectsSealedFileBadMagic(t *testing.T) { +// TestVerifyIntegrityReportsAllFaults verifies that a single VerifyIntegrity pass aggregates every problem it +// finds rather than stopping at the first one. +func TestVerifyIntegrityReportsAllFaults(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one sealed file per record + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + names := sealedFileNames(t, dir) + require.GreaterOrEqual(t, len(names), 4) + + // Corrupt two different sealed files' payloads. Each file holds a single record, so the payload begins at + // walHeaderSize plus the two single-byte uvarint prefixes. + for _, name := range []string{names[0], names[2]} { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + require.NoError(t, err) + data[walHeaderSize+2] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + } + + err := VerifyIntegrity(dir) + require.Error(t, err) + // errors.Join renders one line per wrapped error; both corrupt files must appear. + require.Contains(t, err.Error(), names[0]) + require.Contains(t, err.Error(), names[2]) +} + +// TestVerifyIntegrityIsReadOnly verifies that VerifyIntegrity does not mutate the directory (no orphan +// sealing, no removals): the exact set of files before and after a scan is identical, even when a corrupt +// sealed file is present. +func TestVerifyIntegrityIsReadOnly(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + names := sealedFileNames(t, dir) + require.Len(t, names, 1) + path := filepath.Join(dir, names[0]) + data, err := os.ReadFile(path) + require.NoError(t, err) + data[walHeaderSize+2] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + + before := sealedFileNames(t, dir) + require.Error(t, VerifyIntegrity(dir)) + require.Equal(t, before, sealedFileNames(t, dir), "VerifyIntegrity must not mutate the directory") +} + +// TestVerifyIntegrityDetectsSealedFileBadMagic verifies that a sealed file with a clobbered header (invalid +// magic prefix) does not block open (open reads names only) but is surfaced by VerifyIntegrity. +func TestVerifyIntegrityDetectsSealedFileBadMagic(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) @@ -832,9 +936,13 @@ func TestNewWALRejectsSealedFileBadMagic(t *testing.T) { data[0] ^= 0xFF // clobber the magic prefix require.NoError(t, os.WriteFile(path, data, 0o600)) - _, err = NewWAL(cfg) + w2, err := NewWAL(cfg) + require.NoError(t, err) + require.NoError(t, w2.Close()) + + err = VerifyIntegrity(dir) require.Error(t, err) - require.Contains(t, err.Error(), "corrupt") + require.Contains(t, err.Error(), "magic") } func TestBoundsEmpty(t *testing.T) { diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index aca9469f57..fe9f93e0b2 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -255,11 +255,6 @@ func (it *walIterator) loadNextFile() (bool, error) { } if f.sealed { - // A sealed file is durable and complete, so its content must span the [first, last] range its name - // promises. Fail loudly on any shortfall (interior corruption) instead of silently under-yielding while - // Bounds/GetStoredRange keep reporting the full range. This mirrors the check run eagerly at open by - // validateSealedFiles. The non-sealed mutable snapshot is skipped: it may hold records past maxIndex and - // a torn tail from concurrent writing, both handled below. if err := verifySealedContents(contents, f.fileSeq, f.firstIndex, f.lastIndex); err != nil { return false, err } diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index 882a8db242..6b4c488b27 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -11,10 +11,10 @@ import ( "github.com/stretchr/testify/require" ) -// TestIteratorRejectsCorruptSealedFile verifies that interior corruption appearing in a sealed file after the -// WAL is already open (e.g. bit-rot on disk after a clean startup validation) is surfaced as an error rather -// than silently truncating iteration short of the file's name-promised last index. The corruption is -// introduced after open so it slips past validateSealedFiles and must be caught by the iterator's re-read. +// TestIteratorRejectsCorruptSealedFile verifies that interior corruption in a sealed file is surfaced as an +// error rather than silently truncating iteration short of the file's name-promised last index. Open no longer +// reads sealed contents, so the iterator's per-record CRC re-read is where such corruption is caught at +// point-of-use (the offline VerifyIntegrity also finds it on demand). func TestIteratorRejectsCorruptSealedFile(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) diff --git a/sei-db/seiwal/seiwal_offline.go b/sei-db/seiwal/seiwal_offline.go index 029d1e96ce..f6a5360c5f 100644 --- a/sei-db/seiwal/seiwal_offline.go +++ b/sei-db/seiwal/seiwal_offline.go @@ -1,22 +1,26 @@ package seiwal -import "fmt" +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" +) // GetRange reports the range of record indices stored in the WAL directory at path, without constructing a // live WAL instance. It first runs the standard recovery/sanity pass (the same one the constructor uses), -// which SEALS any unsealed file left behind by a prior session and validates every sealed file — so this -// function mutates the directory on disk even though its name suggests a read. +// which SEALS any unsealed file left behind by a prior session — so this function mutates the directory on +// disk even though its name suggests a read. // -// ok is false (and first/last are undefined) when the directory holds no records. -// -// NOT SAFE FOR CONCURRENT USE with a live WAL: it seals and validates files that a running WAL instance -// owns, so it must only be called while no WAL is open on the same directory (e.g. offline, at startup -// before NewWAL). Callers must serialize it against any other GetRange/PruneAfter on the same directory. +// NOT SAFE FOR CONCURRENT USE with a live WAL: it seals files that a running WAL instance owns, so it must +// only be called while no WAL is open on the same directory (e.g. offline, at startup before NewWAL). +// Callers must serialize it against any other GetRange/PruneAfter on the same directory. func GetRange(path string) (ok bool, first uint64, last uint64, err error) { if err := recoverDirectory(path); err != nil { return false, 0, 0, fmt.Errorf("failed to recover WAL directory: %w", err) } - sealedFiles, _, err := scanAndValidate(path) + sealedFiles, _, err := scanSealedFiles(path) if err != nil { return false, 0, 0, err } @@ -30,11 +34,7 @@ func GetRange(path string) (ok bool, first uint64, last uint64, err error) { // PruneAfter deletes every record with an index greater than highestIndexToKeep from the WAL directory at // path — the offline rollback operation — without constructing a live WAL instance. It runs the standard -// recovery/sanity pass first (sealing any orphaned file), applies the rollback, then re-validates the result. -// -// This mirrors PruneBefore on the other end of the log: PruneBefore(lowestIndexToKeep) keeps indices >= its -// argument, while PruneAfter(highestIndexToKeep) keeps indices <= its argument. A crash mid-prune leaves a -// contiguous prefix of records, never a gap. +// recovery/sanity pass first (sealing any orphaned file), applies the rollback, then re-scans the result. // // NOT SAFE FOR CONCURRENT USE with a live WAL: it seals, rewrites, and removes files that a running WAL // instance owns, so it must only be called while no WAL is open on the same directory (e.g. offline, at @@ -47,8 +47,55 @@ func PruneAfter(path string, highestIndexToKeep uint64) error { if err := rollbackDirectory(path, highestIndexToKeep); err != nil { return fmt.Errorf("failed to prune WAL entries after index %d: %w", highestIndexToKeep, err) } - if _, _, err := scanAndValidate(path); err != nil { + if _, _, err := scanSealedFiles(path); err != nil { return fmt.Errorf("WAL is corrupt after pruning: %w", err) } return nil } + +// VerifyIntegrity reads every sealed file in the WAL directory at path and confirms that each record's CRC is +// intact and that each file's content exactly covers the index range its name promises. This is the expensive +// O(total sealed bytes) check that open (and GetRange/PruneAfter) deliberately skips. +// +// NOT SAFE FOR CONCURRENT USE with a live WAL on the same directory: it reads files a running WAL owns while +// they may still be changing. +func VerifyIntegrity(path string) error { + entries, err := os.ReadDir(path) + if err != nil { + return fmt.Errorf("failed to read WAL directory %s: %w", path, err) + } + + sealed := make([]parsedFileName, 0, len(entries)) + names := make(map[uint64]string, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + parsed, ok := parseFileName(entry.Name()) + if !ok || !parsed.sealed { + continue + } + sealed = append(sealed, parsed) + names[parsed.fileSeq] = entry.Name() + } + sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq < sealed[j].fileSeq }) + + var problems []error + for i, parsed := range sealed { + if i > 0 && parsed.fileSeq != sealed[i-1].fileSeq+1 { + problems = append(problems, fmt.Errorf( + "gap in sealed file sequence between %d and %d (a sealed file is missing)", + sealed[i-1].fileSeq, parsed.fileSeq)) + } + name := names[parsed.fileSeq] + contents, err := readWalFile(filepath.Join(path, name)) + if err != nil { + problems = append(problems, fmt.Errorf("failed to read sealed WAL file %s: %w", name, err)) + continue + } + if err := verifySealedContents(contents, parsed.fileSeq, parsed.firstIndex, parsed.lastIndex); err != nil { + problems = append(problems, err) + } + } + return errors.Join(problems...) +} diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 4da604b957..4a8b2d2267 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -55,10 +55,13 @@ func New(config *Config) (StateWAL, error) { // without constructing a live StateWAL. Like the seiwal function it wraps, it runs the recovery/sanity pass // (which seals any unsealed file left by a prior session) before reading, so it mutates the directory. // +// The range is read from sealed file names only, so its cost is a directory listing regardless of how much +// data the WAL holds; content is not checked. Use VerifyIntegrity to check for corruption. +// // NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with another GetRange/PruneAfter, on the same -// directory: it seals and validates files a running WAL owns. Call it only while no StateWAL is open there -// (e.g. offline, at startup before New). For a range query against a live WAL use the instance method -// GetStoredRange instead. +// directory: it seals files a running WAL owns. Call it only while no StateWAL is open there (e.g. offline, +// at startup before New). For a range query against a live WAL use the instance method GetStoredRange +// instead. func GetRange(config *Config) (bool, uint64, uint64, error) { ok, first, last, err := seiwal.GetRange(config.Path) if err != nil { @@ -69,7 +72,8 @@ func GetRange(config *Config) (bool, uint64, uint64, error) { // PruneAfter deletes all data for blocks after highestBlockToKeep from the state WAL directory configured by // config, without constructing a live StateWAL. It runs the recovery/sanity pass, applies the rollback, and -// re-validates; blocks with a number <= highestBlockToKeep are kept. +// re-scans the result structurally (file names / sequence contiguity, not contents); blocks with a number +// <= highestBlockToKeep are kept. // // NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with another GetRange/PruneAfter, on the same // directory: it seals, rewrites, and removes files a running WAL owns. Call it only while no StateWAL is open @@ -81,6 +85,20 @@ func PruneAfter(config *Config, highestBlockToKeep uint64) error { return nil } +// VerifyIntegrity reads every sealed file in the state WAL directory configured by config and confirms each +// record's CRC and each file's name-versus-content range. This is the expensive O(total stored bytes) check +// that New/GetRange/PruneAfter deliberately skip; call it only when corruption is suspected. It is read-only +// and reports every problem it finds in a single pass, returning nil when the durable log is clean. +// +// NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with GetRange/PruneAfter, on the same directory. Call +// it only while no StateWAL is open there (e.g. offline, at startup before New). +func VerifyIntegrity(config *Config) error { + if err := seiwal.VerifyIntegrity(config.Path); err != nil { + return fmt.Errorf("state WAL integrity check failed: %w", err) + } + return nil +} + func newStateWAL(wal seiwal.WAL[[]*proto.NamedChangeSet]) (StateWAL, error) { w := &stateWALImpl{wal: wal} diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go index 6e7db7fd06..f57fca3ce8 100644 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ b/sei-db/state_db/statewal/state_wal_impl_test.go @@ -1,6 +1,9 @@ package statewal import ( + "os" + "path/filepath" + "strings" "testing" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -281,3 +284,38 @@ func TestPruneAfter(t *testing.T) { }) } } + +// TestVerifyIntegrity is a wrapper-level smoke test that VerifyIntegrity passes on a clean log and reports a +// fault when a sealed file is corrupted (the detailed cases are exercised in the seiwal package). +func TestVerifyIntegrity(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one sealed file per block + + w := openWAL(t, cfg) + for block := uint64(1); block <= 5; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Close()) + + require.NoError(t, VerifyIntegrity(cfg)) + + // Corrupt a byte in one sealed file's body; the on-demand scan must surface it. + entries, err := os.ReadDir(dir) + require.NoError(t, err) + var sealed string + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".wal") { + sealed = entry.Name() + break + } + } + require.NotEmpty(t, sealed) + path := filepath.Join(dir, sealed) + data, err := os.ReadFile(path) + require.NoError(t, err) + data[len(data)-5] ^= 0xFF // flip a byte inside the record, before the trailing CRC + require.NoError(t, os.WriteFile(path, data, 0o600)) + + require.Error(t, VerifyIntegrity(cfg)) +} From 59562f246e8cfbcaac12c945946ae135cabbde19 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 14 Jul 2026 08:55:26 -0500 Subject: [PATCH 33/44] made suggested changes --- sei-db/seiwal/seiwal_config.go | 2 +- sei-db/seiwal/seiwal_file.go | 13 ++--- sei-db/seiwal/seiwal_file_test.go | 32 ++++++++++++ sei-db/seiwal/seiwal_iterator.go | 3 ++ sei-db/seiwal/seiwal_iterator_test.go | 51 +++++++++++++++++++ sei-db/seiwal/seiwal_serializing.go | 8 +++ sei-db/seiwal/seiwal_serializing_test.go | 62 ++++++++++++++++++++++++ sei-db/state_db/statewal/state_wal.go | 3 +- 8 files changed, 164 insertions(+), 10 deletions(-) diff --git a/sei-db/seiwal/seiwal_config.go b/sei-db/seiwal/seiwal_config.go index eb9882cb2b..6568e7c331 100644 --- a/sei-db/seiwal/seiwal_config.go +++ b/sei-db/seiwal/seiwal_config.go @@ -62,7 +62,7 @@ func DefaultConfig(path string, name string) *Config { WriteBufferSize: 16, SerializerBufferSize: 16, TargetFileSize: 64 * unit.MB, - FsyncOnFlush: true, + FsyncOnFlush: false, PermitGaps: false, IteratorPrefetchSize: 32, MetricsSampleInterval: 15 * time.Second, diff --git a/sei-db/seiwal/seiwal_file.go b/sei-db/seiwal/seiwal_file.go index 0b4d02c474..170db8b02d 100644 --- a/sei-db/seiwal/seiwal_file.go +++ b/sei-db/seiwal/seiwal_file.go @@ -237,15 +237,14 @@ func (f *walFile) seal() (string, error) { return "", nil } if err := f.flush(true); err != nil { + _ = f.close() return "", fmt.Errorf("failed to flush before sealing: %w", err) } unsealedPath := filepath.Join(f.directory, unsealedFileName(f.fileSeq)) if !f.hasRecords { - if f.file != nil { - if err := f.file.Close(); err != nil { - return "", fmt.Errorf("failed to close WAL file: %w", err) - } + if err := f.close(); err != nil { + return "", fmt.Errorf("failed to close WAL file: %w", err) } if err := removeAndSyncDir(f.directory, unsealedFileName(f.fileSeq)); err != nil { return "", fmt.Errorf("failed to remove empty WAL file: %w", err) @@ -254,10 +253,8 @@ func (f *walFile) seal() (string, error) { return "", nil } - if f.file != nil { - if err := f.file.Close(); err != nil { - return "", fmt.Errorf("failed to close WAL file: %w", err) - } + if err := f.close(); err != nil { + return "", fmt.Errorf("failed to close WAL file: %w", err) } sealedName := sealedFileName(f.fileSeq, f.firstIndex, f.lastIndex) diff --git a/sei-db/seiwal/seiwal_file_test.go b/sei-db/seiwal/seiwal_file_test.go index c7a515f641..1a0bfbe9d8 100644 --- a/sei-db/seiwal/seiwal_file_test.go +++ b/sei-db/seiwal/seiwal_file_test.go @@ -151,3 +151,35 @@ func TestReadWalFileBadMagic(t *testing.T) { _, err = readWalFile(path) require.Error(t, err) } + +// TestSealClearsFileHandle verifies that a successful seal() closes and clears f.file, so a later close() is a +// true no-op rather than a double-close on an already-closed handle — keeping close() idempotent as documented. +func TestSealClearsFileHandle(t *testing.T) { + dir := t.TempDir() + f, err := newWalFile(dir, 0) + require.NoError(t, err) + writeRecordTo(t, f, 1, []byte("one")) + + name, err := f.seal() + require.NoError(t, err) + require.NotEmpty(t, name) + require.Nil(t, f.file) // seal cleared the handle + require.NoError(t, f.close()) // idempotent: no second Close on a closed handle +} + +// TestSealReleasesHandleOnFlushFailure verifies that when the pre-seal flush fails, seal() still releases the +// file descriptor (clears f.file) rather than leaking it until GC. +func TestSealReleasesHandleOnFlushFailure(t *testing.T) { + dir := t.TempDir() + f, err := newWalFile(dir, 0) + require.NoError(t, err) + writeRecordTo(t, f, 1, []byte("one")) + + // Close the descriptor out from under seal so its flush(true) fails on the still-buffered bytes. + require.NoError(t, f.file.Close()) + + _, err = f.seal() + require.Error(t, err) + require.Nil(t, f.file) // fd released despite the failure + require.NoError(t, f.close()) // idempotent +} diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index fe9f93e0b2..dc4e002462 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -134,6 +134,9 @@ func (it *walIterator) Next() (bool, error) { return it.deliver(result, ok) case <-it.wal.ctx.Done(): it.done = true + if err := it.wal.asyncError(); err != nil { + return false, fmt.Errorf("WAL shut down during iteration: %w", err) + } return false, fmt.Errorf("WAL shut down during iteration: %w", it.wal.ctx.Err()) } } diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index 6b4c488b27..1ee0792eb5 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -1,6 +1,7 @@ package seiwal import ( + "context" "fmt" "os" "path/filepath" @@ -310,3 +311,53 @@ func TestIteratorDoesNotSeePostConstructionRecords(t *testing.T) { } require.Equal(t, []uint64{1, 2, 3}, got, "post-construction record 4 must not be iterated") } + +// TestIteratorSurfacesFatalCause verifies that when the WAL bricks while an iterator is live, Next surfaces the +// recorded fatal cause rather than a bare context.Canceled — so a disk failure during replay is not buried +// under a generic "context canceled" message, matching the asyncError-first pattern used elsewhere in walImpl. +func TestIteratorSurfacesFatalCause(t *testing.T) { + cfg := testConfig(t.TempDir()) + w := openWAL(t, cfg).(*walImpl) + defer func() { _ = w.Close() }() + + // More records than the reader can prefetch, so it is still producing (not at a clean EOF) when the WAL + // bricks, guaranteeing Next reaches the shutdown branch rather than a normal end-of-iteration. + const n = 100 + for i := uint64(1); i <= n; i++ { + appendRecord(t, w, i) + } + require.NoError(t, w.Flush()) + + it, err := w.Iterator(1) + require.NoError(t, err) + defer func() { _ = it.Close() }() + + // Brick from the writer goroutine: close the mutable fd out from under it, then a flush fails and calls fail(). + require.NoError(t, w.mutableFile.file.Close()) + require.NoError(t, w.Append(uint64(n+1), recordPayload(uint64(n+1)))) + require.Error(t, w.Flush()) + + select { + case <-w.ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("WAL did not brick after flush failure") + } + cause := w.asyncError() + require.Error(t, cause) + + // Drain whatever the reader had buffered; the first error must carry the fatal cause, not context.Canceled. + var got error + for { + ok, nextErr := it.Next() + if nextErr != nil { + got = nextErr + break + } + if !ok { + break + } + } + require.Error(t, got) + require.ErrorIs(t, got, cause) + require.NotErrorIs(t, got, context.Canceled) +} diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index 68d41b6252..cdb1f173e1 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -322,6 +322,10 @@ func (s *serializingWAL[T]) serializerLoop() { case serBounds: ok, first, last, err := s.inner.Bounds() m.reply <- serBoundsResult{ok: ok, first: first, last: last, err: err} + if err != nil { + s.fail(fmt.Errorf("bounds query failed: %w", err)) + return + } case serPrune: if err := s.inner.PruneBefore(m.through); err != nil { s.fail(fmt.Errorf("failed to prune below index %d: %w", m.through, err)) @@ -330,6 +334,10 @@ func (s *serializingWAL[T]) serializerLoop() { case serIterator: it, err := s.inner.Iterator(m.startIndex) m.reply <- serIteratorResult{it: it, err: err} + if err != nil { + s.fail(fmt.Errorf("failed to create iterator: %w", err)) + return + } case serClose: m.done <- s.inner.Close() // FIFO guarantees every prior append has been delegated. Forbid further pushes so any diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go index ad6dbcce82..db4f6ed0ef 100644 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -258,3 +258,65 @@ func TestGenericWALAppendOrdering(t *testing.T) { // Close surfaces the same fatal error rather than succeeding. require.Error(t, w.Close()) } + +// faultyInner is a WAL[[]byte] whose Bounds/Iterator return an injected error. An inner WAL only errors on +// those read-path calls once it is already dead, so it models a failed inner engine for the serializing layer. +type faultyInner struct { + boundsErr error + iterErr error +} + +var _ WAL[[]byte] = (*faultyInner)(nil) + +func (f *faultyInner) Append(uint64, []byte) error { return nil } +func (f *faultyInner) Flush() error { return nil } +func (f *faultyInner) Bounds() (bool, uint64, uint64, error) { return false, 0, 0, f.boundsErr } +func (f *faultyInner) PruneBefore(uint64) error { return nil } +func (f *faultyInner) Iterator(uint64) (Iterator[[]byte], error) { + return nil, f.iterErr +} +func (f *faultyInner) Close() error { return nil } + +// TestGenericWALBricksOnInnerBoundsError verifies that an error from the inner WAL's Bounds — which only +// happens once the inner engine is already dead — bricks the serializing layer instead of leaving it running +// against a dead inner until a later mutating call fails. +func TestGenericWALBricksOnInnerBoundsError(t *testing.T) { + boom := errors.New("inner bounds boom") + cfg := testConfig(t.TempDir()) + cfg.MetricsSampleInterval = 0 + s := newSerializingWAL[string](cfg, &faultyInner{boundsErr: boom}, stringSerialize, stringDeserialize) + defer func() { _ = s.Close() }() + + _, _, _, err := s.Bounds() + require.Error(t, err) + require.ErrorIs(t, err, boom) + + select { + case <-s.ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("serializing WAL did not brick after inner Bounds error") + } + require.ErrorIs(t, s.asyncError(), boom) + require.Error(t, s.Append(1, "x"), "appends must fail on a bricked WAL") +} + +// TestGenericWALBricksOnInnerIteratorError is the Iterator analogue of TestGenericWALBricksOnInnerBoundsError. +func TestGenericWALBricksOnInnerIteratorError(t *testing.T) { + boom := errors.New("inner iterator boom") + cfg := testConfig(t.TempDir()) + cfg.MetricsSampleInterval = 0 + s := newSerializingWAL[string](cfg, &faultyInner{iterErr: boom}, stringSerialize, stringDeserialize) + defer func() { _ = s.Close() }() + + _, err := s.Iterator(0) + require.Error(t, err) + require.ErrorIs(t, err, boom) + + select { + case <-s.ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("serializing WAL did not brick after inner Iterator error") + } + require.ErrorIs(t, s.asyncError(), boom) + require.Error(t, s.Append(1, "x"), "appends must fail on a bricked WAL") +} diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index be010560cc..8744012f7f 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -81,6 +81,7 @@ type StateWAL interface { // The returned changesets, and every byte slice reachable through them, must be treated as read-only. Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) - // Close the WAL, flushing any pending writes and releasing resources. + // Close the WAL, flushing complete blocks (those ended with SignalEndOfBlock) to disk and releasing + // resources. Changes for a block that was not ended with SignalEndOfBlock are discarded. Close() error } From 52142bcb644380c10a7c0e6aefd01715bc9b651b Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 14 Jul 2026 09:56:08 -0500 Subject: [PATCH 34/44] made suggested fix --- sei-db/seiwal/seiwal_impl.go | 1 + sei-db/seiwal/seiwal_impl_test.go | 13 ++++++++----- sei-db/seiwal/seiwal_serializing.go | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index be61bbd9a3..7a7a7162d0 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -617,6 +617,7 @@ func (w *walImpl) bounds() storedRange { // fail records the first fatal background error and triggers shutdown of the pipeline. The error is recorded // as the cancellation cause of ctx, so callers observe it via asyncError / context.Cause. func (w *walImpl) fail(err error) { + w.senderCancel(err) w.cancel(err) // the first cancel wins, so the first fatal error is the one retained if cerr := w.mutableFile.close(); cerr != nil { logger.Error("failed to close mutable WAL file after fatal error", "err", cerr) diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 1af8871255..251fa858cb 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -258,12 +258,15 @@ func TestFailReleasesMutableFile(t *testing.T) { mf, err := newWalFile(dir, 0) require.NoError(t, err) ctx, cancel := context.WithCancelCause(context.Background()) + senderCtx, senderCancel := context.WithCancelCause(ctx) w := &walImpl{ - config: testConfig(dir), - metricAttrs: walNameAttr("test"), - ctx: ctx, - cancel: cancel, - mutableFile: mf, + config: testConfig(dir), + metricAttrs: walNameAttr("test"), + ctx: ctx, + cancel: cancel, + senderCtx: senderCtx, + senderCancel: senderCancel, + mutableFile: mf, } require.NoError(t, w.appendRecord(dataToBeWritten{record: frameRecord(1, recordPayload(1)), index: 1})) diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index cdb1f173e1..c5561fbe54 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -351,6 +351,7 @@ func (s *serializingWAL[T]) serializerLoop() { // fail records the first fatal background error and triggers shutdown of the pipeline. The error is recorded // as the cancellation cause of ctx, so callers observe it via asyncError / context.Cause. func (s *serializingWAL[T]) fail(err error) { + s.senderCancel(err) s.cancel(err) // the first cancel wins, so the first fatal error is the one retained if cerr := s.inner.Close(); cerr != nil { logger.Error("failed to close inner WAL after fatal error", "err", cerr) From 622b98b64c96638b79adf666be36d833faa88fe7 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 14 Jul 2026 11:04:55 -0500 Subject: [PATCH 35/44] benchmark for SeiWAL --- .gitignore | 1 + sei-db/seiwal/walsim/Makefile | 14 + .../walsim/cmd/configure-logger/main.go | 52 +++ sei-db/seiwal/walsim/cmd/walsim/main.go | 88 +++++ sei-db/seiwal/walsim/config/debug.json | 9 + sei-db/seiwal/walsim/config/standard.json | 5 + sei-db/seiwal/walsim/record_generator.go | 50 +++ sei-db/seiwal/walsim/wal_store.go | 147 +++++++ sei-db/seiwal/walsim/wal_store_test.go | 83 ++++ sei-db/seiwal/walsim/walsim.go | 365 ++++++++++++++++++ sei-db/seiwal/walsim/walsim.sh | 28 ++ sei-db/seiwal/walsim/walsim_config.go | 194 ++++++++++ sei-db/seiwal/walsim/walsim_metrics.go | 148 +++++++ 13 files changed, 1184 insertions(+) create mode 100644 sei-db/seiwal/walsim/Makefile create mode 100644 sei-db/seiwal/walsim/cmd/configure-logger/main.go create mode 100644 sei-db/seiwal/walsim/cmd/walsim/main.go create mode 100644 sei-db/seiwal/walsim/config/debug.json create mode 100644 sei-db/seiwal/walsim/config/standard.json create mode 100644 sei-db/seiwal/walsim/record_generator.go create mode 100644 sei-db/seiwal/walsim/wal_store.go create mode 100644 sei-db/seiwal/walsim/wal_store_test.go create mode 100644 sei-db/seiwal/walsim/walsim.go create mode 100755 sei-db/seiwal/walsim/walsim.sh create mode 100644 sei-db/seiwal/walsim/walsim_config.go create mode 100644 sei-db/seiwal/walsim/walsim_metrics.go diff --git a/.gitignore b/.gitignore index c259bafa2d..f0deb83282 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ sei-db/state_db/bench/cryptosim/data/** sei-db/state_db/bench/cryptosim/bin/ sei-db/state_db/bench/cryptosim/logs/ sei-db/ledger_db/block/blocksim/bin/ +sei-db/seiwal/walsim/bin/ sei-db/db_engine/litt/bin/ \ No newline at end of file diff --git a/sei-db/seiwal/walsim/Makefile b/sei-db/seiwal/walsim/Makefile new file mode 100644 index 0000000000..362f0f78b3 --- /dev/null +++ b/sei-db/seiwal/walsim/Makefile @@ -0,0 +1,14 @@ +BINARY := bin/walsim +LOGGER_BINARY := bin/configure-logger +MODULE_ROOT ?= $(shell git -C "$(CURDIR)" rev-parse --show-toplevel) +CMD_DIR := ./sei-db/seiwal/walsim/cmd + +.PHONY: build +build: + @mkdir -p "$(CURDIR)/bin" + cd "$(MODULE_ROOT)" && go build -o "$(CURDIR)/$(BINARY)" "$(CMD_DIR)/walsim" + cd "$(MODULE_ROOT)" && go build -o "$(CURDIR)/$(LOGGER_BINARY)" "$(CMD_DIR)/configure-logger" + +.PHONY: clean +clean: + rm -f "$(CURDIR)/$(BINARY)" "$(CURDIR)/$(LOGGER_BINARY)" diff --git a/sei-db/seiwal/walsim/cmd/configure-logger/main.go b/sei-db/seiwal/walsim/cmd/configure-logger/main.go new file mode 100644 index 0000000000..ef30685b84 --- /dev/null +++ b/sei-db/seiwal/walsim/cmd/configure-logger/main.go @@ -0,0 +1,52 @@ +// configure-logger reads a walsim config file and prints shell export +// statements that configure seilog's environment variables. Intended to be +// called via eval in a shell script before launching the benchmark binary. +// +// Usage: +// +// eval "$(configure-logger config.json)" +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "github.com/sei-protocol/sei-chain/sei-db/seiwal/walsim" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "configure-logger: %v\n", err) + os.Exit(1) + } +} + +func run() error { + if len(os.Args) != 2 { + return fmt.Errorf("usage: configure-logger ") + } + + cfg := walsim.DefaultWalsimConfig() + if err := utils.LoadConfigFromFile(os.Args[1], cfg); err != nil { + return fmt.Errorf("load config: %w", err) + } + + logDir, err := utils.ResolveAndCreateDir(cfg.LogDir) + if err != nil { + return fmt.Errorf("resolve log dir: %w", err) + } + + logFile := filepath.Join(logDir, "walsim.log") + + fmt.Printf("export SEI_LOG_OUTPUT=%s\n", shellQuote(logFile)) + fmt.Printf("export SEI_LOG_LEVEL=%s\n", shellQuote(strings.ToLower(cfg.LogLevel))) + + return nil +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/sei-db/seiwal/walsim/cmd/walsim/main.go b/sei-db/seiwal/walsim/cmd/walsim/main.go new file mode 100644 index 0000000000..ecb644b52d --- /dev/null +++ b/sei-db/seiwal/walsim/cmd/walsim/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/signal" + + "github.com/sei-protocol/sei-chain/sei-db/common/metrics" + "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "github.com/sei-protocol/sei-chain/sei-db/seiwal/walsim" +) + +func main() { + err := run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + config := walsim.DefaultWalsimConfig() + if err := utils.LoadConfigFromFile(os.Args[1], config); err != nil { + return err + } + + configString, err := utils.StringifyConfig(config) + if err != nil { + return fmt.Errorf("failed to stringify config: %w", err) + } + fmt.Printf("%s\n", configString) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + reg, shutdown, err := metrics.SetupOtelPrometheus() + if err != nil { + return fmt.Errorf("setup metrics: %w", err) + } + defer func() { + _ = shutdown(context.Background()) + }() + + wsMetrics := walsim.NewWalsimMetrics(ctx, config) + + ws, err := walsim.NewWalSim(ctx, config, wsMetrics) + if err != nil { + return fmt.Errorf("failed to create walsim: %w", err) + } + defer func() { + if err := ws.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Error closing walsim: %v\n", err) + } + }() + + metrics.StartMetricsServer(ctx, reg, config.MetricsAddr) + metrics.StartSystemMetrics(ctx, "walsim", config.BackgroundMetricsScrapeInterval, + []metrics.MonitoredDir{ + {Name: "data_dir", Path: config.DataDir, TrackAvailableSpace: true}, + {Name: "log_dir", Path: config.LogDir}, + }) + + if config.EnableSuspension { + go func() { + scanner := bufio.NewScanner(os.Stdin) + suspended := false + for scanner.Scan() { + if suspended { + ws.Resume() + suspended = false + } else { + ws.Suspend() + suspended = true + } + } + }() + } + + ws.BlockUntilHalted() + + return nil +} diff --git a/sei-db/seiwal/walsim/config/debug.json b/sei-db/seiwal/walsim/config/debug.json new file mode 100644 index 0000000000..fb169c31ac --- /dev/null +++ b/sei-db/seiwal/walsim/config/debug.json @@ -0,0 +1,9 @@ +{ + "Comment": "Configuration for local smoke testing and debugging.", + "DataDir": "~/walsim/data", + "LogDir": "~/walsim/logs", + "CleanDataOnStart": true, + "CleanLogsOnStart": true, + "CleanDataOnExit": true, + "CleanLogsOnExit": true +} diff --git a/sei-db/seiwal/walsim/config/standard.json b/sei-db/seiwal/walsim/config/standard.json new file mode 100644 index 0000000000..24dbd4ea98 --- /dev/null +++ b/sei-db/seiwal/walsim/config/standard.json @@ -0,0 +1,5 @@ +{ + "Comment": "Standard walsim configuration.", + "DataDir": "~/walsim/data", + "LogDir": "~/walsim/logs" +} diff --git a/sei-db/seiwal/walsim/record_generator.go b/sei-db/seiwal/walsim/record_generator.go new file mode 100644 index 0000000000..ff9eda5d5c --- /dev/null +++ b/sei-db/seiwal/walsim/record_generator.go @@ -0,0 +1,50 @@ +package walsim + +import ( + "context" + + crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" +) + +// RecordGenerator asynchronously produces fixed-size opaque records and feeds them into a channel. +// Each record is a zero-copy sub-slice of a pre-generated, immutable CannedRandom buffer, so the +// generator never runs math/rand or allocates payload bytes on the hot path. The generator stops +// when the context is cancelled. +// +// The CannedRandom buffer is never mutated, so the sub-slices remain valid indefinitely; this makes +// it safe for the WAL to retain a record slice and serialize it asynchronously. +type RecordGenerator struct { + ctx context.Context + rand *crand.CannedRandom + recordSize int + recordChan chan []byte +} + +// NewRecordGenerator creates a RecordGenerator and immediately starts its background goroutine. rand +// must not be shared with any other goroutine (this generator owns it). +func NewRecordGenerator( + ctx context.Context, + rng *crand.CannedRandom, + recordSize int, + queueSize int, +) *RecordGenerator { + g := &RecordGenerator{ + ctx: ctx, + rand: rng, + recordSize: recordSize, + recordChan: make(chan []byte, queueSize), + } + go g.mainLoop() + return g +} + +func (g *RecordGenerator) mainLoop() { + for { + record := g.rand.Bytes(g.recordSize) + select { + case <-g.ctx.Done(): + return + case g.recordChan <- record: + } + } +} diff --git a/sei-db/seiwal/walsim/wal_store.go b/sei-db/seiwal/walsim/wal_store.go new file mode 100644 index 0000000000..d79dbfaccb --- /dev/null +++ b/sei-db/seiwal/walsim/wal_store.go @@ -0,0 +1,147 @@ +package walsim + +import ( + "context" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/seiwal" + "github.com/sei-protocol/sei-chain/sei-db/wal" +) + +// walStore is the minimal WAL surface the benchmark drives. The new seiwal WAL (seiwal.WAL[[]byte]) +// satisfies it directly with no wrapper; the legacy sei-db/wal WAL is adapted by legacyWALShim. +type walStore interface { + // Append schedules a record with the given index and payload. + Append(index uint64, data []byte) error + + // Flush blocks until previously scheduled appends are durable. + Flush() error + + // Bounds reports whether any record is stored and, if so, the lowest and highest stored indices. + Bounds() (ok bool, first uint64, last uint64, err error) + + // PruneBefore requests removal of all records with an index below lowestIndexToKeep. + PruneBefore(lowestIndexToKeep uint64) error + + // Close flushes pending appends and releases resources. + Close() error +} + +// The seiwal WAL implements walStore directly. +var _ walStore = (seiwal.WAL[[]byte])(nil) + +// The legacy shim implements walStore. +var _ walStore = (*legacyWALShim)(nil) + +// openWALStore opens the WAL backend selected by the configuration. +func openWALStore(ctx context.Context, config *WalsimConfig) (walStore, error) { + switch config.Backend { + case "seiwal": + cfg := config.Seiwal + // walsim owns the storage path and metric name. + cfg.Path = config.DataDir + cfg.Name = "walsim" + w, err := seiwal.NewWAL(&cfg) + if err != nil { + return nil, fmt.Errorf("failed to open seiwal WAL: %w", err) + } + return w, nil + case "legacy": + return newLegacyWALShim(ctx, config) + default: + return nil, fmt.Errorf("unknown WAL backend: %q", config.Backend) + } +} + +// legacyWALShim adapts the legacy sei-db/wal WAL to the walStore interface so the benchmark can +// drive it exactly like the new seiwal WAL. It is throwaway code: the legacy WAL is scheduled for +// deletion, and this shim goes with it. +// +// The legacy WAL's TruncateBefore rewrites segment files and is O(segments), so forwarding it on +// every request (as the size-target prune loop wants) would be catastrophic. The shim therefore +// coalesces prune requests, forwarding only every pruneRelaxationFactor-th PruneBefore to the +// underlying TruncateBefore. +type legacyWALShim struct { + inner *wal.WAL[[]byte] + + // Forward a TruncateBefore only once per this many PruneBefore calls. + pruneRelaxationFactor uint64 + + // The number of PruneBefore calls received so far. + pruneRequestCount uint64 +} + +func newLegacyWALShim(ctx context.Context, config *WalsimConfig) (*legacyWALShim, error) { + identity := func(b []byte) ([]byte, error) { return b, nil } + + cfg := config.Legacy + // Force the legacy WAL's own background pruning off so pruning is driven solely through + // PruneBefore (and coalesced below). + cfg.KeepRecent = 0 + cfg.PruneInterval = 0 + + inner, err := wal.NewWAL[[]byte](ctx, identity, identity, config.DataDir, cfg) + if err != nil { + return nil, fmt.Errorf("failed to open legacy WAL: %w", err) + } + + return &legacyWALShim{ + inner: inner, + pruneRelaxationFactor: config.PruneRelaxationFactor, + }, nil +} + +// Append writes data to the legacy WAL. The legacy WAL assigns its own 1-based, contiguous index, +// so the caller-supplied index is ignored; walsim seeds its counter from Bounds, keeping the two +// aligned. +func (s *legacyWALShim) Append(_ uint64, data []byte) error { + if err := s.inner.Write(data); err != nil { + return fmt.Errorf("failed to write to legacy WAL: %w", err) + } + return nil +} + +// Flush is a no-op. The legacy WAL exposes no explicit sync, so durability is governed entirely by +// its FsyncEnabled and batching configuration. +func (s *legacyWALShim) Flush() error { + return nil +} + +// Bounds reports the stored index range. The legacy WAL reports 0/0 for an empty log; real indices +// are 1-based, so a last offset of 0 means empty. +func (s *legacyWALShim) Bounds() (bool, uint64, uint64, error) { + first, err := s.inner.FirstOffset() + if err != nil { + return false, 0, 0, fmt.Errorf("failed to read first offset: %w", err) + } + last, err := s.inner.LastOffset() + if err != nil { + return false, 0, 0, fmt.Errorf("failed to read last offset: %w", err) + } + if last == 0 { + return false, 0, 0, nil + } + return true, first, last, nil +} + +// PruneBefore coalesces prune requests: only every pruneRelaxationFactor-th call reaches the +// underlying TruncateBefore, which is expensive in the legacy WAL. The most recent +// lowestIndexToKeep is used when the forward fires. +func (s *legacyWALShim) PruneBefore(lowestIndexToKeep uint64) error { + s.pruneRequestCount++ + if s.pruneRequestCount%s.pruneRelaxationFactor != 0 { + return nil + } + if err := s.inner.TruncateBefore(lowestIndexToKeep); err != nil { + return fmt.Errorf("failed to truncate legacy WAL: %w", err) + } + return nil +} + +// Close shuts down the legacy WAL. +func (s *legacyWALShim) Close() error { + if err := s.inner.Close(); err != nil { + return fmt.Errorf("failed to close legacy WAL: %w", err) + } + return nil +} diff --git a/sei-db/seiwal/walsim/wal_store_test.go b/sei-db/seiwal/walsim/wal_store_test.go new file mode 100644 index 0000000000..af32de853f --- /dev/null +++ b/sei-db/seiwal/walsim/wal_store_test.go @@ -0,0 +1,83 @@ +package walsim + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// newTestShim opens a legacy shim backed by a temp dir with synchronous writes (so appends and +// truncations are observable deterministically) and the given prune relaxation factor. +func newTestShim(t *testing.T, pruneRelaxationFactor uint64) *legacyWALShim { + t.Helper() + config := DefaultWalsimConfig() + config.Backend = "legacy" + config.DataDir = t.TempDir() + config.Legacy.WriteBufferSize = 0 // synchronous writes + config.Legacy.WriteBatchSize = 1 // no batching + config.PruneRelaxationFactor = pruneRelaxationFactor + + shim, err := newLegacyWALShim(context.Background(), config) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, shim.Close()) }) + return shim +} + +func appendN(t *testing.T, shim *legacyWALShim, n int) { + t.Helper() + for i := 0; i < n; i++ { + // The index argument is ignored by the legacy shim; the legacy WAL assigns its own. + require.NoError(t, shim.Append(uint64(i+1), []byte("payload"))) + } +} + +func firstIndex(t *testing.T, shim *legacyWALShim) uint64 { + t.Helper() + ok, first, _, err := shim.Bounds() + require.NoError(t, err) + require.True(t, ok) + return first +} + +func TestLegacyShimBoundsEmptyThenPopulated(t *testing.T) { + shim := newTestShim(t, 1) + + ok, _, _, err := shim.Bounds() + require.NoError(t, err) + require.False(t, ok, "a fresh legacy WAL must report no bounds") + + appendN(t, shim, 5) + + ok, first, last, err := shim.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first, "legacy WAL indices are 1-based") + require.Equal(t, uint64(5), last) +} + +func TestLegacyShimCoalescesPruneRequests(t *testing.T) { + const relaxation = 3 + shim := newTestShim(t, relaxation) + appendN(t, shim, 10) + + require.Equal(t, uint64(1), firstIndex(t, shim)) + + // The first two prune requests are swallowed; the underlying WAL is untouched. + require.NoError(t, shim.PruneBefore(2)) + require.Equal(t, uint64(1), firstIndex(t, shim)) + require.NoError(t, shim.PruneBefore(3)) + require.Equal(t, uint64(1), firstIndex(t, shim)) + + // The third request (a multiple of the relaxation factor) is forwarded, using its own + // lowestIndexToKeep. + require.NoError(t, shim.PruneBefore(4)) + require.Equal(t, uint64(4), firstIndex(t, shim)) + + // The cycle repeats: two more swallowed, then one forwarded. + require.NoError(t, shim.PruneBefore(5)) + require.NoError(t, shim.PruneBefore(6)) + require.Equal(t, uint64(4), firstIndex(t, shim)) + require.NoError(t, shim.PruneBefore(7)) + require.Equal(t, uint64(7), firstIndex(t, shim)) +} diff --git a/sei-db/seiwal/walsim/walsim.go b/sei-db/seiwal/walsim/walsim.go new file mode 100644 index 0000000000..be01fa4afe --- /dev/null +++ b/sei-db/seiwal/walsim/walsim.go @@ -0,0 +1,365 @@ +package walsim + +import ( + "context" + "fmt" + "os" + "time" + + crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" + "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "golang.org/x/time/rate" +) + +// WalSim is the benchmark runner for the walsim benchmark. +type WalSim struct { + ctx context.Context + cancel context.CancelFunc + + config *WalsimConfig + + store walStore + generator *RecordGenerator + metrics *WalsimMetrics + + // The index to assign to the next appended record. + nextIndex uint64 + + // The number of records to retain on disk, derived from TargetDiskSizeBytes. 0 disables pruning. + retainedRecords uint64 + + // Console reporting state. + consoleUpdatePeriod time.Duration + lastConsoleUpdateTime time.Time + lastConsoleUpdateRecordCount int64 + startTimestamp time.Time + totalRecordsWritten int64 + totalBytesWritten int64 + highestIndex uint64 + + // A message is sent on this channel when the benchmark is fully stopped. + closeChan chan struct{} + + // Suspend/resume toggle channel. + suspendChan chan bool + + // Enforces a maximum record write rate (if enabled). + rateLimiter *rate.Limiter +} + +// NewWalSim creates a new walsim benchmark runner and starts it. +func NewWalSim( + ctx context.Context, + config *WalsimConfig, + metrics *WalsimMetrics, +) (*WalSim, error) { + + var err error + config.DataDir, err = utils.ResolveAndCreateDir(config.DataDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve data directory: %w", err) + } + config.LogDir, err = utils.ResolveAndCreateDir(config.LogDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve log directory: %w", err) + } + + if config.CleanDataOnStart { + fmt.Printf("CleanDataOnStart is enabled, removing contents of: %s\n", config.DataDir) + if err := removeContents(config.DataDir); err != nil { + return nil, fmt.Errorf("failed to clean data directory: %w", err) + } + } + if config.CleanLogsOnStart { + fmt.Printf("CleanLogsOnStart is enabled, removing contents of: %s\n", config.LogDir) + if err := removeContents(config.LogDir); err != nil { + return nil, fmt.Errorf("failed to clean log directory: %w", err) + } + } + + fmt.Printf("Running walsim benchmark (%s backend) from data directory: %s\n", config.Backend, config.DataDir) + fmt.Printf("Logs are being routed to: %s\n", config.LogDir) + + fmt.Printf("Initializing random number generator.\n") + // Pre-generate a random buffer once; all record data slices into it (zero-copy) so the generator + // never runs math/rand on the hot path. + cannedRand := crand.NewCannedRandom(int(config.RandomDataBufferSizeBytes), config.Seed) //nolint:gosec // buffer size is bounded by config + + ctx, cancel := context.WithCancel(ctx) + + store, err := openWALStore(ctx, config) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to open WAL: %w", err) + } + + // Resume after any existing history instead of colliding with on-disk records. + ok, _, last, err := store.Bounds() + if err != nil { + cancel() + _ = store.Close() + return nil, fmt.Errorf("failed to read WAL bounds: %w", err) + } + var highest uint64 + var nextIndex uint64 = 1 + if ok { + highest = last + nextIndex = last + 1 + fmt.Printf("Resuming from index %d.\n", highest) + } + + var retainedRecords uint64 + if config.TargetDiskSizeBytes > 0 { + retainedRecords = config.TargetDiskSizeBytes / config.RecordSizeBytes + } + + generator := NewRecordGenerator( + ctx, + cannedRand, + int(config.RecordSizeBytes), //nolint:gosec // record size is bounded by config + int(config.StagedRecordQueueSize), //nolint:gosec // queue size is bounded by config + ) + + consoleUpdatePeriod := time.Duration(config.ConsoleUpdateIntervalSeconds * float64(time.Second)) + + var rateLimiter *rate.Limiter + if config.MaxRecordsPerSecond > 0 { + rateLimiter = rate.NewLimiter(rate.Limit(config.MaxRecordsPerSecond), 1) + } + + start := time.Now() + + b := &WalSim{ + ctx: ctx, + cancel: cancel, + config: config, + store: store, + generator: generator, + metrics: metrics, + nextIndex: nextIndex, + retainedRecords: retainedRecords, + consoleUpdatePeriod: consoleUpdatePeriod, + lastConsoleUpdateTime: start, + startTimestamp: start, + highestIndex: highest, + closeChan: make(chan struct{}, 1), + suspendChan: make(chan bool, 1), + rateLimiter: rateLimiter, + } + + go b.run() + return b, nil +} + +// The main loop of the benchmark. +func (b *WalSim) run() { + defer b.teardown() + + var timeoutChan <-chan time.Time + if b.config.MaxRuntimeSeconds > 0 { + timeoutChan = time.After(time.Duration(b.config.MaxRuntimeSeconds) * time.Second) + } + + for { + b.metrics.SetMainThreadPhase("get_record") + + select { + case <-b.ctx.Done(): + b.generateConsoleReport(true) + fmt.Printf("\nBenchmark halted.\n") + return + case isSuspended := <-b.suspendChan: + if isSuspended { + b.suspend() + } + case <-timeoutChan: + fmt.Printf("\nBenchmark timed out after %s.\n", + utils.FormatDuration(time.Since(b.startTimestamp), 1)) + b.cancel() + return + case record := <-b.generator.recordChan: + b.maybeThrottle() + b.handleNextRecord(record) + } + + b.generateConsoleReport(false) + } +} + +func (b *WalSim) maybeThrottle() { + if b.rateLimiter == nil { + return + } + b.metrics.SetMainThreadPhase("throttling") + if err := b.rateLimiter.Wait(b.ctx); err != nil { + return + } +} + +// handleNextRecord appends one record, then applies the periodic flush and size-targeted prune. +func (b *WalSim) handleNextRecord(record []byte) { + b.metrics.SetMainThreadPhase("append") + index := b.nextIndex + if err := b.store.Append(index, record); err != nil { + fmt.Printf("failed to append record %d: %v\n", index, err) + b.cancel() + return + } + b.nextIndex++ + b.totalRecordsWritten++ + b.totalBytesWritten += int64(len(record)) + b.highestIndex = index + b.metrics.ReportRecordWritten(int64(len(record))) + b.metrics.RecordHighestIndex(b.highestIndex) + + // Periodic flush. + if b.config.FlushIntervalRecords > 0 && b.totalRecordsWritten%int64(b.config.FlushIntervalRecords) == 0 { //nolint:gosec + b.metrics.SetMainThreadPhase("flush") + if err := b.store.Flush(); err != nil { + fmt.Printf("failed to flush: %v\n", err) + b.cancel() + return + } + b.metrics.ReportFlush() + } + + // Size-targeted prune: hold the on-disk data as close to TargetDiskSizeBytes as possible by + // keeping the most recent retainedRecords records. + if b.retainedRecords > 0 && b.highestIndex >= b.retainedRecords { + b.metrics.SetMainThreadPhase("prune") + lowestToKeep := b.highestIndex - b.retainedRecords + 1 + if err := b.store.PruneBefore(lowestToKeep); err != nil { + fmt.Printf("failed to prune: %v\n", err) + b.cancel() + return + } + b.metrics.ReportPruneRequest() + b.metrics.RecordLowestIndex(lowestToKeep) + } +} + +func (b *WalSim) suspend() { + // Flush before suspending so state is durable. + if err := b.store.Flush(); err != nil { + fmt.Printf("failed to flush on suspend: %v\n", err) + } + + fmt.Printf("Benchmark suspended.\n") + b.metrics.SetMainThreadPhase("suspended") + + for { + select { + case <-b.ctx.Done(): + return + case suspended := <-b.suspendChan: + if suspended { + break + } + // Reset console metrics on resume. + b.totalRecordsWritten = 0 + b.totalBytesWritten = 0 + b.startTimestamp = time.Now() + fmt.Printf("Benchmark resumed.\n") + return + } + } +} + +func (b *WalSim) teardown() { + fmt.Printf("Flushing and closing WAL.\n") + if err := b.store.Flush(); err != nil { + fmt.Printf("failed to flush during teardown: %v\n", err) + } + if err := b.store.Close(); err != nil { + fmt.Printf("failed to close WAL: %v\n", err) + } + + if b.config.CleanDataOnExit { + fmt.Printf("CleanDataOnExit is enabled, removing contents of: %s\n", b.config.DataDir) + if err := removeContents(b.config.DataDir); err != nil { + fmt.Printf("failed to clean data directory on exit: %v\n", err) + } + } + if b.config.CleanLogsOnExit { + fmt.Printf("CleanLogsOnExit is enabled, removing contents of: %s\n", b.config.LogDir) + if err := removeContents(b.config.LogDir); err != nil { + fmt.Printf("failed to clean log directory on exit: %v\n", err) + } + } + + b.closeChan <- struct{}{} +} + +func (b *WalSim) generateConsoleReport(force bool) { + now := time.Now() + timeSinceLastUpdate := now.Sub(b.lastConsoleUpdateTime) + recordsSinceLastUpdate := b.totalRecordsWritten - b.lastConsoleUpdateRecordCount + + if !force && + timeSinceLastUpdate < b.consoleUpdatePeriod && + recordsSinceLastUpdate < int64(b.config.ConsoleUpdateIntervalRecords) { //nolint:gosec + return + } + + b.lastConsoleUpdateTime = now + b.lastConsoleUpdateRecordCount = b.totalRecordsWritten + + elapsed := now.Sub(b.startTimestamp) + bytesPerSecond := float64(b.totalBytesWritten) / elapsed.Seconds() + recordsPerSecond := float64(b.totalRecordsWritten) / elapsed.Seconds() + + fmt.Printf("%s records in %s | %s written | %s/sec | %s rec/sec \r", + utils.Int64Commas(b.totalRecordsWritten), + utils.FormatDuration(elapsed, 1), + utils.FormatBytes(b.totalBytesWritten), + utils.FormatBytes(int64(bytesPerSecond)), + utils.Int64Commas(int64(recordsPerSecond))) +} + +// BlockUntilHalted blocks until the benchmark has halted. +func (b *WalSim) BlockUntilHalted() { + <-b.closeChan + b.closeChan <- struct{}{} +} + +// Close shuts down the benchmark and releases resources. +func (b *WalSim) Close() error { + b.cancel() + <-b.closeChan + b.closeChan <- struct{}{} + fmt.Printf("Benchmark terminated successfully.\n") + return nil +} + +// Suspend the benchmark. Call Resume() to continue. +func (b *WalSim) Suspend() { + select { + case <-b.ctx.Done(): + case b.suspendChan <- true: + } +} + +// Resume the benchmark after a Suspend(). +func (b *WalSim) Resume() { + select { + case <-b.ctx.Done(): + case b.suspendChan <- false: + } +} + +// removeContents deletes all entries inside dir without removing dir itself. +func removeContents(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if err := os.RemoveAll(fmt.Sprintf("%s/%s", dir, entry.Name())); err != nil { + return err + } + } + return nil +} diff --git a/sei-db/seiwal/walsim/walsim.sh b/sei-db/seiwal/walsim/walsim.sh new file mode 100755 index 0000000000..b3d0b3a25a --- /dev/null +++ b/sei-db/seiwal/walsim/walsim.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Resolve script directory (handles symlinks and relative paths). +SCRIPT_SOURCE="${BASH_SOURCE[0]}" +[[ "$SCRIPT_SOURCE" != /* ]] && SCRIPT_SOURCE="$(pwd)/${SCRIPT_SOURCE#./}" +while [[ -L "$SCRIPT_SOURCE" ]]; do + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)" + SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")" + [[ "$SCRIPT_SOURCE" != /* ]] && SCRIPT_SOURCE="${SCRIPT_DIR}/${SCRIPT_SOURCE}" +done +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)" +BINARY="${SCRIPT_DIR}/bin/walsim" + +# Build binaries (no-op if already up to date; Go's build cache handles staleness). +make -C "$SCRIPT_DIR" build + +# Configure seilog env vars from the config file. The config file is the sole +# source of truth -- any pre-existing SEI_LOG_* env vars are overwritten. +if [[ $# -ge 1 && -f "$1" ]]; then + LOGGER_OUTPUT=$("${SCRIPT_DIR}/bin/configure-logger" "$1") || { + echo "configure-logger failed" >&2 + exit 1 + } + eval "$LOGGER_OUTPUT" +fi + +# Run the benchmark. +exec "$BINARY" "$@" diff --git a/sei-db/seiwal/walsim/walsim_config.go b/sei-db/seiwal/walsim/walsim_config.go new file mode 100644 index 0000000000..598e30fb62 --- /dev/null +++ b/sei-db/seiwal/walsim/walsim_config.go @@ -0,0 +1,194 @@ +package walsim + +import ( + "fmt" + "strings" + + "github.com/sei-protocol/sei-chain/sei-db/common/unit" + "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "github.com/sei-protocol/sei-chain/sei-db/seiwal" + "github.com/sei-protocol/sei-chain/sei-db/wal" +) + +var _ utils.Config = (*WalsimConfig)(nil) + +// WalsimConfig is the configuration for the walsim benchmark. +type WalsimConfig struct { + + // The WAL implementation to benchmark. One of "seiwal" (the new WAL) or "legacy" (the old + // sei-db/wal WAL, driven through a throwaway adapter). + Backend string + + // The size in bytes of each record appended to the WAL. Every append writes exactly this many + // bytes of opaque, canned-random data. + RecordSizeBytes uint64 + + // Size in bytes of the pre-generated random buffer used to synthesize record payloads. The buffer + // is filled once at startup and sliced (zero-copy) thereafter, so the generator never runs + // math/rand or allocates payload bytes on the hot path. Must be at least RecordSizeBytes. + RandomDataBufferSizeBytes uint64 + + // The capacity of the queue that holds generated records before they are consumed by the + // benchmark. A larger queue lets the generator run further ahead of the consumer. + StagedRecordQueueSize uint64 + + // How often (in records) to call Flush() on the WAL. 0 means never explicitly flush. + FlushIntervalRecords uint64 + + // The target size in bytes of the on-disk data. Once the retained data would exceed this, the + // benchmark issues prune requests to hold the on-disk size as close to this target as possible. + // 0 disables pruning entirely. + TargetDiskSizeBytes uint64 + + // For the "legacy" backend only: coalesce prune requests, forwarding only every Nth PruneBefore + // to the underlying (expensive) TruncateBefore. Ignored by the "seiwal" backend. Must be at + // least 1. + PruneRelaxationFactor uint64 + + // The configuration for the "seiwal" backend, used only when Backend is "seiwal". Path and Name + // are owned by walsim (Path is taken from DataDir); any values supplied for them here are + // overwritten. + Seiwal seiwal.Config + + // The configuration for the "legacy" backend, used only when Backend is "legacy". The storage + // directory is taken from DataDir, and KeepRecent/PruneInterval are forced off so that walsim + // drives pruning through PruneBefore; any values supplied for them here are overwritten. + Legacy wal.Config + + // The seed to use for the random number generator. Altering this seed for a pre-existing data + // directory is harmless (records are opaque), but keeping it stable makes runs reproducible. + Seed int64 + + // The directory to store the benchmark data. + DataDir string + + // If this many seconds go by without a console update, the benchmark will print a report. + ConsoleUpdateIntervalSeconds float64 + + // If this many records are written without a console update, the benchmark will print a report. + // Prevents console spam when throughput is very high. + ConsoleUpdateIntervalRecords uint64 + + // The amount of time to run the benchmark for, in seconds. If 0, runs until interrupted. + MaxRuntimeSeconds int + + // Address for the Prometheus metrics HTTP server (e.g. ":9090"). If empty, metrics are disabled. + MetricsAddr string + + // If true, pressing Enter in the terminal will toggle suspend/resume of the benchmark. + EnableSuspension bool + + // How often (in seconds) to scrape background metrics (data dir size, uptime). If 0, background + // metrics are disabled. + BackgroundMetricsScrapeInterval int + + // Directory for seilog output files. Supports ~ expansion and relative paths. + LogDir string + + // Log level for seilog output. Valid values: debug, info, warn, error. + LogLevel string + + // If true, delete the contents of DataDir before opening the WAL. + CleanDataOnStart bool + + // If true, delete the contents of LogDir before starting. + CleanLogsOnStart bool + + // If true, delete the contents of DataDir after the benchmark finishes. + CleanDataOnExit bool + + // If true, delete the contents of LogDir after the benchmark finishes. + CleanLogsOnExit bool + + // Throttle record write rate to this many records per second. 0 = disabled (unlimited + // throughput). Useful for testing steady-state performance rather than max-throughput thrashing. + MaxRecordsPerSecond float64 + + // This field is ignored, but allows for a comment to be added to the config file. + Comment string +} + +// DefaultWalsimConfig returns the default configuration for the walsim benchmark. +func DefaultWalsimConfig() *WalsimConfig { + return &WalsimConfig{ + Backend: "seiwal", + RecordSizeBytes: 1 * unit.MB, + RandomDataBufferSizeBytes: unit.GB, + StagedRecordQueueSize: 16, + FlushIntervalRecords: 1, + TargetDiskSizeBytes: 1 * unit.GB, + PruneRelaxationFactor: 100, + // Path is set by walsim from DataDir at open time. + Seiwal: *seiwal.DefaultConfig("", "walsim"), + Legacy: wal.Config{ + WriteBufferSize: 0, + WriteBatchSize: 64, + FsyncEnabled: false, + }, + Seed: 1337, + DataDir: "data", + ConsoleUpdateIntervalSeconds: 1, + ConsoleUpdateIntervalRecords: 10_000, + MaxRuntimeSeconds: 0, + MetricsAddr: ":9090", + EnableSuspension: true, + BackgroundMetricsScrapeInterval: 60, + LogDir: "logs", + LogLevel: "info", + CleanDataOnStart: false, + CleanLogsOnStart: false, + CleanDataOnExit: false, + CleanLogsOnExit: false, + MaxRecordsPerSecond: 0, + } +} + +// Validate checks that the configuration is sane and returns an error if not. +func (c *WalsimConfig) Validate() error { + switch c.Backend { + case "seiwal", "legacy": + default: + return fmt.Errorf("invalid Backend %q, must be one of seiwal, legacy", c.Backend) + } + if c.RecordSizeBytes < 1 { + return fmt.Errorf("RecordSizeBytes must be at least 1 (got %d)", c.RecordSizeBytes) + } + if c.RandomDataBufferSizeBytes < c.RecordSizeBytes { + return fmt.Errorf("RandomDataBufferSizeBytes must be at least RecordSizeBytes (%d) (got %d)", + c.RecordSizeBytes, c.RandomDataBufferSizeBytes) + } + if c.StagedRecordQueueSize < 1 { + return fmt.Errorf("StagedRecordQueueSize must be at least 1 (got %d)", c.StagedRecordQueueSize) + } + if c.TargetDiskSizeBytes > 0 && c.TargetDiskSizeBytes < c.RecordSizeBytes { + return fmt.Errorf("TargetDiskSizeBytes, when non-zero, must be at least RecordSizeBytes (%d) (got %d)", + c.RecordSizeBytes, c.TargetDiskSizeBytes) + } + if c.PruneRelaxationFactor < 1 { + return fmt.Errorf("PruneRelaxationFactor must be at least 1 (got %d)", c.PruneRelaxationFactor) + } + if c.DataDir == "" { + return fmt.Errorf("DataDir is required") + } + if c.LogDir == "" { + return fmt.Errorf("LogDir is required") + } + if c.ConsoleUpdateIntervalSeconds < 0 { + return fmt.Errorf("ConsoleUpdateIntervalSeconds must be non-negative (got %f)", c.ConsoleUpdateIntervalSeconds) + } + if c.MaxRuntimeSeconds < 0 { + return fmt.Errorf("MaxRuntimeSeconds must be non-negative (got %d)", c.MaxRuntimeSeconds) + } + if c.BackgroundMetricsScrapeInterval < 0 { + return fmt.Errorf("BackgroundMetricsScrapeInterval must be non-negative (got %d)", c.BackgroundMetricsScrapeInterval) + } + if c.MaxRecordsPerSecond < 0 { + return fmt.Errorf("MaxRecordsPerSecond must be non-negative (got %f)", c.MaxRecordsPerSecond) + } + switch strings.ToLower(c.LogLevel) { + case "debug", "info", "warn", "error": + default: + return fmt.Errorf("LogLevel must be one of debug, info, warn, error (got %q)", c.LogLevel) + } + return nil +} diff --git a/sei-db/seiwal/walsim/walsim_metrics.go b/sei-db/seiwal/walsim/walsim_metrics.go new file mode 100644 index 0000000000..c100981e6d --- /dev/null +++ b/sei-db/seiwal/walsim/walsim_metrics.go @@ -0,0 +1,148 @@ +package walsim + +import ( + "context" + + "github.com/sei-protocol/sei-chain/sei-db/common/metrics" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +const walsimMeterName = "walsim" + +// WalsimMetrics holds OpenTelemetry metrics for the walsim benchmark. +type WalsimMetrics struct { + ctx context.Context + + recordsWrittenTotal metric.Int64Counter + bytesWrittenTotal metric.Int64Counter + flushCallsTotal metric.Int64Counter + pruneRequestsTotal metric.Int64Counter + + highestIndex metric.Int64Gauge + lowestIndex metric.Int64Gauge + recordSizeBytes metric.Int64Gauge + + mainThreadPhase *metrics.PhaseTimer +} + +// NewWalsimMetrics creates metrics for the walsim benchmark using the global OTel MeterProvider. The +// caller must configure the MeterProvider before calling this. +func NewWalsimMetrics(ctx context.Context, config *WalsimConfig) *WalsimMetrics { + meter := otel.Meter(walsimMeterName) + + recordsWrittenTotal, _ := meter.Int64Counter( + "walsim_records_written_total", + metric.WithDescription("Total number of records appended to the WAL"), + metric.WithUnit("{count}"), + ) + bytesWrittenTotal, _ := meter.Int64Counter( + "walsim_bytes_written_total", + metric.WithDescription("Total bytes of record payload data appended to the WAL"), + metric.WithUnit("By"), + ) + flushCallsTotal, _ := meter.Int64Counter( + "walsim_flush_calls_total", + metric.WithDescription("Total number of Flush calls"), + metric.WithUnit("{count}"), + ) + pruneRequestsTotal, _ := meter.Int64Counter( + "walsim_prune_requests_total", + metric.WithDescription("Total number of prune requests issued by the benchmark (before any legacy coalescing)"), + metric.WithUnit("{count}"), + ) + + highestIndex, _ := meter.Int64Gauge( + "walsim_highest_index", + metric.WithDescription("Highest record index currently stored in the WAL"), + metric.WithUnit("{index}"), + ) + lowestIndex, _ := meter.Int64Gauge( + "walsim_lowest_index", + metric.WithDescription("Lowest record index the benchmark has requested be retained"), + metric.WithUnit("{index}"), + ) + recordSizeBytes, _ := meter.Int64Gauge( + "walsim_record_size_bytes", + metric.WithDescription("Size in bytes of a single record (constant for a given config)"), + metric.WithUnit("By"), + ) + + mainThreadPhase := metrics.NewPhaseTimer(meter, "walsim_main_thread") + + m := &WalsimMetrics{ + ctx: ctx, + recordsWrittenTotal: recordsWrittenTotal, + bytesWrittenTotal: bytesWrittenTotal, + flushCallsTotal: flushCallsTotal, + pruneRequestsTotal: pruneRequestsTotal, + highestIndex: highestIndex, + lowestIndex: lowestIndex, + recordSizeBytes: recordSizeBytes, + mainThreadPhase: mainThreadPhase, + } + + m.recordRecordSize(config) + return m +} + +func (m *WalsimMetrics) recordRecordSize(config *WalsimConfig) { + if m == nil || m.recordSizeBytes == nil { + return + } + m.recordSizeBytes.Record(context.Background(), int64(config.RecordSizeBytes)) //nolint:gosec // record size is bounded by config +} + +// ReportRecordWritten records a single appended record of the given byte count. +func (m *WalsimMetrics) ReportRecordWritten(byteCount int64) { + if m == nil { + return + } + ctx := context.Background() + if m.recordsWrittenTotal != nil { + m.recordsWrittenTotal.Add(ctx, 1) + } + if m.bytesWrittenTotal != nil { + m.bytesWrittenTotal.Add(ctx, byteCount) + } +} + +// ReportFlush records a Flush call. +func (m *WalsimMetrics) ReportFlush() { + if m == nil || m.flushCallsTotal == nil { + return + } + m.flushCallsTotal.Add(context.Background(), 1) +} + +// ReportPruneRequest records a prune request issued by the benchmark. +func (m *WalsimMetrics) ReportPruneRequest() { + if m == nil || m.pruneRequestsTotal == nil { + return + } + m.pruneRequestsTotal.Add(context.Background(), 1) +} + +// RecordHighestIndex records the highest stored record index as a gauge. +func (m *WalsimMetrics) RecordHighestIndex(index uint64) { + if m == nil || m.highestIndex == nil { + return + } + m.highestIndex.Record(context.Background(), int64(index)) //nolint:gosec // index fits in int64 for any realistic run +} + +// RecordLowestIndex records the lowest retained record index as a gauge. +func (m *WalsimMetrics) RecordLowestIndex(index uint64) { + if m == nil || m.lowestIndex == nil { + return + } + m.lowestIndex.Record(context.Background(), int64(index)) //nolint:gosec // index fits in int64 for any realistic run +} + +// SetMainThreadPhase records a transition of the main loop into the named phase. +func (m *WalsimMetrics) SetMainThreadPhase(phase string) { + if m == nil || m.mainThreadPhase == nil { + return + } + m.mainThreadPhase.SetPhase(phase) +} From 086b4e25b3af8a37af0c2b3f3ee532072eaec9f2 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 15 Jul 2026 10:40:36 -0500 Subject: [PATCH 36/44] make suggested changes to iterator --- sei-db/seiwal/seiwal.go | 18 +- sei-db/seiwal/seiwal_impl.go | 62 +++--- sei-db/seiwal/seiwal_impl_test.go | 180 ++++++++++++++---- sei-db/seiwal/seiwal_iterator_test.go | 85 +++++++-- sei-db/seiwal/seiwal_serializing.go | 17 +- sei-db/seiwal/seiwal_serializing_test.go | 18 +- sei-db/state_db/statewal/state_wal.go | 9 +- .../state_db/statewal/state_wal_brick_test.go | 6 +- sei-db/state_db/statewal/state_wal_impl.go | 16 +- .../state_db/statewal/state_wal_impl_test.go | 19 +- .../statewal/state_wal_iterator_test.go | 43 +++-- 11 files changed, 342 insertions(+), 131 deletions(-) diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index 54c39aed6f..b5d9063e7e 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -1,6 +1,14 @@ package seiwal -import "fmt" +import ( + "errors" + "fmt" +) + +// ErrIteratorRange is returned by Iterator when the requested [startIndex, endIndex] range is invalid: +// endIndex below startIndex, or endIndex above the highest index currently stored in the WAL (including when +// the WAL is empty). It signals a caller error, not a failure of the WAL, so the WAL remains usable. +var ErrIteratorRange = errors.New("invalid iterator range") // WAL is a generic, index-keyed, append-only write-ahead log over payloads of type T. // @@ -58,13 +66,17 @@ type WAL[T any] interface { // is fully below it. PruneBefore(lowestIndexToKeep uint64) error - // Iterator returns an iterator over the WAL starting at the given index. + // Iterator returns an iterator over the WAL across the inclusive index range [startIndex, endIndex]. + // + // The iterator yields no record with an index below startIndex or above endIndex. It is an error for + // endIndex to be below startIndex, or for endIndex to be above the highest index currently stored in the + // WAL (including when the WAL is empty); both are reported as ErrIteratorRange and leave the WAL usable. // // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the // start and the return of this call. Records appended before that instant are included; records // appended after it are not. For records appended concurrently with this call, whether they are // included is unspecified. - Iterator(startIndex uint64) (Iterator[T], error) + Iterator(startIndex uint64, endIndex uint64) (Iterator[T], error) // Close flushes pending appends, seals the current file, and releases resources. Close() error diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 7a7a7162d0..0bc3a2139c 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -2,6 +2,7 @@ package seiwal import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -50,6 +51,7 @@ type closeRequest struct { // serialized with rotation/seal/prune. type iteratorStartRequest struct { startIndex uint64 + endIndex uint64 reply chan iteratorStartResponse } @@ -314,13 +316,13 @@ func (w *walImpl) PruneBefore(lowestIndexToKeep uint64) error { return nil } -// Iterator returns an iterator over the WAL starting at startIndex. Construction runs on the writer goroutine -// (see iteratorStartRequest): the writer captures a hard-link snapshot of the files to read so later rotation -// and pruning cannot pull them out from under the iterator, and builds the iterator. The snapshot is removed -// by the iterator's Close. -func (w *walImpl) Iterator(startIndex uint64) (Iterator[[]byte], error) { +// Iterator returns an iterator over the inclusive index range [startIndex, endIndex]. Construction runs on +// the writer goroutine (see iteratorStartRequest): the writer captures a hard-link snapshot of the files to +// read so later rotation and pruning cannot pull them out from under the iterator, and builds the iterator. +// The snapshot is removed by the iterator's Close. +func (w *walImpl) Iterator(startIndex uint64, endIndex uint64) (Iterator[[]byte], error) { reply := make(chan iteratorStartResponse, 1) - if err := w.sendToWriter(iteratorStartRequest{startIndex: startIndex, reply: reply}); err != nil { + if err := w.sendToWriter(iteratorStartRequest{startIndex: startIndex, endIndex: endIndex, reply: reply}); err != nil { return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) } select { @@ -419,9 +421,11 @@ func (w *walImpl) writerLoop() { return } case iteratorStartRequest: - resp := w.startIterator(m.startIndex) + resp := w.startIterator(m.startIndex, m.endIndex) m.reply <- resp - if resp.err != nil { + // A rejected range is the caller's error and leaves the WAL healthy; only a genuine I/O failure + // (a failed flush or hard-link) bricks the pipeline. + if resp.err != nil && !errors.Is(resp.err, ErrIteratorRange) { w.fail(resp.err) return } @@ -524,25 +528,37 @@ func (w *walImpl) pruneSealedFiles(pruneThrough uint64) error { // startIterator builds an iterator on the writer goroutine. It captures a point-in-time snapshot by // hard-linking every file the iterator will read into a private directory (iterator//) and bounding it -// at the highest index stored now. Running here serializes the snapshot with rotation, seal, and prune. The -// live WAL may then rotate, seal, and prune freely: the iterator reads only its own hard links, which keep the -// underlying inodes alive until Close removes them. The mutable file is flushed (not fsynced) so its records -// are readable through the reader's separate handle — no crash can intervene between creation and use, so -// durability is irrelevant here. -func (w *walImpl) startIterator(startIndex uint64) iteratorStartResponse { +// at endIndex, which must not exceed the highest index stored now. Running here serializes the snapshot with +// rotation, seal, and prune. The live WAL may then rotate, seal, and prune freely: the iterator reads only its +// own hard links, which keep the underlying inodes alive until Close removes them. The mutable file is flushed +// (not fsynced) so its records are readable through the reader's separate handle — no crash can intervene +// between creation and use, so durability is irrelevant here. +func (w *walImpl) startIterator(startIndex uint64, endIndex uint64) iteratorStartResponse { + if endIndex < startIndex { + return iteratorStartResponse{ + err: fmt.Errorf("%w: end index %d is below start index %d", ErrIteratorRange, endIndex, startIndex), + } + } r := w.bounds() if !r.ok { - // Nothing stored: an empty iterator with no snapshot directory. - return iteratorStartResponse{iterator: newWalIterator(w, startIndex, 0, "", nil, w.config.IteratorPrefetchSize)} + return iteratorStartResponse{ + err: fmt.Errorf("%w: end index %d requested but the WAL is empty", ErrIteratorRange, endIndex), + } + } + if endIndex > r.last { + return iteratorStartResponse{ + err: fmt.Errorf("%w: end index %d is beyond the latest stored index %d", ErrIteratorRange, endIndex, r.last), + } } - maxIndex := r.last + maxIndex := endIndex - // Gather the files to read: sealed files reaching startIndex, then the mutable file if it holds records at - // or above startIndex. Files entirely below startIndex are never opened, so they are not linked. The - // mutable snapshot's range is capped at maxIndex; the reader drops anything the writer appends past it. + // Gather the files to read: those whose range overlaps [startIndex, endIndex], plus the mutable file if it + // holds records in range. Files entirely below startIndex or entirely above endIndex are never opened, so + // they are not linked. The mutable snapshot's range is capped at maxIndex; the reader drops anything the + // writer appends past it. var sources []iteratorFile for _, info := range w.sealedFiles.Iterator() { - if info.lastIndex < startIndex { + if info.lastIndex < startIndex || info.firstIndex > endIndex { continue } sources = append(sources, iteratorFile{ @@ -550,7 +566,9 @@ func (w *walImpl) startIterator(startIndex uint64) iteratorStartResponse { firstIndex: info.firstIndex, lastIndex: info.lastIndex, sealed: true, }) } - if w.mutableFile.hasRecords && w.mutableFile.lastIndex >= startIndex { + // Flush the mutable file only when it is actually needed to cover the range. When endIndex is fully served + // by sealed files, the mutable file is left untouched. + if w.mutableFile.hasRecords && w.mutableFile.lastIndex >= startIndex && w.mutableFile.firstIndex <= endIndex { if err := w.mutableFile.flush(false); err != nil { return iteratorStartResponse{err: fmt.Errorf("failed to flush mutable file for iterator: %w", err)} } diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 251fa858cb..4e0d275774 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -48,11 +48,11 @@ func appendRecord(t *testing.T, w WAL[[]byte], index uint64) { require.NoError(t, w.Append(index, recordPayload(index))) } -// collectIndices iterates from start and returns the index of each record, verifying that indices are -// strictly increasing and never below start. -func collectIndices(t *testing.T, w WAL[[]byte], start uint64) []uint64 { +// collectIndices iterates the inclusive range [start, end] and returns the index of each record, verifying +// that indices are strictly increasing and never below start or above end. +func collectIndices(t *testing.T, w WAL[[]byte], start uint64, end uint64) []uint64 { t.Helper() - it, err := w.Iterator(start) + it, err := w.Iterator(start, end) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -65,6 +65,7 @@ func collectIndices(t *testing.T, w WAL[[]byte], start uint64) []uint64 { } index, _ := it.Entry() require.GreaterOrEqual(t, index, start) + require.LessOrEqual(t, index, end) if len(indices) > 0 { require.Greater(t, index, indices[len(indices)-1]) } @@ -127,7 +128,7 @@ func TestAppendFlushReopenBounds(t *testing.T) { require.Equal(t, uint64(1), first) require.Equal(t, uint64(5), last) - require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectIndices(t, w2, 1)) + require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectIndices(t, w2, 1, 5)) } func TestAppendOrdering(t *testing.T) { @@ -174,7 +175,7 @@ func TestAppendOrdering(t *testing.T) { require.NoError(t, w.Append(3, recordPayload(3))) require.NoError(t, w.Append(100, recordPayload(100))) require.NoError(t, w.Flush()) - require.Equal(t, []uint64{1, 3, 100}, collectIndices(t, w, 0)) + require.Equal(t, []uint64{1, 3, 100}, collectIndices(t, w, 0, 100)) }) t.Run("empty payload is allowed", func(t *testing.T) { @@ -184,7 +185,7 @@ func TestAppendOrdering(t *testing.T) { require.NoError(t, w.Append(2, []byte{})) require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 2) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() ok, err := it.Next() @@ -337,7 +338,7 @@ func TestOrphanFileRecovery(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(2), last) - require.Equal(t, []uint64{1, 2}, collectIndices(t, w, 1)) + require.Equal(t, []uint64{1, 2}, collectIndices(t, w, 1, 2)) } func TestRotationProducesContiguousSealedFiles(t *testing.T) { @@ -356,7 +357,7 @@ func TestRotationProducesContiguousSealedFiles(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(6), last) - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectIndices(t, w, 1)) + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectIndices(t, w, 1, 6)) require.NoError(t, w.Close()) // Every record should have produced its own sealed file with a clean [k,k] range. @@ -394,7 +395,7 @@ func TestRecordNeverSplitAcrossFiles(t *testing.T) { // Each oversized record rotated into its own file, intact — never split across files. require.Equal(t, 2, countSealedFiles(t, dir)) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 2) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -436,7 +437,7 @@ func TestPruneDropsWholeFiles(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(5), first) require.Equal(t, uint64(10), last) - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0)) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0, 10)) } func TestPrunePastAllRecordsEmptiesRange(t *testing.T) { @@ -458,35 +459,38 @@ func TestPrunePastAllRecordsEmptiesRange(t *testing.T) { require.False(t, ok) } -// TestIteratorOnEmptyWALDoesNotBlockPruning covers an iterator created over a fresh, empty WAL: its file -// snapshot is empty (no hard links, no private directory), so it holds nothing on disk. Held open across later -// appends and a prune, it neither yields anything nor impedes pruning, which proceeds unconditionally. -func TestIteratorOnEmptyWALDoesNotBlockPruning(t *testing.T) { +// TestIteratorWithEmptySnapshotDoesNotBlockPruning covers an iterator whose requested range falls entirely in +// an index gap, so its file snapshot is empty (no hard links, no private directory) and it holds nothing on +// disk. Held open across a prune, it neither yields anything nor impedes pruning, which proceeds +// unconditionally. +func TestIteratorWithEmptySnapshotDoesNotBlockPruning(t *testing.T) { dir := t.TempDir() cfg := testConfig(dir) cfg.TargetFileSize = 1 // one record per sealed file, so pruning works file-by-file + cfg.PermitGaps = true // the requested range lands in a gap w := openWAL(t, cfg) defer func() { require.NoError(t, w.Close()) }() - // Create the iterator while the WAL is empty and hold it open (never closed until the end). - it, err := w.Iterator(0) - require.NoError(t, err) - ok, err := it.Next() - require.NoError(t, err) - require.False(t, ok, "an iterator over an empty WAL yields no records") - - for index := uint64(1); index <= 10; index++ { + // Indices 1,2,3 then a legal jump to 10,11,12. No file covers the range [5, 8], so the snapshot is empty. + for _, index := range []uint64{1, 2, 3, 10, 11, 12} { appendRecord(t, w, index) } require.NoError(t, w.Flush()) - require.NoError(t, w.PruneBefore(5)) + // Create the empty-snapshot iterator and hold it open (never closed until the end). + it, err := w.Iterator(5, 8) + require.NoError(t, err) + ok, err := it.Next() + require.NoError(t, err) + require.False(t, ok, "an iterator whose range falls in a gap yields no records") + + require.NoError(t, w.PruneBefore(11)) stored, first, last, err := w.Bounds() require.NoError(t, err) require.True(t, stored) - require.Equal(t, uint64(5), first, "the empty-snapshot iterator must not pin index 0 and block pruning") - require.Equal(t, uint64(10), last) + require.Equal(t, uint64(11), first, "the empty-snapshot iterator must not block pruning") + require.Equal(t, uint64(12), last) require.NoError(t, it.Close()) } @@ -524,7 +528,7 @@ func TestActiveIteratorReadsThroughUnconditionalPruning(t *testing.T) { require.NoError(t, w.Flush()) // Snapshot indices 1..10 (hard-linked) at creation. - it, err := w.Iterator(1) + it, err := w.Iterator(1, 10) require.NoError(t, err) // Pruning proceeds unconditionally: Bounds advances even though the iterator is live. @@ -540,7 +544,7 @@ func TestActiveIteratorReadsThroughUnconditionalPruning(t *testing.T) { require.NoError(t, it.Close()) // A fresh iterator sees only the post-prune range. - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0)) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0, 10)) } func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { @@ -556,7 +560,7 @@ func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { require.NoError(t, w.Flush()) // An iterator anchored at index 8 does not need records below 5, so pruning to 5 proceeds. - it, err := w.Iterator(8) + it, err := w.Iterator(8, 10) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -586,7 +590,7 @@ func TestIteratorAcrossGapReadsThroughPruning(t *testing.T) { // Snapshot links the files reaching index 5 or higher: those for 10, 11, 12. Files 1,2,3 are below the // start and are not linked. - it, err := w.Iterator(5) + it, err := w.Iterator(5, 12) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -619,7 +623,7 @@ func TestIteratorReadsThroughPruningPastAnchor(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(5) + it, err := w.Iterator(5, 10) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -645,7 +649,7 @@ func TestIteratorDoesNotSealMutableFile(t *testing.T) { require.NoError(t, w.Flush()) require.Equal(t, 0, countSealedFiles(t, dir), "records stay in the mutable file; nothing sealed yet") - it, err := w.Iterator(1) + it, err := w.Iterator(1, 3) require.NoError(t, err) require.Equal(t, 0, countSealedFiles(t, dir), "opening an iterator must not seal the mutable file") require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) @@ -654,7 +658,101 @@ func TestIteratorDoesNotSealMutableFile(t *testing.T) { // The mutable file is untouched, so appends continue contiguously. appendRecord(t, w, 4) require.NoError(t, w.Flush()) - require.Equal(t, []uint64{1, 2, 3, 4}, collectIndices(t, w, 1)) + require.Equal(t, []uint64{1, 2, 3, 4}, collectIndices(t, w, 1, 4)) +} + +// unsealedFilePath returns the path of the single mutable (unsealed) WAL file in dir. +func unsealedFilePath(t *testing.T, dir string) string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + var name string + for _, entry := range entries { + if parsed, ok := parseFileName(entry.Name()); ok && !parsed.sealed { + require.Empty(t, name, "expected exactly one mutable file") + name = entry.Name() + } + } + require.NotEmpty(t, name, "no mutable file found") + return filepath.Join(dir, name) +} + +// onDiskSize returns the number of bytes currently written to disk for the file at path. Records buffered in +// the mutable file's writer but not yet flushed do not count. +func onDiskSize(t *testing.T, path string) int64 { + t.Helper() + info, err := os.Stat(path) + require.NoError(t, err) + return info.Size() +} + +// TestIteratorDoesNotFlushMutableFileOutsideRange verifies that when the requested range is fully covered by +// sealed files, the mutable file (which holds only higher indices) is left unflushed — its buffered records +// stay off disk. As a positive control, a range that reaches into the mutable file does flush it. +func TestIteratorDoesNotFlushMutableFileOutsideRange(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 250 // seals after three ~107-byte records, leaving the rest in the mutable file + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + + payload := make([]byte, 100) + for index := uint64(1); index <= 5; index++ { + require.NoError(t, w.Append(index, payload)) + } + // Bounds is a writer-goroutine round-trip that drains the appends without flushing the mutable buffer. + ok, _, last, err := w.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(5), last) + require.Equal(t, 1, countSealedFiles(t, dir), "records 1..3 sealed into one file; 4,5 buffered in the mutable file") + + mutablePath := unsealedFilePath(t, dir) + before := onDiskSize(t, mutablePath) + + // Range [1,3] is fully covered by the sealed file; the mutable file (first index 4) must not be flushed. + it, err := w.Iterator(1, 3) + require.NoError(t, err) + require.Equal(t, before, onDiskSize(t, mutablePath), "the mutable file must not be flushed when outside the range") + require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) + require.NoError(t, it.Close()) + + // Positive control: a range reaching into the mutable file flushes it, so its bytes reach disk. + it2, err := w.Iterator(1, 5) + require.NoError(t, err) + require.Greater(t, onDiskSize(t, mutablePath), before, "the mutable file is flushed when the range needs it") + require.Equal(t, []uint64{1, 2, 3, 4, 5}, drainIndices(t, it2)) + require.NoError(t, it2.Close()) +} + +// TestIteratorDoesNotLinkFilesOutsideRange verifies that only the sealed files overlapping the requested range +// are hard-linked into the iterator's snapshot directory; files entirely below the start or above the end are +// not linked. +func TestIteratorDoesNotLinkFilesOutsideRange(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + cfg.TargetFileSize = 1 // one record per sealed file + w := openWAL(t, cfg) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + // Range [2,4] overlaps only the files for indices 2, 3, and 4. + it, err := w.Iterator(2, 4) + require.NoError(t, err) + + linkDir := iteratorLinkDir(dir, 0) // first iterator gets serial 0 + linked := sealedFileNames(t, linkDir) + require.Equal(t, []string{ + sealedFileName(1, 2, 2), + sealedFileName(2, 3, 3), + sealedFileName(3, 4, 4), + }, linked, "only files overlapping [2,4] are linked; those below 2 or above 4 are not") + + require.Equal(t, []uint64{2, 3, 4}, drainIndices(t, it)) + require.NoError(t, it.Close()) } // TestIteratorExcludesRecordsAppendedAfterCreation verifies the point-in-time cap: records appended (and @@ -669,7 +767,7 @@ func TestIteratorExcludesRecordsAppendedAfterCreation(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(1) // snapshot caps at index 3 + it, err := w.Iterator(1, 3) // snapshot caps at index 3 require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -691,7 +789,7 @@ func TestIteratorSnapshotDirLifecycle(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 3) require.NoError(t, err) linkDir := iteratorLinkDir(dir, 0) // first iterator gets serial 0 @@ -1001,7 +1099,7 @@ func TestGetRange(t *testing.T) { // A subsequent normal open round-trips cleanly against the sealed range. w := openWAL(t, testConfig(dir)) defer func() { require.NoError(t, w.Close()) }() - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w, 1, 3)) }) } @@ -1026,7 +1124,7 @@ func TestPruneAfter(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1, 3)) }) t.Run("truncates within a file at the rollback point", func(t *testing.T) { @@ -1048,7 +1146,7 @@ func TestPruneAfter(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1, 3)) // Appending continues cleanly after the rollback point. appendRecord(t, w2, 4) @@ -1093,7 +1191,7 @@ func TestPruneAfter(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w3, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w3, 1, 3)) }) } }) @@ -1154,7 +1252,7 @@ func TestRollbackCrashAfterSwapReconciledOnReopen(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1, 3)) } // TestRollbackCrashDuringSwapWindowRecovers simulates a crash mid-rollback in the earlier window: the @@ -1202,5 +1300,5 @@ func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w4, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w4, 1, 3)) } diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index 1ee0792eb5..54ba7d61fe 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -40,7 +40,7 @@ func TestIteratorRejectsCorruptSealedFile(t *testing.T) { data[len(data)-1] ^= 0xFF require.NoError(t, os.WriteFile(path, data, 0o600)) - it, err := w2.Iterator(1) + it, err := w2.Iterator(1, 5) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -59,17 +59,54 @@ func TestIteratorRejectsCorruptSealedFile(t *testing.T) { require.Contains(t, iterErr.Error(), "corrupt") } -func TestIteratorEmpty(t *testing.T) { +// TestIteratorEmptyWALErrors verifies that an empty WAL has no latest index, so any requested end index is +// beyond it: iterator creation fails with ErrIteratorRange rather than returning an empty iterator, and the +// rejection leaves the WAL usable. +func TestIteratorEmptyWALErrors(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() - it, err := w.Iterator(0) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() + _, err := w.Iterator(0, 0) + require.ErrorIs(t, err, ErrIteratorRange) - ok, err := it.Next() - require.NoError(t, err) - require.False(t, ok) + // The WAL is unharmed by the rejected request: it still accepts appends and iterates. + appendRecord(t, w, 1) + require.NoError(t, w.Flush()) + require.Equal(t, []uint64{1}, collectIndices(t, w, 1, 1)) +} + +// TestIteratorRangeErrors verifies the two invalid-range rejections on a non-empty WAL: an end index below the +// start index, and an end index beyond the latest stored index. Both report ErrIteratorRange and leave the WAL +// usable. +func TestIteratorRangeErrors(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + _, err := w.Iterator(3, 2) + require.ErrorIs(t, err, ErrIteratorRange, "end index below start index must be rejected") + + _, err = w.Iterator(1, 6) + require.ErrorIs(t, err, ErrIteratorRange, "end index beyond the latest stored index must be rejected") + + // Neither rejection bricked the WAL: a valid request still works. + require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectIndices(t, w, 1, 5)) +} + +// TestIteratorStopsAtEndIndex verifies the core new behavior: the iterator yields no record beyond the +// requested end index, even though later records exist in the WAL. +func TestIteratorStopsAtEndIndex(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + for index := uint64(1); index <= 10; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + require.Equal(t, []uint64{3, 4, 5, 6, 7}, collectIndices(t, w, 3, 7)) } func TestIteratorFromMiddle(t *testing.T) { @@ -80,7 +117,7 @@ func TestIteratorFromMiddle(t *testing.T) { } require.NoError(t, w.Flush()) - require.Equal(t, []uint64{3, 4, 5}, collectIndices(t, w, 3)) + require.Equal(t, []uint64{3, 4, 5}, collectIndices(t, w, 3, 5)) } func TestIteratorAcrossFiles(t *testing.T) { @@ -93,7 +130,7 @@ func TestIteratorAcrossFiles(t *testing.T) { } require.NoError(t, w.Flush()) - require.Equal(t, []uint64{2, 3, 4, 5}, collectIndices(t, w, 2)) + require.Equal(t, []uint64{2, 3, 4, 5}, collectIndices(t, w, 2, 5)) } func TestIteratorWithTinyPrefetchBuffer(t *testing.T) { @@ -109,7 +146,7 @@ func TestIteratorWithTinyPrefetchBuffer(t *testing.T) { require.NoError(t, w.Flush()) require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, - collectIndices(t, w, 1)) + collectIndices(t, w, 1, 20)) } func TestIteratorCloseBeforeDrainDoesNotLeak(t *testing.T) { @@ -123,7 +160,7 @@ func TestIteratorCloseBeforeDrainDoesNotLeak(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 20) require.NoError(t, err) // Consume just one record, then close while the reader is still mid-stream (blocked on the full buffer). ok, err := it.Next() @@ -146,7 +183,7 @@ func TestIteratorReaderExitsWhenWALTornDownWhileOrphaned(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 20) require.NoError(t, err) iter := it.(*walIterator) @@ -183,7 +220,7 @@ func TestIteratorYieldsRecordContents(t *testing.T) { require.NoError(t, w.Append(1, []byte("hello world"))) require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 1) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -250,11 +287,19 @@ func TestConcurrentIterationDuringRotation(t *testing.T) { } } -// drainContiguousFrom fully consumes an iterator anchored at start, verifying the yielded indices form a -// gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not have -// produced start yet). Returns the first error encountered. +// drainContiguousFrom fully consumes an iterator over [start, currentLast], verifying the yielded indices +// form a gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not +// have produced start yet). The end is read from the WAL's current bounds so a concurrent writer's latest +// index does not need to be known in advance. Returns the first error encountered. func drainContiguousFrom(w WAL[[]byte], start uint64) error { - it, err := w.Iterator(start) + ok, _, last, err := w.Bounds() + if err != nil { + return fmt.Errorf("bounds: %w", err) + } + if !ok || last < start { + return nil // nothing at or above start yet + } + it, err := w.Iterator(start, last) if err != nil { return fmt.Errorf("create iterator: %w", err) } @@ -291,7 +336,7 @@ func TestIteratorDoesNotSeePostConstructionRecords(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 3) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -328,7 +373,7 @@ func TestIteratorSurfacesFatalCause(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, n) require.NoError(t, err) defer func() { _ = it.Close() }() diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go index c5561fbe54..1874131cad 100644 --- a/sei-db/seiwal/seiwal_serializing.go +++ b/sei-db/seiwal/seiwal_serializing.go @@ -2,6 +2,7 @@ package seiwal import ( "context" + "errors" "fmt" "sync" "time" @@ -45,6 +46,7 @@ type serPrune struct { // serIterator asks the serializer goroutine to create an inner iterator, ordered after every prior append. type serIterator struct { startIndex uint64 + endIndex uint64 reply chan serIteratorResult } @@ -214,11 +216,12 @@ func (s *serializingWAL[T]) PruneBefore(lowestIndexToKeep uint64) error { return nil } -// Iterator returns an iterator over the WAL starting at startIndex. Construction is ordered on the serializer -// goroutine after every prior append, so the iterator observes all previously scheduled appends. -func (s *serializingWAL[T]) Iterator(startIndex uint64) (Iterator[T], error) { +// Iterator returns an iterator over the inclusive index range [startIndex, endIndex]. Construction is ordered +// on the serializer goroutine after every prior append, so the iterator observes all previously scheduled +// appends. +func (s *serializingWAL[T]) Iterator(startIndex uint64, endIndex uint64) (Iterator[T], error) { reply := make(chan serIteratorResult, 1) - if err := s.submit(serIterator{startIndex: startIndex, reply: reply}); err != nil { + if err := s.submit(serIterator{startIndex: startIndex, endIndex: endIndex, reply: reply}); err != nil { return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) } select { @@ -332,9 +335,11 @@ func (s *serializingWAL[T]) serializerLoop() { return } case serIterator: - it, err := s.inner.Iterator(m.startIndex) + it, err := s.inner.Iterator(m.startIndex, m.endIndex) m.reply <- serIteratorResult{it: it, err: err} - if err != nil { + // A rejected range leaves the inner WAL healthy, so mirror that here; only a genuine inner + // failure bricks the serializing layer. + if err != nil && !errors.Is(err, ErrIteratorRange) { s.fail(fmt.Errorf("failed to create iterator: %w", err)) return } diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go index db4f6ed0ef..19e03ed266 100644 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ b/sei-db/seiwal/seiwal_serializing_test.go @@ -38,9 +38,9 @@ type indexedString struct { value string } -func collectStrings(t *testing.T, w WAL[string], start uint64) []indexedString { +func collectStrings(t *testing.T, w WAL[string], start uint64, end uint64) []indexedString { t.Helper() - it, err := w.Iterator(start) + it, err := w.Iterator(start, end) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -126,8 +126,8 @@ func TestGenericWALRoundTrip(t *testing.T) { require.Equal(t, uint64(1), first) require.Equal(t, uint64(5), last) - require.Equal(t, []indexedString{{1, "one"}, {2, "two"}, {5, "five"}}, collectStrings(t, w, 0)) - require.Equal(t, []indexedString{{2, "two"}, {5, "five"}}, collectStrings(t, w, 2)) + require.Equal(t, []indexedString{{1, "one"}, {2, "two"}, {5, "five"}}, collectStrings(t, w, 0, 5)) + require.Equal(t, []indexedString{{2, "two"}, {5, "five"}}, collectStrings(t, w, 2, 5)) } func TestGenericWALReopen(t *testing.T) { @@ -149,7 +149,7 @@ func TestGenericWALReopen(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(3), last) - require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0)) + require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0, 3)) } func TestGenericWALPrune(t *testing.T) { @@ -192,7 +192,7 @@ func TestGenericWALRollback(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), first) require.Equal(t, uint64(3), last) - require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0)) + require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0, 3)) } func TestGenericWALSerializeErrorSurfaces(t *testing.T) { @@ -230,7 +230,7 @@ func TestGenericWALDeserializeErrorSurfaces(t *testing.T) { require.NoError(t, w.Append(2, "poison")) require.NoError(t, w.Flush()) - it, err := w.Iterator(0) + it, err := w.Iterator(0, 2) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -272,7 +272,7 @@ func (f *faultyInner) Append(uint64, []byte) error { return nil } func (f *faultyInner) Flush() error { return nil } func (f *faultyInner) Bounds() (bool, uint64, uint64, error) { return false, 0, 0, f.boundsErr } func (f *faultyInner) PruneBefore(uint64) error { return nil } -func (f *faultyInner) Iterator(uint64) (Iterator[[]byte], error) { +func (f *faultyInner) Iterator(uint64, uint64) (Iterator[[]byte], error) { return nil, f.iterErr } func (f *faultyInner) Close() error { return nil } @@ -308,7 +308,7 @@ func TestGenericWALBricksOnInnerIteratorError(t *testing.T) { s := newSerializingWAL[string](cfg, &faultyInner{iterErr: boom}, stringSerialize, stringDeserialize) defer func() { _ = s.Close() }() - _, err := s.Iterator(0) + _, err := s.Iterator(0, 0) require.Error(t, err) require.ErrorIs(t, err, boom) diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index 8744012f7f..7749a93086 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -69,7 +69,12 @@ type StateWAL interface { // called again or for a higher block number. Prune(lowestBlockNumberToKeep uint64) error - // Create an iterator over the WAL, starting at the given block number. + // Create an iterator over the WAL across the inclusive block range [startingBlockNumber, endingBlockNumber]. + // + // The iterator yields no block below startingBlockNumber or above endingBlockNumber. It is an error for + // endingBlockNumber to be below startingBlockNumber, or for endingBlockNumber to be above the highest block + // number currently stored in the WAL (including when the WAL is empty); both are reported as + // seiwal.ErrIteratorRange and leave the WAL usable. // // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the // start and the return of this call. Data written before that instant is included; data written after it @@ -79,7 +84,7 @@ type StateWAL interface { // changesets), where changesets are all the changes written for that block (across one or more Write // calls) combined in write order. Blocks that were never ended with SignalEndOfBlock are not yielded. // The returned changesets, and every byte slice reachable through them, must be treated as read-only. - Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) + Iterator(startingBlockNumber uint64, endingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) // Close the WAL, flushing complete blocks (those ended with SignalEndOfBlock) to disk and releasing // resources. Changes for a block that was not ended with SignalEndOfBlock are discarded. diff --git a/sei-db/state_db/statewal/state_wal_brick_test.go b/sei-db/state_db/statewal/state_wal_brick_test.go index 2e3e578d7c..a047a2d78f 100644 --- a/sei-db/state_db/statewal/state_wal_brick_test.go +++ b/sei-db/state_db/statewal/state_wal_brick_test.go @@ -52,7 +52,7 @@ func (f *fakeWAL) PruneBefore(lowestIndexToKeep uint64) error { return f.pruneErr } -func (f *fakeWAL) Iterator(startIndex uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { +func (f *fakeWAL) Iterator(startIndex uint64, endIndex uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { if f.iteratorErr != nil { return nil, f.iteratorErr } @@ -79,7 +79,7 @@ func requireBricked(t *testing.T, w StateWAL) { _, _, _, err := w.GetStoredRange() require.ErrorIs(t, err, errInjected) require.ErrorIs(t, w.Prune(1), errInjected) - _, err = w.Iterator(0) + _, err = w.Iterator(0, 0) require.ErrorIs(t, err, errInjected) } @@ -124,7 +124,7 @@ func TestFatalErrorsBrickWAL(t *testing.T) { f := &fakeWAL{} w := newFakeStateWAL(t, f) f.iteratorErr = errInjected - _, err := w.Iterator(0) + _, err := w.Iterator(0, 0) require.ErrorIs(t, err, errInjected) requireBricked(t, w) }) diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 4a8b2d2267..b586f2bfa9 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -1,6 +1,7 @@ package statewal import ( + "errors" "fmt" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -230,17 +231,24 @@ func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { return nil } -// Iterator returns an iterator over the WAL starting at startingBlockNumber. It yields (blockNumber, -// changesets) directly from the underlying generic WAL. -func (w *stateWALImpl) Iterator(startingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { +// Iterator returns an iterator over the inclusive block range [startingBlockNumber, endingBlockNumber]. It +// yields (blockNumber, changesets) directly from the underlying generic WAL. +func (w *stateWALImpl) Iterator( + startingBlockNumber uint64, + endingBlockNumber uint64, +) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { if w.closed { return nil, fmt.Errorf("state WAL is closed") } if w.fatalErr != nil { return nil, fmt.Errorf("state WAL failed: %w", w.fatalErr) } - it, err := w.wal.Iterator(startingBlockNumber) + it, err := w.wal.Iterator(startingBlockNumber, endingBlockNumber) if err != nil { + // A rejected range is the caller's error and leaves the WAL usable; only a genuine WAL failure bricks. + if errors.Is(err, seiwal.ErrIteratorRange) { + return nil, fmt.Errorf("failed to create WAL iterator: %w", err) + } return nil, w.fail(fmt.Errorf("failed to create WAL iterator: %w", err)) } return it, nil diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go index f57fca3ce8..1396ac5e71 100644 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ b/sei-db/state_db/statewal/state_wal_impl_test.go @@ -29,11 +29,11 @@ func writeBlock(t *testing.T, w StateWAL, block uint64) { require.NoError(t, w.SignalEndOfBlock()) } -// collectBlocks iterates from start and returns the block number of each entry, verifying that entries are -// strictly increasing and never below start. -func collectBlocks(t *testing.T, w StateWAL, start uint64) []uint64 { +// collectBlocks iterates the inclusive range [start, end] and returns the block number of each entry, +// verifying that entries are strictly increasing and never below start or above end. +func collectBlocks(t *testing.T, w StateWAL, start uint64, end uint64) []uint64 { t.Helper() - it, err := w.Iterator(start) + it, err := w.Iterator(start, end) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -46,6 +46,7 @@ func collectBlocks(t *testing.T, w StateWAL, start uint64) []uint64 { } blockNumber, _ := it.Entry() require.GreaterOrEqual(t, blockNumber, start) + require.LessOrEqual(t, blockNumber, end) if len(blocks) > 0 { require.Greater(t, blockNumber, blocks[len(blocks)-1]) } @@ -80,7 +81,7 @@ func TestWriteFlushReopenGetRange(t *testing.T) { require.Equal(t, uint64(1), start) require.Equal(t, uint64(5), end) - require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectBlocks(t, w2, 1)) + require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectBlocks(t, w2, 1, 5)) } func TestContractViolations(t *testing.T) { @@ -150,7 +151,7 @@ func TestIncompleteBlockDiscardedOnReopen(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), start) require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1, 3)) // Block 4 may now be re-executed cleanly. writeBlock(t, w2, 4) @@ -185,7 +186,7 @@ func TestEmptyChangesetBlockIsStored(t *testing.T) { require.Equal(t, uint64(1), start) require.Equal(t, uint64(1), end) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 1) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() ok, err = it.Next() @@ -215,7 +216,7 @@ func TestPruneDropsOldBlocks(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(5), start) require.Equal(t, uint64(10), end) - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 0)) + require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 0, 10)) } // TestGetRange is a wrapper-level smoke test that the standalone GetRange reports the stored block range of a @@ -273,7 +274,7 @@ func TestPruneAfter(t *testing.T) { require.True(t, ok) require.Equal(t, uint64(1), start) require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1, 3)) // Writing continues cleanly after the rollback point. writeBlock(t, w2, 4) diff --git a/sei-db/state_db/statewal/state_wal_iterator_test.go b/sei-db/state_db/statewal/state_wal_iterator_test.go index 47bd423d1f..fdf3a8d9f0 100644 --- a/sei-db/state_db/statewal/state_wal_iterator_test.go +++ b/sei-db/state_db/statewal/state_wal_iterator_test.go @@ -4,20 +4,39 @@ import ( "testing" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/seiwal" "github.com/stretchr/testify/require" ) -func TestIteratorEmpty(t *testing.T) { +// TestIteratorEmptyWALErrors verifies that an empty WAL has no latest block, so any requested end block is +// beyond it: iterator creation fails with ErrIteratorRange rather than bricking the WAL. +func TestIteratorEmptyWALErrors(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) defer func() { require.NoError(t, w.Close()) }() - it, err := w.Iterator(0) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() + _, err := w.Iterator(0, 0) + require.ErrorIs(t, err, seiwal.ErrIteratorRange) - ok, err := it.Next() - require.NoError(t, err) - require.False(t, ok) + // The rejection must not brick the WAL: it still accepts a block and iterates. + writeBlock(t, w, 1) + require.NoError(t, w.Flush()) + require.Equal(t, []uint64{1}, collectBlocks(t, w, 1, 1)) +} + +// TestIteratorRangeErrorDoesNotBrick verifies that an out-of-range request on a non-empty WAL is reported as +// ErrIteratorRange and leaves the WAL usable, rather than bricking the wrapper. +func TestIteratorRangeErrorDoesNotBrick(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + for block := uint64(1); block <= 3; block++ { + writeBlock(t, w, block) + } + require.NoError(t, w.Flush()) + + _, err := w.Iterator(1, 4) // end block beyond the latest stored block + require.ErrorIs(t, err, seiwal.ErrIteratorRange) + + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1, 3), "the WAL remains usable after a rejected range") } func TestIteratorFromMiddle(t *testing.T) { @@ -28,7 +47,7 @@ func TestIteratorFromMiddle(t *testing.T) { } require.NoError(t, w.Flush()) - require.Equal(t, []uint64{3, 4, 5}, collectBlocks(t, w, 3)) + require.Equal(t, []uint64{3, 4, 5}, collectBlocks(t, w, 3, 5)) } func TestIteratorYieldsChangesetContents(t *testing.T) { @@ -40,7 +59,7 @@ func TestIteratorYieldsChangesetContents(t *testing.T) { require.NoError(t, w.SignalEndOfBlock()) require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 1) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -73,7 +92,7 @@ func TestIteratorCombinesMultipleWritesInOrder(t *testing.T) { require.NoError(t, w.SignalEndOfBlock()) require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 1) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() @@ -104,7 +123,7 @@ func TestIteratorStopsBeforeIncompleteBlock(t *testing.T) { require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{4}, []byte{4})})) require.NoError(t, w.Flush()) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1)) + require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1, 3)) } // TestIteratorDoesNotSeePostConstructionBlocks confirms the snapshot contract at the wrapper level: an @@ -118,7 +137,7 @@ func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { } require.NoError(t, w.Flush()) - it, err := w.Iterator(1) + it, err := w.Iterator(1, 3) require.NoError(t, err) defer func() { require.NoError(t, it.Close()) }() From e62672f24cb89546f6d4177e242949d2341441cb Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 15 Jul 2026 12:11:17 -0500 Subject: [PATCH 37/44] added file locks --- sei-db/seiwal/seiwal_impl.go | 28 +++++++++ sei-db/seiwal/seiwal_lock.go | 48 ++++++++++++++++ sei-db/seiwal/seiwal_lock_test.go | 96 +++++++++++++++++++++++++++++++ sei-db/seiwal/seiwal_offline.go | 47 ++++++++++++--- 4 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 sei-db/seiwal/seiwal_lock.go create mode 100644 sei-db/seiwal/seiwal_lock_test.go diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 0bc3a2139c..3706b84a32 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/zbiljic/go-filelock" "go.opentelemetry.io/otel/metric" "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" @@ -107,6 +108,11 @@ type walImpl struct { // Closed by Close() to stop the queue-depth sampler goroutine. samplerStop chan struct{} + // The exclusive advisory lock on the WAL directory, held for this instance's entire lifetime and + // released by Close(). It prevents a second WAL instance or an offline utility from mutating the same + // directory concurrently. + fileLock filelock.TryLockerSafe + // Guarantees the Close() shutdown sequence runs at most once. closeOnce sync.Once @@ -176,6 +182,22 @@ func newWAL(config *Config) (WAL[[]byte], error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid WAL config: %w", err) } + + // Take the directory lock before touching any files: recoverDirectory (below) mutates the directory, so + // we must own it exclusively first. The lock is released by Close(), or here on any open failure. + if err := util.EnsureDirectoryExists(config.Path, true); err != nil { + return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) + } + fileLock, err := acquireDirLock(config.Path) + if err != nil { + return nil, fmt.Errorf("failed to lock WAL directory %s: %w", config.Path, err) + } + defer func() { + if fileLock != nil { + releaseDirLock(fileLock, config.Path) + } + }() + if err := recoverDirectory(config.Path); err != nil { return nil, err } @@ -196,6 +218,7 @@ func newWAL(config *Config) (WAL[[]byte], error) { w := &walImpl{ config: config, + fileLock: fileLock, metricAttrs: walNameAttr(config.Name), writerChan: make(chan any, config.WriteBufferSize), ctx: ctx, @@ -223,6 +246,8 @@ func newWAL(config *Config) (WAL[[]byte], error) { go w.sampleQueueDepth(config.MetricsSampleInterval) } + // Ownership of the lock has passed to w (released by Close); disarm the open-failure cleanup above. + fileLock = nil return w, nil } @@ -354,6 +379,9 @@ func (w *walImpl) Close() error { } w.wg.Wait() w.cancel(nil) // a clean close carries no fatal cause; a prior fail() already recorded one + // Release the directory lock only after every goroutine has stopped, so nothing touches the files + // after another owner could acquire the directory. + releaseDirLock(w.fileLock, w.config.Path) }) if err := w.asyncError(); err != nil { return fmt.Errorf("WAL closed with error: %w", err) diff --git a/sei-db/seiwal/seiwal_lock.go b/sei-db/seiwal/seiwal_lock.go new file mode 100644 index 0000000000..af517dcb62 --- /dev/null +++ b/sei-db/seiwal/seiwal_lock.go @@ -0,0 +1,48 @@ +package seiwal + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/zbiljic/go-filelock" + + commonerrors "github.com/sei-protocol/sei-chain/sei-db/common/errors" +) + +// The name of the WAL directory lock file. Holding this lock grants exclusive access to the WAL directory, +// so a second WAL instance or an offline utility cannot mutate the directory while another owner is live. +const lockFileName = "wal.lock" + +// acquireDirLock takes an exclusive advisory lock on dir/wal.lock. It fails with +// commonerrors.ErrFileLockUnavailable if another WAL instance or offline utility already holds the lock. The +// caller owns the returned handle and must Unlock it to release the directory. +func acquireDirLock(dir string) (filelock.TryLockerSafe, error) { + lockPath, err := filepath.Abs(filepath.Join(dir, lockFileName)) + if err != nil { + return nil, fmt.Errorf("resolve lock path: %w", err) + } + fl, err := filelock.New(lockPath) + if err != nil { + return nil, fmt.Errorf("create file lock %s: %w", lockPath, err) + } + locked, err := fl.TryLock() + if err != nil { + if errors.Is(err, filelock.ErrLocked) { + return nil, fmt.Errorf("%w: %s", commonerrors.ErrFileLockUnavailable, lockPath) + } + return nil, fmt.Errorf("acquire file lock %s: %w", lockPath, err) + } + if !locked { + return nil, fmt.Errorf("%w: held by another owner (%s)", commonerrors.ErrFileLockUnavailable, lockPath) + } + return fl, nil +} + +// releaseDirLock releases a lock acquired by acquireDirLock, logging any failure. A release failure is not +// fatal: the lock is advisory and the kernel drops it when the process exits. +func releaseDirLock(lock filelock.TryLockerSafe, dir string) { + if err := lock.Unlock(); err != nil { + logger.Error("failed to release WAL directory lock", "path", dir, "error", err) + } +} diff --git a/sei-db/seiwal/seiwal_lock_test.go b/sei-db/seiwal/seiwal_lock_test.go new file mode 100644 index 0000000000..005d0a3b7f --- /dev/null +++ b/sei-db/seiwal/seiwal_lock_test.go @@ -0,0 +1,96 @@ +package seiwal + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + commonerrors "github.com/sei-protocol/sei-chain/sei-db/common/errors" +) + +// TestFileLockPreventsSecondWAL verifies that a second WAL cannot open a directory a live WAL already owns. +func TestFileLockPreventsSecondWAL(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + defer func() { require.NoError(t, w.Close()) }() + + _, err := NewWAL(testConfig(dir)) + require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) +} + +// TestFileLockPreventsOfflineWhileLive verifies that the offline utilities fail fast while a WAL is live on +// the same directory, rather than mutating files the running WAL owns. +func TestFileLockPreventsOfflineWhileLive(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)) + defer func() { require.NoError(t, w.Close()) }() + + _, _, _, err := GetRange(dir) + require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) + + err = PruneAfter(dir, 0) + require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) + + err = VerifyIntegrity(dir) + require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) +} + +// TestFileLockReleasedOnClose verifies that Close releases the lock so a later WAL and the offline utilities +// can acquire the same directory. +func TestFileLockReleasedOnClose(t *testing.T) { + dir := t.TempDir() + + w := openWAL(t, testConfig(dir)) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + require.NoError(t, w.Close()) + + // Every operation that takes the lock now succeeds because the lock was released by Close. + ok, first, last, err := GetRange(dir) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(5), last) + + require.NoError(t, VerifyIntegrity(dir)) + require.NoError(t, PruneAfter(dir, 3)) + + w2 := openWAL(t, testConfig(dir)) + require.NoError(t, w2.Close()) +} + +// TestFileLockSequentialOpenClose verifies that repeated open/close cycles succeed: the lock leaves no stale +// state that blocks a subsequent open. +func TestFileLockSequentialOpenClose(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 3; i++ { + w := openWAL(t, testConfig(dir)) + require.NoError(t, w.Close()) + } +} + +// TestFileLockFileIgnoredByScans verifies that the lock file does not interfere with directory recovery: a +// WAL that appended, closed, and reopened still reports the correct bounds despite wal.lock in the directory. +func TestFileLockFileIgnoredByScans(t *testing.T) { + dir := t.TempDir() + + w := openWAL(t, testConfig(dir)) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + require.NoError(t, w.Close()) + + require.FileExists(t, filepath.Join(dir, lockFileName)) + + w2 := openWAL(t, testConfig(dir)) + defer func() { require.NoError(t, w2.Close()) }() + ok, first, last, err := w2.Bounds() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(1), first) + require.Equal(t, uint64(5), last) +} diff --git a/sei-db/seiwal/seiwal_offline.go b/sei-db/seiwal/seiwal_offline.go index f6a5360c5f..cee34e4cf2 100644 --- a/sei-db/seiwal/seiwal_offline.go +++ b/sei-db/seiwal/seiwal_offline.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "sort" + + "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" ) // GetRange reports the range of record indices stored in the WAL directory at path, without constructing a @@ -13,10 +15,19 @@ import ( // which SEALS any unsealed file left behind by a prior session — so this function mutates the directory on // disk even though its name suggests a read. // -// NOT SAFE FOR CONCURRENT USE with a live WAL: it seals files that a running WAL instance owns, so it must -// only be called while no WAL is open on the same directory (e.g. offline, at startup before NewWAL). -// Callers must serialize it against any other GetRange/PruneAfter on the same directory. +// It takes the same exclusive directory lock a live WAL holds, so it fails with +// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory, and serializes against any +// other GetRange/PruneAfter/VerifyIntegrity on that directory. The lock is released before it returns. func GetRange(path string) (ok bool, first uint64, last uint64, err error) { + if err := util.EnsureDirectoryExists(path, true); err != nil { + return false, 0, 0, fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) + } + lock, err := acquireDirLock(path) + if err != nil { + return false, 0, 0, fmt.Errorf("failed to lock WAL directory %s: %w", path, err) + } + defer releaseDirLock(lock, path) + if err := recoverDirectory(path); err != nil { return false, 0, 0, fmt.Errorf("failed to recover WAL directory: %w", err) } @@ -36,11 +47,19 @@ func GetRange(path string) (ok bool, first uint64, last uint64, err error) { // path — the offline rollback operation — without constructing a live WAL instance. It runs the standard // recovery/sanity pass first (sealing any orphaned file), applies the rollback, then re-scans the result. // -// NOT SAFE FOR CONCURRENT USE with a live WAL: it seals, rewrites, and removes files that a running WAL -// instance owns, so it must only be called while no WAL is open on the same directory (e.g. offline, at -// startup before NewWAL). Callers must serialize it against any other GetRange/PruneAfter on the same -// directory. +// It takes the same exclusive directory lock a live WAL holds, so it fails with +// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory, and serializes against any +// other GetRange/PruneAfter/VerifyIntegrity on that directory. The lock is released before it returns. func PruneAfter(path string, highestIndexToKeep uint64) error { + if err := util.EnsureDirectoryExists(path, true); err != nil { + return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) + } + lock, err := acquireDirLock(path) + if err != nil { + return fmt.Errorf("failed to lock WAL directory %s: %w", path, err) + } + defer releaseDirLock(lock, path) + if err := recoverDirectory(path); err != nil { return fmt.Errorf("failed to recover WAL directory: %w", err) } @@ -57,9 +76,19 @@ func PruneAfter(path string, highestIndexToKeep uint64) error { // intact and that each file's content exactly covers the index range its name promises. This is the expensive // O(total sealed bytes) check that open (and GetRange/PruneAfter) deliberately skips. // -// NOT SAFE FOR CONCURRENT USE with a live WAL on the same directory: it reads files a running WAL owns while -// they may still be changing. +// It takes the same exclusive directory lock a live WAL holds, so it fails with +// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory, and serializes against any +// other GetRange/PruneAfter/VerifyIntegrity on that directory. The lock is released before it returns. func VerifyIntegrity(path string) error { + if err := util.EnsureDirectoryExists(path, true); err != nil { + return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) + } + lock, err := acquireDirLock(path) + if err != nil { + return fmt.Errorf("failed to lock WAL directory %s: %w", path, err) + } + defer releaseDirLock(lock, path) + entries, err := os.ReadDir(path) if err != nil { return fmt.Errorf("failed to read WAL directory %s: %w", path, err) From 12ccd558de779d91116f7162c1602b5cb5594dc8 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 15 Jul 2026 12:47:40 -0500 Subject: [PATCH 38/44] made suggested changes --- sei-db/seiwal/metrics.go | 3 +- sei-db/seiwal/seiwal.go | 3 ++ sei-db/seiwal/seiwal_file.go | 6 ++-- sei-db/seiwal/seiwal_impl.go | 45 ++++++++++++++++++++------- sei-db/seiwal/seiwal_impl_test.go | 18 +++++++---- sei-db/seiwal/seiwal_iterator.go | 22 ++++++++----- sei-db/seiwal/seiwal_iterator_test.go | 37 +++++++++++++++++++++- sei-db/seiwal/seiwal_offline.go | 3 +- 8 files changed, 107 insertions(+), 30 deletions(-) diff --git a/sei-db/seiwal/metrics.go b/sei-db/seiwal/metrics.go index b845fb599d..30ebef81b3 100644 --- a/sei-db/seiwal/metrics.go +++ b/sei-db/seiwal/metrics.go @@ -84,7 +84,8 @@ func walNameAttr(name string) metric.MeasurementOption { // queueDepthAttrs tags a queue-depth observation with the WAL instance name and which internal channel // ("writer" or "serializer") is being measured. func queueDepthAttrs(name string, queue string) metric.MeasurementOption { - return metric.WithAttributeSet(attribute.NewSet(attribute.String("wal", name), attribute.String("queue", queue))) + return metric.WithAttributeSet(attribute.NewSet( + attribute.String("wal", name), attribute.String("queue", queue))) } func must[V any](v V, err error) V { diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go index b5d9063e7e..e0ce5f161a 100644 --- a/sei-db/seiwal/seiwal.go +++ b/sei-db/seiwal/seiwal.go @@ -72,6 +72,9 @@ type WAL[T any] interface { // endIndex to be below startIndex, or for endIndex to be above the highest index currently stored in the // WAL (including when the WAL is empty); both are reported as ErrIteratorRange and leave the WAL usable. // + // Construction may also fail with an I/O error (for example, the point-in-time snapshot the iterator reads + // from could not be created). Such a failure is returned to the caller and leaves the WAL usable. + // // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the // start and the return of this call. Records appended before that instant are included; records // appended after it are not. For records appended concurrently with this call, whether they are diff --git a/sei-db/seiwal/seiwal_file.go b/sei-db/seiwal/seiwal_file.go index 170db8b02d..6a12aba9b4 100644 --- a/sei-db/seiwal/seiwal_file.go +++ b/sei-db/seiwal/seiwal_file.go @@ -368,7 +368,8 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile break // torn or incomplete length prefix } payloadStart := lenStart + lenN - remaining := uint64(len(data) - payloadStart) //nolint:gosec // payloadStart <= len(data), so non-negative + // payloadStart <= len(data), so the difference is non-negative. + remaining := uint64(len(data) - payloadStart) //nolint:gosec if remaining < 4 || length > remaining-4 { if err := torn(offset, "truncated record payload or checksum"); err != nil { return nil, err @@ -409,7 +410,8 @@ func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFile func verifySealedContents(contents *walFileContents, fileSeq uint64, first uint64, last uint64) error { if !contents.hasRecords { return fmt.Errorf( - "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but no intact records were read", + "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] "+ + "but no intact records were read", fileSeq, first, last) } if contents.firstIndex != first || contents.lastIndex != last { diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go index 3706b84a32..2e6621d601 100644 --- a/sei-db/seiwal/seiwal_impl.go +++ b/sei-db/seiwal/seiwal_impl.go @@ -2,7 +2,6 @@ package seiwal import ( "context" - "errors" "fmt" "os" "path/filepath" @@ -60,6 +59,11 @@ type iteratorStartRequest struct { type iteratorStartResponse struct { iterator *walIterator err error + + // fatal reports whether err left the WAL in a state that requires bricking the pipeline. A non-fatal err (a + // caller range error, or a non-corrupting filesystem failure while building the snapshot) leaves the WAL + // usable, so the writer surfaces it to the caller and keeps running. + fatal bool } // The index range reported by Bounds. @@ -347,7 +351,8 @@ func (w *walImpl) PruneBefore(lowestIndexToKeep uint64) error { // The snapshot is removed by the iterator's Close. func (w *walImpl) Iterator(startIndex uint64, endIndex uint64) (Iterator[[]byte], error) { reply := make(chan iteratorStartResponse, 1) - if err := w.sendToWriter(iteratorStartRequest{startIndex: startIndex, endIndex: endIndex, reply: reply}); err != nil { + req := iteratorStartRequest{startIndex: startIndex, endIndex: endIndex, reply: reply} + if err := w.sendToWriter(req); err != nil { return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) } select { @@ -451,9 +456,9 @@ func (w *walImpl) writerLoop() { case iteratorStartRequest: resp := w.startIterator(m.startIndex, m.endIndex) m.reply <- resp - // A rejected range is the caller's error and leaves the WAL healthy; only a genuine I/O failure - // (a failed flush or hard-link) bricks the pipeline. - if resp.err != nil && !errors.Is(resp.err, ErrIteratorRange) { + // A rejected range and a non-corrupting snapshot I/O failure both leave the WAL healthy; only a + // failed flush of buffered records desyncs in-memory state from disk and bricks the pipeline. + if resp.fatal { w.fail(resp.err) return } @@ -477,12 +482,14 @@ func (w *walImpl) appendRecord(m dataToBeWritten) error { if w.hasWritten { if w.config.PermitGaps { if m.index <= w.lastWrittenIndex { - return fmt.Errorf("append out of order: index %d is not greater than last written index %d", + return fmt.Errorf( + "append out of order: index %d is not greater than last written index %d", m.index, w.lastWrittenIndex) } } else if m.index != w.lastWrittenIndex+1 { return fmt.Errorf( - "append out of order: index %d is not contiguous with last written index %d (expected %d)", + "append out of order: index %d is not contiguous with last written index "+ + "%d (expected %d)", m.index, w.lastWrittenIndex, w.lastWrittenIndex+1) } } @@ -561,10 +568,17 @@ func (w *walImpl) pruneSealedFiles(pruneThrough uint64) error { // own hard links, which keep the underlying inodes alive until Close removes them. The mutable file is flushed // (not fsynced) so its records are readable through the reader's separate handle — no crash can intervene // between creation and use, so durability is irrelevant here. +// +// Failures split by whether they leave the WAL usable. A range rejection (ErrIteratorRange) is a caller error, +// and a snapshot failure (MkdirAll/os.Link) touches only the ephemeral iterator// lease directory, so +// both are returned non-fatally and the WAL keeps running. Only a failed mutable-file flush is fatal: it +// poisons the file's buffered writer and leaves in-memory bookkeeping ahead of what is durable, so the caller +// marks the response fatal to brick the pipeline. func (w *walImpl) startIterator(startIndex uint64, endIndex uint64) iteratorStartResponse { if endIndex < startIndex { return iteratorStartResponse{ - err: fmt.Errorf("%w: end index %d is below start index %d", ErrIteratorRange, endIndex, startIndex), + err: fmt.Errorf( + "%w: end index %d is below start index %d", ErrIteratorRange, endIndex, startIndex), } } r := w.bounds() @@ -575,7 +589,9 @@ func (w *walImpl) startIterator(startIndex uint64, endIndex uint64) iteratorStar } if endIndex > r.last { return iteratorStartResponse{ - err: fmt.Errorf("%w: end index %d is beyond the latest stored index %d", ErrIteratorRange, endIndex, r.last), + err: fmt.Errorf( + "%w: end index %d is beyond the latest stored index %d", + ErrIteratorRange, endIndex, r.last), } } maxIndex := endIndex @@ -598,7 +614,10 @@ func (w *walImpl) startIterator(startIndex uint64, endIndex uint64) iteratorStar // by sealed files, the mutable file is left untouched. if w.mutableFile.hasRecords && w.mutableFile.lastIndex >= startIndex && w.mutableFile.firstIndex <= endIndex { if err := w.mutableFile.flush(false); err != nil { - return iteratorStartResponse{err: fmt.Errorf("failed to flush mutable file for iterator: %w", err)} + return iteratorStartResponse{ + err: fmt.Errorf("failed to flush mutable file for iterator: %w", err), + fatal: true, + } } sources = append(sources, iteratorFile{ fileSeq: w.mutableFile.fileSeq, name: unsealedFileName(w.mutableFile.fileSeq), @@ -729,11 +748,13 @@ func rollbackDirectory(directory string, rollbackThrough uint64) error { for _, parsed := range sealed { if parsed.lastIndex <= rollbackThrough { - // This file lies entirely at or below the rollback point; so does every lower-sequence file. Done. + // This file lies entirely at or below the rollback point; so does every lower-sequence + // file. Done. break } if parsed.firstIndex > rollbackThrough { - // Entirely beyond the rollback point: remove the whole file, durably, before the next-lower one. + // Entirely beyond the rollback point: remove the whole file, durably, before the + // next-lower one. if err := removeAndSyncDir(directory, names[parsed.fileSeq]); err != nil { return fmt.Errorf("failed to roll back %s: %w", names[parsed.fileSeq], err) } diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index 4e0d275774..d210a396bd 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -705,7 +705,8 @@ func TestIteratorDoesNotFlushMutableFileOutsideRange(t *testing.T) { require.NoError(t, err) require.True(t, ok) require.Equal(t, uint64(5), last) - require.Equal(t, 1, countSealedFiles(t, dir), "records 1..3 sealed into one file; 4,5 buffered in the mutable file") + require.Equal(t, 1, countSealedFiles(t, dir), + "records 1..3 sealed into one file; 4,5 buffered in the mutable file") mutablePath := unsealedFilePath(t, dir) before := onDiskSize(t, mutablePath) @@ -713,7 +714,8 @@ func TestIteratorDoesNotFlushMutableFileOutsideRange(t *testing.T) { // Range [1,3] is fully covered by the sealed file; the mutable file (first index 4) must not be flushed. it, err := w.Iterator(1, 3) require.NoError(t, err) - require.Equal(t, before, onDiskSize(t, mutablePath), "the mutable file must not be flushed when outside the range") + require.Equal(t, before, onDiskSize(t, mutablePath), + "the mutable file must not be flushed when outside the range") require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) require.NoError(t, it.Close()) @@ -847,7 +849,8 @@ func TestScanRejectsGapInSealedFiles(t *testing.T) { require.GreaterOrEqual(t, len(sealed), 3) sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq < sealed[j].fileSeq }) victim := sealed[len(sealed)/2] - require.NoError(t, os.Remove(filepath.Join(dir, sealedFileName(victim.fileSeq, victim.firstIndex, victim.lastIndex)))) + victimName := sealedFileName(victim.fileSeq, victim.firstIndex, victim.lastIndex) + require.NoError(t, os.Remove(filepath.Join(dir, victimName))) _, err = NewWAL(cfg) require.Error(t, err) @@ -1166,8 +1169,10 @@ func TestPruneAfter(t *testing.T) { name string targetSize uint }{ - {"whole-file removal", 1}, // one record per file: rollback removes whole trailing files - {"in-file truncation", 64 * 1024 * 1024}, // all records in one file: rollback truncates it in place + // one record per file: rollback removes whole trailing files + {"whole-file removal", 1}, + // all records in one file: rollback truncates it in place + {"in-file truncation", 64 * 1024 * 1024}, } { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -1182,7 +1187,8 @@ func TestPruneAfter(t *testing.T) { require.NoError(t, PruneAfter(cfg.Path, 3)) - // Reopen normally; the rollback must have durably and consistently reduced the range to [1,3]. + // Reopen normally; the rollback must have durably and consistently reduced the range + // to [1,3]. w3 := openWAL(t, cfg) defer func() { require.NoError(t, w3.Close()) }() diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index dc4e002462..7bd121443e 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -165,8 +165,9 @@ func (it *walIterator) Close() error { close(it.stop) // tell the reader to stop if it is mid-read <-it.readerExited // wait for it to actually exit before releasing its file handles if it.dir != "" { - // Remove this iterator's hard-link snapshot, freeing any inode it was the last link to. Best-effort: - // a leftover is reclaimed by the startup blast. The reader has exited, so no handle is open here. + // Remove this iterator's hard-link snapshot, freeing any inode it was the last link to. + // Best-effort: a leftover is reclaimed by the startup blast. The reader has exited, so no + // handle is open here. _ = os.RemoveAll(it.dir) } }) @@ -251,10 +252,16 @@ func (it *walIterator) loadNextFile() (bool, error) { return false, err } - parsed := parsedFileName{fileSeq: f.fileSeq, firstIndex: f.firstIndex, lastIndex: f.lastIndex, sealed: f.sealed} + parsed := parsedFileName{ + fileSeq: f.fileSeq, + firstIndex: f.firstIndex, + lastIndex: f.lastIndex, + sealed: f.sealed, + } contents, err := readWalFileFromHandle(handle, parsed) if err != nil { - return false, fmt.Errorf("failed to read WAL file (sequence %d) during iteration: %w", f.fileSeq, err) + return false, fmt.Errorf( + "failed to read WAL file (sequence %d) during iteration: %w", f.fileSeq, err) } if f.sealed { @@ -265,9 +272,10 @@ func (it *walIterator) loadNextFile() (bool, error) { for _, record := range contents.records { if record.index < it.start { - // Locating the start index is a linear scan over this file's records (and the whole file was just - // read into memory above). It's wasteful when start lands deep in a large file. If this becomes a - // hotspot, build a small per-file index (offset by index, like LittDB key files) and seek instead. + // Locating the start index is a linear scan over this file's records (and the whole + // file was just read into memory above). It's wasteful when start lands deep in a + // large file. If this becomes a hotspot, build a small per-file index (offset by + // index, like LittDB key files) and seek instead. continue } if record.index > it.maxIndex { diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index 54ba7d61fe..7e170ede71 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -96,6 +96,40 @@ func TestIteratorRangeErrors(t *testing.T) { require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectIndices(t, w, 1, 5)) } +// TestIteratorSnapshotIOFailureDoesNotBrick verifies that a filesystem failure while building the iterator's +// hard-link snapshot is surfaced to the caller without bricking the WAL. Such a failure touches only the +// ephemeral iterator// lease directory, never the WAL data files or the in-memory write state, so the +// WAL must keep accepting appends and, once the fault clears, serving iterators. +func TestIteratorSnapshotIOFailureDoesNotBrick(t *testing.T) { + dir := t.TempDir() + w := openWAL(t, testConfig(dir)).(*walImpl) + defer func() { require.NoError(t, w.Close()) }() + + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) + + // Occupy the iterator root with a regular file so MkdirAll of iterator// fails with ENOTDIR. The + // mutable-file flush inside startIterator still succeeds, so this exercises only the non-corrupting snapshot + // failure, not the fatal flush path. + require.NoError(t, os.WriteFile(iteratorRoot(dir), []byte("x"), 0o600)) + + _, err := w.Iterator(1, 5) + require.Error(t, err) + require.NotErrorIs(t, err, ErrIteratorRange, "a snapshot I/O failure is not a caller range error") + + // The failed request left the WAL healthy: no fatal cause recorded, and appends still succeed. + require.NoError(t, w.asyncError()) + appendRecord(t, w, 6) + require.NoError(t, w.Flush()) + + // Once the fault clears, iteration works again and sees every record, including the one appended after the + // failed request. + require.NoError(t, os.Remove(iteratorRoot(dir))) + require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectIndices(t, w, 1, 6)) +} + // TestIteratorStopsAtEndIndex verifies the core new behavior: the iterator yields no record beyond the // requested end index, even though later records exist in the WAL. func TestIteratorStopsAtEndIndex(t *testing.T) { @@ -316,7 +350,8 @@ func drainContiguousFrom(w WAL[[]byte], start uint64) error { index, _ := it.Entry() if index != prev+1 { _ = it.Close() - return fmt.Errorf("non-contiguous iteration: got index %d after %d (start %d)", index, prev, start) + return fmt.Errorf( + "non-contiguous iteration: got index %d after %d (start %d)", index, prev, start) } prev = index } diff --git a/sei-db/seiwal/seiwal_offline.go b/sei-db/seiwal/seiwal_offline.go index cee34e4cf2..ea78da2d30 100644 --- a/sei-db/seiwal/seiwal_offline.go +++ b/sei-db/seiwal/seiwal_offline.go @@ -122,7 +122,8 @@ func VerifyIntegrity(path string) error { problems = append(problems, fmt.Errorf("failed to read sealed WAL file %s: %w", name, err)) continue } - if err := verifySealedContents(contents, parsed.fileSeq, parsed.firstIndex, parsed.lastIndex); err != nil { + err = verifySealedContents(contents, parsed.fileSeq, parsed.firstIndex, parsed.lastIndex) + if err != nil { problems = append(problems, err) } } From 06f64a524e1c2246bb69dac41c67f931adc9cde5 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 15 Jul 2026 12:56:29 -0500 Subject: [PATCH 39/44] made suggested changes --- sei-db/state_db/statewal/state_wal.go | 7 +++-- sei-db/state_db/statewal/state_wal_impl.go | 14 +++++++--- .../state_db/statewal/state_wal_impl_test.go | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index 7749a93086..cb7fa7dc74 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -24,6 +24,9 @@ type StateWAL interface { // cs, and every byte slice reachable through it (changeset keys and values), must not be modified after // this call. Callers that need to modify those buffers must copy them first. // + // A nil entry in cs is rejected synchronously with an error and leaves the WAL usable; cs itself may be + // nil or empty. + // // The StateWAL rejects writes for blocks if provided out of order. To avoid errors, observe // the following rules: // @@ -40,8 +43,8 @@ type StateWAL interface { // Signal that there will be no more writes for the current block number. Attempting to write additional // changes for the same block number after calling this method may result in an error. // - // Similar to Write(), this method is asynchronous. Calling this method does not, by itself, make data immediately - // crash durable. + // Similar to Write(), this method is asynchronous. Calling this method does not, by itself, make + // data immediately crash durable. SignalEndOfBlock() error // Flush the WAL to disk. Only completed blocks — those for which SignalEndOfBlock has been called — are diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index b586f2bfa9..5dbc9e7de1 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -125,6 +125,11 @@ func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) err if w.fatalErr != nil { return fmt.Errorf("state WAL failed: %w", w.fatalErr) } + for i, ncs := range cs { + if ncs == nil { + return fmt.Errorf("write rejected: changeset at index %d is nil", i) + } + } if err := w.enforceWriteOrdering(blockNumber); err != nil { return fmt.Errorf("write rejected: %w", err) } @@ -165,18 +170,21 @@ func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { return nil } if blockNumber < w.currentBlock { - return fmt.Errorf("block number %d is less than the current block number %d", blockNumber, w.currentBlock) + return fmt.Errorf( + "block number %d is less than the current block number %d", blockNumber, w.currentBlock) } if blockNumber == w.currentBlock { if w.currentBlockEnded { - return fmt.Errorf("block number %d has already ended; cannot write more changes to it", blockNumber) + return fmt.Errorf( + "block number %d has already ended; cannot write more changes to it", blockNumber) } return nil } // blockNumber > currentBlock if !w.currentBlockEnded { return fmt.Errorf( - "cannot write block %d before calling SignalEndOfBlock for block %d", blockNumber, w.currentBlock) + "cannot write block %d before calling SignalEndOfBlock for block %d", + blockNumber, w.currentBlock) } if blockNumber != w.currentBlock+1 { return fmt.Errorf("block number %d is not contiguous with the current block number %d (expected %d)", diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go index 1396ac5e71..749b80fa8d 100644 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ b/sei-db/state_db/statewal/state_wal_impl_test.go @@ -84,6 +84,33 @@ func TestWriteFlushReopenGetRange(t *testing.T) { require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectBlocks(t, w2, 1, 5)) } +// TestWriteRejectsNilChangeset verifies that a nil changeset entry is rejected synchronously at the Write call +// site rather than surfacing later from the serialization goroutine, and that the rejection neither bricks the +// WAL nor advances the block-ordering state. +func TestWriteRejectsNilChangeset(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + valid := makeChangeSet("evm", []byte("k"), []byte("v")) + + // A nil entry is rejected synchronously at the call site, before SignalEndOfBlock/Flush is ever reached. + err := w.Write(5, []*proto.NamedChangeSet{valid, nil}) + require.Error(t, err) + require.Contains(t, err.Error(), "nil") + + // The rejected write is a no-op: it neither bricked the WAL nor advanced the block-ordering state. Were the + // state advanced to block 5, this write to the lower block 3 would be rejected as decreasing. + require.NoError(t, w.Write(3, []*proto.NamedChangeSet{valid})) + require.NoError(t, w.SignalEndOfBlock()) + require.NoError(t, w.Flush()) + + ok, start, end, err := w.GetStoredRange() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint64(3), start) + require.Equal(t, uint64(3), end) +} + func TestContractViolations(t *testing.T) { t.Run("block numbers may not decrease", func(t *testing.T) { w := openWAL(t, testConfig(t.TempDir())) From 011ae8bd047f1faec47322de15ebac24ca15fc35 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 15 Jul 2026 14:11:22 -0500 Subject: [PATCH 40/44] bugfix --- sei-db/seiwal/seiwal_impl_test.go | 34 +++++++++++++++++++++++++++++++ sei-db/seiwal/seiwal_offline.go | 33 +++++++++++++++++------------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go index d210a396bd..4bcd13787a 100644 --- a/sei-db/seiwal/seiwal_impl_test.go +++ b/sei-db/seiwal/seiwal_impl_test.go @@ -961,6 +961,40 @@ func TestVerifyIntegrityDetectsSequenceGap(t *testing.T) { require.Contains(t, err.Error(), "gap") } +// TestVerifyIntegrityReportsDuplicateSequence verifies that when an interrupted rollback swap leaves two sealed +// files sharing a fileSeq (the reduced [1,3] file beside the untouched [1,6] original), VerifyIntegrity +// checksums each physical file under its own name and reports the duplicate as such. It must not collapse both +// parsed entries onto one file — which would skip the survivor's CRC entirely, raise a misattributed range +// mismatch, and report the duplicate as a nonsensical "gap between N and N". +func TestVerifyIntegrityReportsDuplicateSequence(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: all six records land in one file, sequence 0 + + w := openWAL(t, cfg) + for index := uint64(1); index <= 6; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Close()) + + oldName := sealedFileName(0, 1, 6) + require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) + + // Reproduce the crash state: the reduced file [1,3] beside the untouched original [1,6], both internally + // name/content consistent. VerifyIntegrity does not run recovery, so it observes the unreconciled duplicate. + reducedName := sealedFileName(0, 1, 3) + prefix := recordPrefixBytes(t, filepath.Join(dir, oldName), 3) + require.NoError(t, os.WriteFile(filepath.Join(dir, reducedName), prefix, 0o600)) + require.Equal(t, []string{reducedName, oldName}, sealedFileNames(t, dir)) + + err := VerifyIntegrity(dir) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate") + // Each parsed entry resolves to its own file, so neither yields a misattributed range mismatch, and the + // duplicate is reported as a duplicate rather than a sequence gap. + require.NotContains(t, err.Error(), "content holds") + require.NotContains(t, err.Error(), "gap") +} + // TestVerifyIntegrityReportsAllFaults verifies that a single VerifyIntegrity pass aggregates every problem it // finds rather than stopping at the first one. func TestVerifyIntegrityReportsAllFaults(t *testing.T) { diff --git a/sei-db/seiwal/seiwal_offline.go b/sei-db/seiwal/seiwal_offline.go index ea78da2d30..c001d2fa1c 100644 --- a/sei-db/seiwal/seiwal_offline.go +++ b/sei-db/seiwal/seiwal_offline.go @@ -72,13 +72,10 @@ func PruneAfter(path string, highestIndexToKeep uint64) error { return nil } -// VerifyIntegrity reads every sealed file in the WAL directory at path and confirms that each record's CRC is -// intact and that each file's content exactly covers the index range its name promises. This is the expensive -// O(total sealed bytes) check that open (and GetRange/PruneAfter) deliberately skips. -// -// It takes the same exclusive directory lock a live WAL holds, so it fails with -// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory, and serializes against any -// other GetRange/PruneAfter/VerifyIntegrity on that directory. The lock is released before it returns. +// VerifyIntegrity checks every sealed file in the WAL directory at path: each record's CRC is intact, each +// file's content covers the index range its name promises, and the sealed sequence has no gaps or duplicates. +// It does not modify the directory. It requires the exclusive directory lock and returns +// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory. func VerifyIntegrity(path string) error { if err := util.EnsureDirectoryExists(path, true); err != nil { return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) @@ -95,7 +92,6 @@ func VerifyIntegrity(path string) error { } sealed := make([]parsedFileName, 0, len(entries)) - names := make(map[uint64]string, len(entries)) for _, entry := range entries { if entry.IsDir() { continue @@ -105,18 +101,27 @@ func VerifyIntegrity(path string) error { continue } sealed = append(sealed, parsed) - names[parsed.fileSeq] = entry.Name() } sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq < sealed[j].fileSeq }) var problems []error for i, parsed := range sealed { - if i > 0 && parsed.fileSeq != sealed[i-1].fileSeq+1 { - problems = append(problems, fmt.Errorf( - "gap in sealed file sequence between %d and %d (a sealed file is missing)", - sealed[i-1].fileSeq, parsed.fileSeq)) + if i > 0 { + switch { + case parsed.fileSeq == sealed[i-1].fileSeq: + problems = append(problems, fmt.Errorf( + "duplicate sealed file sequence %d (an interrupted rollback swap left two files)", + parsed.fileSeq)) + case parsed.fileSeq != sealed[i-1].fileSeq+1: + problems = append(problems, fmt.Errorf( + "gap in sealed file sequence between %d and %d (a sealed file is missing)", + sealed[i-1].fileSeq, parsed.fileSeq)) + } } - name := names[parsed.fileSeq] + // A sealed file's name is a deterministic function of its parsed fields, so reconstruct it here rather + // than tracking the raw directory entry. Two sealed files that share a fileSeq (a crash remnant from an + // interrupted rollback swap) then resolve to their own distinct files instead of collapsing to one. + name := sealedFileName(parsed.fileSeq, parsed.firstIndex, parsed.lastIndex) contents, err := readWalFile(filepath.Join(path, name)) if err != nil { problems = append(problems, fmt.Errorf("failed to read sealed WAL file %s: %w", name, err)) From 054f452d29ec710c48344f6a53ca799d045bd70f Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 15 Jul 2026 14:38:06 -0500 Subject: [PATCH 41/44] bugfixes --- sei-db/seiwal/seiwal_iterator.go | 16 ++++++++++ sei-db/seiwal/seiwal_iterator_test.go | 42 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go index 7bd121443e..133460b134 100644 --- a/sei-db/seiwal/seiwal_iterator.go +++ b/sei-db/seiwal/seiwal_iterator.go @@ -268,6 +268,22 @@ func (it *walIterator) loadNextFile() (bool, error) { if err := verifySealedContents(contents, f.fileSeq, f.firstIndex, f.lastIndex); err != nil { return false, err } + } else { + // The unsealed mutable snapshot's records through f.lastIndex (the snapshot bound) were complete + // when the snapshot was taken, so a short read is corruption in known-complete data — not a + // live-write torn tail, which can only appear past the bound and is dropped by the maxIndex filter. + if !contents.hasRecords { + return false, fmt.Errorf( + "WAL file (sequence %d) is corrupt: no intact records were read, "+ + "but the snapshot promises indices through %d", + f.fileSeq, f.lastIndex) + } + if contents.lastIndex < f.lastIndex { + return false, fmt.Errorf( + "WAL file (sequence %d) is corrupt: content covers indices through %d, "+ + "short of the snapshot bound %d", + f.fileSeq, contents.lastIndex, f.lastIndex) + } } for _, record := range contents.records { diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go index 7e170ede71..f52777ec12 100644 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ b/sei-db/seiwal/seiwal_iterator_test.go @@ -59,6 +59,48 @@ func TestIteratorRejectsCorruptSealedFile(t *testing.T) { require.Contains(t, iterErr.Error(), "corrupt") } +// TestIteratorRejectsCorruptMutableSnapshot verifies that interior corruption in the still-unsealed mutable +// file, within the range the iterator's point-in-time snapshot promises, is surfaced as an error rather than +// silently truncating. Those records were complete when the snapshot was taken, so a short read is corruption, +// not a live-write torn tail — and iteration must not depend on whether the file later gets sealed for real. +func TestIteratorRejectsCorruptMutableSnapshot(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) // large target: records 1..5 stay in the unsealed mutable file, sequence 0 + + w := openWAL(t, cfg) + for index := uint64(1); index <= 5; index++ { + appendRecord(t, w, index) + } + require.NoError(t, w.Flush()) // records readable on disk; the file is still unsealed + defer func() { require.NoError(t, w.Close()) }() + require.Empty(t, sealedFileNames(t, dir)) + + // Corrupt record 5's CRC in the mutable file, within the [1,5] range the snapshot will promise. + path := filepath.Join(dir, unsealedFileName(0)) + data, err := os.ReadFile(path) + require.NoError(t, err) + data[len(data)-1] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o600)) + + it, err := w.Iterator(1, 5) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + + var iterErr error + for { + ok, err := it.Next() + if err != nil { + iterErr = err + break + } + if !ok { + break + } + } + require.Error(t, iterErr) + require.Contains(t, iterErr.Error(), "corrupt") +} + // TestIteratorEmptyWALErrors verifies that an empty WAL has no latest index, so any requested end index is // beyond it: iterator creation fails with ErrIteratorRange rather than returning an empty iterator, and the // rejection leaves the WAL usable. From ed97f933c8bb73623cfa01801b7416e039c6db7a Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 15 Jul 2026 14:53:27 -0500 Subject: [PATCH 42/44] ignore sei-db/seiwal/walsim/bin build artifacts Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c259bafa2d..bcced10aa4 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ sei-db/state_db/bench/cryptosim/data/** sei-db/state_db/bench/cryptosim/bin/ sei-db/state_db/bench/cryptosim/logs/ sei-db/ledger_db/block/blocksim/bin/ -sei-db/db_engine/litt/bin/ \ No newline at end of file +sei-db/db_engine/litt/bin/ +sei-db/seiwal/walsim/bin/ \ No newline at end of file From 64ec46b60bd107453aa06c5e9b06716c95700035 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 17 Jul 2026 11:27:18 -0500 Subject: [PATCH 43/44] clean .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index bcced10aa4..30c6efe10c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,3 @@ sei-db/state_db/bench/cryptosim/bin/ sei-db/state_db/bench/cryptosim/logs/ sei-db/ledger_db/block/blocksim/bin/ sei-db/db_engine/litt/bin/ -sei-db/seiwal/walsim/bin/ \ No newline at end of file From c2db999776005bd2af82df3b9b920b7162b5efc2 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 20 Jul 2026 13:49:50 -0500 Subject: [PATCH 44/44] made suggested change --- sei-db/seiwal/walsim/walsim_config.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sei-db/seiwal/walsim/walsim_config.go b/sei-db/seiwal/walsim/walsim_config.go index 598e30fb62..1a86331a9d 100644 --- a/sei-db/seiwal/walsim/walsim_config.go +++ b/sei-db/seiwal/walsim/walsim_config.go @@ -157,6 +157,10 @@ func (c *WalsimConfig) Validate() error { return fmt.Errorf("RandomDataBufferSizeBytes must be at least RecordSizeBytes (%d) (got %d)", c.RecordSizeBytes, c.RandomDataBufferSizeBytes) } + // The random buffer is fed to crand.NewCannedRandom, which requires at least 8 bytes. + if c.RandomDataBufferSizeBytes < 8 { + return fmt.Errorf("RandomDataBufferSizeBytes must be at least 8 (got %d)", c.RandomDataBufferSizeBytes) + } if c.StagedRecordQueueSize < 1 { return fmt.Errorf("StagedRecordQueueSize must be at least 1 (got %d)", c.StagedRecordQueueSize) }