From 33a6cb0397a997ed163d3b6b758ef2befc66adb2 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Thu, 2 Jul 2026 14:48:59 -0500 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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) },