Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
33a6cb0
flatKV WAL implementation
Jul 2, 2026
855cbe9
iterator improvements
Jul 2, 2026
6a1efd7
bugfixes
Jul 2, 2026
d86fdc5
bugfixes
Jul 2, 2026
c3207da
bugfixes
Jul 2, 2026
cab8132
fix recovery bug
Jul 6, 2026
12b943f
fix bugs
Jul 6, 2026
6ea0aab
bugfix
Jul 6, 2026
54dd10c
rename
Jul 6, 2026
3fbc79a
made suggested changes
Jul 7, 2026
101e586
move statewal to requested location
Jul 7, 2026
691a217
split into abstract utility
Jul 8, 2026
a201ba8
Merge branch 'main' into cjl/flatkv-wal
Jul 8, 2026
9e4df1b
create async serializing utility
Jul 8, 2026
47b5ac0
iterate and improve
Jul 8, 2026
6ebc75a
document thread safety better
Jul 9, 2026
6191630
bugfixes
Jul 9, 2026
888cc8d
minor fixes
Jul 9, 2026
819fac9
bugfixes
Jul 9, 2026
bc70257
bugfixes
Jul 9, 2026
781c4c0
make suggested changes
Jul 10, 2026
fea1112
made suggested changes
Jul 10, 2026
d9a978b
godoc thread safety issues
Jul 10, 2026
88c06cf
made suggested changes
Jul 10, 2026
f61ec79
make suggested change
Jul 10, 2026
a7836a3
brick on first failure
Jul 10, 2026
70ec467
threading model fixes
Jul 13, 2026
5eef620
check gaps
Jul 13, 2026
869379e
static setup methods
Jul 13, 2026
9c696be
static functions
Jul 13, 2026
3a0d826
better iterators
Jul 13, 2026
efb976e
Added note about possible perf hotspot
Jul 13, 2026
fe3cede
don't scan DB at startup time
Jul 13, 2026
59562f2
made suggested changes
Jul 14, 2026
52142bc
made suggested fix
Jul 14, 2026
622b98b
benchmark for SeiWAL
Jul 14, 2026
086b4e2
make suggested changes to iterator
Jul 15, 2026
e62672f
added file locks
Jul 15, 2026
12ccd55
made suggested changes
Jul 15, 2026
06f64a5
made suggested changes
Jul 15, 2026
011ae8b
bugfix
Jul 15, 2026
054f452
bugfixes
Jul 15, 2026
ed97f93
ignore sei-db/seiwal/walsim/bin build artifacts
Jul 15, 2026
64ec46b
clean .gitignore
Jul 17, 2026
dc751d4
Merge branch 'cjl/flatkv-wal' into cjl/walsim
Jul 17, 2026
c015363
Merge branch 'main' into cjl/walsim
Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,5 @@ sei-db/state_db/bench/cryptosim/data/**
sei-db/state_db/bench/cryptosim/bin/
sei-db/state_db/bench/cryptosim/logs/
sei-db/ledger_db/block/blocksim/bin/
sei-db/seiwal/walsim/bin/
sei-db/db_engine/litt/bin/
14 changes: 14 additions & 0 deletions sei-db/seiwal/walsim/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
BINARY := bin/walsim
LOGGER_BINARY := bin/configure-logger
MODULE_ROOT ?= $(shell git -C "$(CURDIR)" rev-parse --show-toplevel)
CMD_DIR := ./sei-db/seiwal/walsim/cmd

.PHONY: build
build:
@mkdir -p "$(CURDIR)/bin"
cd "$(MODULE_ROOT)" && go build -o "$(CURDIR)/$(BINARY)" "$(CMD_DIR)/walsim"
cd "$(MODULE_ROOT)" && go build -o "$(CURDIR)/$(LOGGER_BINARY)" "$(CMD_DIR)/configure-logger"

.PHONY: clean
clean:
rm -f "$(CURDIR)/$(BINARY)" "$(CURDIR)/$(LOGGER_BINARY)"
52 changes: 52 additions & 0 deletions sei-db/seiwal/walsim/cmd/configure-logger/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// configure-logger reads a walsim config file and prints shell export
// statements that configure seilog's environment variables. Intended to be
// called via eval in a shell script before launching the benchmark binary.
//
// Usage:
//
// eval "$(configure-logger config.json)"
package main

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/sei-protocol/sei-chain/sei-db/common/utils"
"github.com/sei-protocol/sei-chain/sei-db/seiwal/walsim"
)

func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "configure-logger: %v\n", err)
os.Exit(1)
}
}

func run() error {
if len(os.Args) != 2 {
return fmt.Errorf("usage: configure-logger <config-file>")
}

cfg := walsim.DefaultWalsimConfig()
if err := utils.LoadConfigFromFile(os.Args[1], cfg); err != nil {
return fmt.Errorf("load config: %w", err)
}

logDir, err := utils.ResolveAndCreateDir(cfg.LogDir)
if err != nil {
return fmt.Errorf("resolve log dir: %w", err)
}

logFile := filepath.Join(logDir, "walsim.log")

fmt.Printf("export SEI_LOG_OUTPUT=%s\n", shellQuote(logFile))
fmt.Printf("export SEI_LOG_LEVEL=%s\n", shellQuote(strings.ToLower(cfg.LogLevel)))

return nil
}

func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
88 changes: 88 additions & 0 deletions sei-db/seiwal/walsim/cmd/walsim/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"bufio"
"context"
"fmt"
"os"
"os/signal"

"github.com/sei-protocol/sei-chain/sei-db/common/metrics"
"github.com/sei-protocol/sei-chain/sei-db/common/utils"
"github.com/sei-protocol/sei-chain/sei-db/seiwal/walsim"
)

func main() {
err := run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

func run() error {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s <config-file>\n", os.Args[0])
os.Exit(1)
}
config := walsim.DefaultWalsimConfig()
if err := utils.LoadConfigFromFile(os.Args[1], config); err != nil {
return err
}

configString, err := utils.StringifyConfig(config)
if err != nil {
return fmt.Errorf("failed to stringify config: %w", err)
}
fmt.Printf("%s\n", configString)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

reg, shutdown, err := metrics.SetupOtelPrometheus()
if err != nil {
return fmt.Errorf("setup metrics: %w", err)
}
defer func() {
_ = shutdown(context.Background())
}()

wsMetrics := walsim.NewWalsimMetrics(ctx, config)

ws, err := walsim.NewWalSim(ctx, config, wsMetrics)
if err != nil {
return fmt.Errorf("failed to create walsim: %w", err)
}
defer func() {
if err := ws.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Error closing walsim: %v\n", err)
}
}()

metrics.StartMetricsServer(ctx, reg, config.MetricsAddr)
metrics.StartSystemMetrics(ctx, "walsim", config.BackgroundMetricsScrapeInterval,
[]metrics.MonitoredDir{
{Name: "data_dir", Path: config.DataDir, TrackAvailableSpace: true},
{Name: "log_dir", Path: config.LogDir},
})

if config.EnableSuspension {
go func() {
scanner := bufio.NewScanner(os.Stdin)
suspended := false
for scanner.Scan() {
if suspended {
ws.Resume()
suspended = false
} else {
ws.Suspend()
suspended = true
}
}
}()
}

ws.BlockUntilHalted()

return nil
}
9 changes: 9 additions & 0 deletions sei-db/seiwal/walsim/config/debug.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Comment": "Configuration for local smoke testing and debugging.",
"DataDir": "~/walsim/data",
"LogDir": "~/walsim/logs",
"CleanDataOnStart": true,
"CleanLogsOnStart": true,
"CleanDataOnExit": true,
"CleanLogsOnExit": true
}
5 changes: 5 additions & 0 deletions sei-db/seiwal/walsim/config/standard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"Comment": "Standard walsim configuration.",
"DataDir": "~/walsim/data",
"LogDir": "~/walsim/logs"
}
50 changes: 50 additions & 0 deletions sei-db/seiwal/walsim/record_generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package walsim

import (
"context"

crand "github.com/sei-protocol/sei-chain/sei-db/common/rand"
)

// RecordGenerator asynchronously produces fixed-size opaque records and feeds them into a channel.
// Each record is a zero-copy sub-slice of a pre-generated, immutable CannedRandom buffer, so the
// generator never runs math/rand or allocates payload bytes on the hot path. The generator stops
// when the context is cancelled.
//
// The CannedRandom buffer is never mutated, so the sub-slices remain valid indefinitely; this makes
// it safe for the WAL to retain a record slice and serialize it asynchronously.
type RecordGenerator struct {
ctx context.Context
rand *crand.CannedRandom
recordSize int
recordChan chan []byte
}

// NewRecordGenerator creates a RecordGenerator and immediately starts its background goroutine. rand
// must not be shared with any other goroutine (this generator owns it).
func NewRecordGenerator(
ctx context.Context,
rng *crand.CannedRandom,
recordSize int,
queueSize int,
) *RecordGenerator {
g := &RecordGenerator{
ctx: ctx,
rand: rng,
recordSize: recordSize,
recordChan: make(chan []byte, queueSize),
}
go g.mainLoop()
return g
}

func (g *RecordGenerator) mainLoop() {
for {
record := g.rand.Bytes(g.recordSize)
select {
case <-g.ctx.Done():
return
case g.recordChan <- record:
}
}
}
147 changes: 147 additions & 0 deletions sei-db/seiwal/walsim/wal_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package walsim

import (
"context"
"fmt"

"github.com/sei-protocol/sei-chain/sei-db/seiwal"
"github.com/sei-protocol/sei-chain/sei-db/wal"
)

// walStore is the minimal WAL surface the benchmark drives. The new seiwal WAL (seiwal.WAL[[]byte])
// satisfies it directly with no wrapper; the legacy sei-db/wal WAL is adapted by legacyWALShim.
type walStore interface {
// Append schedules a record with the given index and payload.
Append(index uint64, data []byte) error

// Flush blocks until previously scheduled appends are durable.
Flush() error

// Bounds reports whether any record is stored and, if so, the lowest and highest stored indices.
Bounds() (ok bool, first uint64, last uint64, err error)

// PruneBefore requests removal of all records with an index below lowestIndexToKeep.
PruneBefore(lowestIndexToKeep uint64) error

// Close flushes pending appends and releases resources.
Close() error
}

// The seiwal WAL implements walStore directly.
var _ walStore = (seiwal.WAL[[]byte])(nil)

// The legacy shim implements walStore.
var _ walStore = (*legacyWALShim)(nil)

// openWALStore opens the WAL backend selected by the configuration.
func openWALStore(ctx context.Context, config *WalsimConfig) (walStore, error) {
switch config.Backend {
case "seiwal":
cfg := config.Seiwal
// walsim owns the storage path and metric name.
cfg.Path = config.DataDir
cfg.Name = "walsim"
w, err := seiwal.NewWAL(&cfg)
if err != nil {
return nil, fmt.Errorf("failed to open seiwal WAL: %w", err)
}
return w, nil
case "legacy":
return newLegacyWALShim(ctx, config)
default:
return nil, fmt.Errorf("unknown WAL backend: %q", config.Backend)
}
}

// legacyWALShim adapts the legacy sei-db/wal WAL to the walStore interface so the benchmark can
// drive it exactly like the new seiwal WAL. It is throwaway code: the legacy WAL is scheduled for
// deletion, and this shim goes with it.
//
// The legacy WAL's TruncateBefore rewrites segment files and is O(segments), so forwarding it on
// every request (as the size-target prune loop wants) would be catastrophic. The shim therefore
// coalesces prune requests, forwarding only every pruneRelaxationFactor-th PruneBefore to the
// underlying TruncateBefore.
type legacyWALShim struct {
inner *wal.WAL[[]byte]

// Forward a TruncateBefore only once per this many PruneBefore calls.
pruneRelaxationFactor uint64

// The number of PruneBefore calls received so far.
pruneRequestCount uint64
}

func newLegacyWALShim(ctx context.Context, config *WalsimConfig) (*legacyWALShim, error) {
identity := func(b []byte) ([]byte, error) { return b, nil }

cfg := config.Legacy
// Force the legacy WAL's own background pruning off so pruning is driven solely through
// PruneBefore (and coalesced below).
cfg.KeepRecent = 0
cfg.PruneInterval = 0

inner, err := wal.NewWAL[[]byte](ctx, identity, identity, config.DataDir, cfg)
if err != nil {
return nil, fmt.Errorf("failed to open legacy WAL: %w", err)
}

return &legacyWALShim{
inner: inner,
pruneRelaxationFactor: config.PruneRelaxationFactor,
}, nil
}

// Append writes data to the legacy WAL. The legacy WAL assigns its own 1-based, contiguous index,
// so the caller-supplied index is ignored; walsim seeds its counter from Bounds, keeping the two
// aligned.
func (s *legacyWALShim) Append(_ uint64, data []byte) error {
if err := s.inner.Write(data); err != nil {
return fmt.Errorf("failed to write to legacy WAL: %w", err)
}
return nil
}

// Flush is a no-op. The legacy WAL exposes no explicit sync, so durability is governed entirely by
// its FsyncEnabled and batching configuration.
func (s *legacyWALShim) Flush() error {
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks default FlushIntervalRecords: 1, the seiwal backend does a real blocking Flush() per record while legacyWALShim.Flush() is a no-op. so the config compares per-record durability against batched writes, does it mean the throughput numbers aren't directly comparable across backends?

walsim_flush_calls_total still increments for legacy even though the flush did nothing, so that metric is inflated for the legacy backend

}

// Bounds reports the stored index range. The legacy WAL reports 0/0 for an empty log; real indices
// are 1-based, so a last offset of 0 means empty.
func (s *legacyWALShim) Bounds() (bool, uint64, uint64, error) {
first, err := s.inner.FirstOffset()
if err != nil {
return false, 0, 0, fmt.Errorf("failed to read first offset: %w", err)
}
last, err := s.inner.LastOffset()
if err != nil {
return false, 0, 0, fmt.Errorf("failed to read last offset: %w", err)
}
if last == 0 {
return false, 0, 0, nil
}
return true, first, last, nil
}

// PruneBefore coalesces prune requests: only every pruneRelaxationFactor-th call reaches the
// underlying TruncateBefore, which is expensive in the legacy WAL. The most recent
// lowestIndexToKeep is used when the forward fires.
func (s *legacyWALShim) PruneBefore(lowestIndexToKeep uint64) error {
s.pruneRequestCount++
if s.pruneRequestCount%s.pruneRelaxationFactor != 0 {
return nil
}
if err := s.inner.TruncateBefore(lowestIndexToKeep); err != nil {
return fmt.Errorf("failed to truncate legacy WAL: %w", err)
}
return nil

Check warning on line 138 in sei-db/seiwal/walsim/wal_store.go

View check run for this annotation

Claude / Claude Code Review

legacyWALShim.PruneBefore escalates benign out-of-range truncate errors to fatal

legacyWALShim.PruneBefore (sei-db/seiwal/walsim/wal_store.go:135-137) treats **any** non-nil error from the legacy WAL's TruncateBefore as fatal, but sei-db/wal's handleTruncate intentionally returns a benign 'out of range' error when the target index is at or behind the current front (documented at wal.go:387-388) — the WAL keeps running, it just reports it. The shim forwards that benign error unmodified, and WalSim.handleNextRecord (walsim.go) escalates any PruneBefore error into b.cancel(), a
Comment on lines +128 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 legacyWALShim.PruneBefore (sei-db/seiwal/walsim/wal_store.go:135-137) treats any non-nil error from the legacy WAL's TruncateBefore as fatal, but sei-db/wal's handleTruncate intentionally returns a benign 'out of range' error when the target index is at or behind the current front (documented at wal.go:387-388) — the WAL keeps running, it just reports it. The shim forwards that benign error unmodified, and WalSim.handleNextRecord (walsim.go) escalates any PruneBefore error into b.cancel(), aborting the whole benchmark run instead of continuing like the seiwal backend does (walImpl.PruneBefore never errors for this case).

Extended reasoning...

The bug: sei-db/wal's handleTruncate deliberately treats an out-of-range truncate index (i.e. TruncateFront(idx) where idx is at or behind the WAL's current first offset) as benign — see the doc comment at sei-db/wal/wal.go:387-388: "'Out of range' truncate errors ... are reported to the caller but are not fatal; the WAL continues operating so callers can treat them as benign." However, handleTruncate still writes a non-nil, wrapped error ("out of range truncate error: ...") onto req.errChan, and sendTruncate/TruncateBefore propagate that same non-nil error to the caller regardless of whether it's the benign out-of-range case or a genuinely fatal one — there is no way for a caller to distinguish the two just by checking err != nil.

legacyWALShim.PruneBefore (sei-db/seiwal/walsim/wal_store.go:135-137) does not make that distinction: it wraps and returns any non-nil error from inner.TruncateBefore(lowestIndexToKeep). That propagates up through WalSim.handleNextRecord (walsim.go), which treats any PruneBefore error as fatal — it prints the error and calls b.cancel(), tearing down the entire benchmark run.

Why this is reachable, not just theoretical: In a single continuous run with a fixed config, lowestToKeep grows monotonically and the WAL's firstIndex advances to match, so the target is always in-range and this path never fires. But legacyWALShim is opened against a persistent on-disk WAL (CleanDataOnStart defaults to false), and WalSim.NewWalSim explicitly resumes from existing bounds (Bounds() -> highest, nextIndex = last + 1). retainedRecords is recomputed from the current config's TargetDiskSizeBytes / RecordSizeBytes each time the process starts, not persisted. If a user resumes a legacy-backend run against an existing data directory after raising TargetDiskSizeBytes (or lowering RecordSizeBytes) between runs, retainedRecords grows. The very first forwarded prune in the new run computes lowestToKeep = highestIndex - retainedRecords + 1, which can land below the WAL's persisted firstIndex from the prior run's truncation (the old, smaller retainedRecords had already pruned further ahead). That is exactly the 'out of range' case the WAL treats as benign — but the shim aborts the benchmark instead of ignoring it.

Concrete walkthrough:

  1. Run 1: TargetDiskSizeBytes=1GB, RecordSizeBytes=1MB -> retainedRecords=1000. After writing 5000 records, prunes have advanced the WAL's firstIndex to 4001 (keeping the most recent 1000).
  2. Process stops (or is restarted) with the same data dir.
  3. Run 2: config is changed to TargetDiskSizeBytes=4GB (e.g. testing a larger working set) -> retainedRecords=4000. NewWalSim resumes: Bounds() reports last=5000, so nextIndex=5001, highestIndex=5000.
  4. The very first new append pushes highestIndex to 5001. Since 5001 >= 4000, a prune fires: lowestToKeep = 5001 - 4000 + 1 = 1002.
  5. But the WAL's firstIndex is already 4001 (from run 1's pruning) — 1002 < 4001, so TruncateFront(1002) is out-of-range for the underlying tidwall/wal log.
  6. handleTruncate returns the benign-but-non-nil "out of range truncate error". The shim forwards it as a hard error; WalSim.handleNextRecord calls b.cancel() and the benchmark halts almost immediately, instead of simply no-op'ing the stale prune target and continuing (as the seiwal backend does via walImpl.PruneBefore, which never errors on out-of-range targets).

Why nothing currently catches this: wal_store_test.go's TestLegacyShimCoalescesPruneRequests only exercises monotonically increasing, always-in-range prune indices within a single shim instance — it never re-opens the shim against existing data with a different PruneRelaxationFactor/target, so this resume-with-changed-config path is untested.

Fix: In legacyWALShim.PruneBefore, check whether the error from TruncateBefore is the benign 'out of range' case (e.g. strings.Contains(err.Error(), "out of range")) and return nil in that case, mirroring the WAL's own documented benign-error contract and matching the seiwal backend's no-op behavior for the same situation.

}

// Close shuts down the legacy WAL.
func (s *legacyWALShim) Close() error {
if err := s.inner.Close(); err != nil {
return fmt.Errorf("failed to close legacy WAL: %w", err)
}
return nil
}
Loading
Loading