Skip to content

WAL Benchmark#3775

Open
cody-littley wants to merge 46 commits into
mainfrom
cjl/walsim
Open

WAL Benchmark#3775
cody-littley wants to merge 46 commits into
mainfrom
cjl/walsim

Conversation

@cody-littley

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Implements a benchmark for the new WAL implementation.

@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
New benchmark-only code under sei-db/seiwal/walsim; no production node or consensus paths changed.

Overview
Adds sei-db/seiwal/walsim, a standalone benchmark runner for comparing the new seiwal WAL with the legacy sei-db/wal implementation under sustained append, flush, and prune load.

The harness drives a shared walStore interface (append, flush, bounds, prune, close), opens seiwal directly or the legacy WAL through a legacyWALShim that coalesces expensive TruncateBefore calls. The main loop streams fixed-size zero-copy payloads from a RecordGenerator, optionally rate-limits writes, flushes on an interval, and prunes to hold on-disk size near TargetDiskSizeBytes, with resume-from-existing-WAL behavior via Bounds.

Operational pieces mirror other DB sims: JSON WalsimConfig (with validation), sample debug.json / standard.json, OpenTelemetry/Prometheus metrics and phase timing, walsim.sh + configure-logger for seilog env, and a Makefile. .gitignore ignores walsim/bin/. Unit tests cover legacy shim bounds and prune coalescing.

Reviewed by Cursor Bugbot for commit c015363. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 17, 2026, 6:52 PM

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 11.27049% with 433 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.04%. Comparing base (7fee5c2) to head (c015363).

Files with missing lines Patch % Lines
sei-db/seiwal/walsim/walsim.go 0.00% 205 Missing ⚠️
sei-db/seiwal/walsim/walsim_metrics.go 0.00% 82 Missing ⚠️
sei-db/seiwal/walsim/cmd/walsim/main.go 0.00% 50 Missing ⚠️
sei-db/seiwal/walsim/walsim_config.go 46.87% 34 Missing ⚠️
sei-db/seiwal/walsim/wal_store.go 47.16% 22 Missing and 6 partials ⚠️
sei-db/seiwal/walsim/cmd/configure-logger/main.go 0.00% 19 Missing ⚠️
sei-db/seiwal/walsim/record_generator.go 0.00% 15 Missing ⚠️

❌ Your patch check has failed because the patch coverage (11.27%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3775      +/-   ##
==========================================
- Coverage   60.14%   59.04%   -1.10%     
==========================================
  Files        2303     2218      -85     
  Lines      192032   182042    -9990     
==========================================
- Hits       115491   107484    -8007     
+ Misses      66230    65092    -1138     
+ Partials    10311     9466     -845     
Flag Coverage Δ
sei-chain-pr 11.27% <11.27%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/seiwal/walsim/record_generator.go 0.00% <0.00%> (ø)
sei-db/seiwal/walsim/cmd/configure-logger/main.go 0.00% <0.00%> (ø)
sei-db/seiwal/walsim/wal_store.go 47.16% <47.16%> (ø)
sei-db/seiwal/walsim/walsim_config.go 46.87% <46.87%> (ø)
sei-db/seiwal/walsim/cmd/walsim/main.go 0.00% <0.00%> (ø)
sei-db/seiwal/walsim/walsim_metrics.go 0.00% <0.00%> (ø)
sei-db/seiwal/walsim/walsim.go 0.00% <0.00%> (ø)

... and 92 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c015363. Configure here.

b.totalRecordsWritten = 0
b.totalBytesWritten = 0
b.startTimestamp = time.Now()
fmt.Printf("Benchmark resumed.\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resume skips console rate baseline

Low Severity

After resume, totalRecordsWritten and startTimestamp reset but lastConsoleUpdateRecordCount does not. recordsSinceLastUpdate stays negative until the pre-suspend write count is exceeded, so record-based console update throttling is wrong until that catch-up.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c015363. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds a self-contained WAL benchmark (walsim) comparing the new seiwal WAL against the legacy WAL via a throwaway shim. This is standalone benchmark/tooling code (no production paths, labeled non-app-hash-breaking); it's well-structured and documented, but has a few config/correctness rough edges worth addressing.

Findings: 0 blocking | 6 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • legacyWALShim.Flush is a permanent no-op because the legacy WAL exposes no explicit sync (confirmed: sei-db/wal/wal.go has only Write/Truncate/Close). This is inherent, but it undermines the benchmark's stated purpose: FlushIntervalRecords is meaningful for the seiwal backend and meaningless for legacy, and WalsimMetrics.ReportFlush() is still incremented for legacy on every flush interval even though nothing was flushed — producing a misleading flush metric and a non-apples-to-apples comparison. Consider documenting this limitation prominently and/or suppressing the flush metric for the legacy backend.
  • WalsimConfig.Validate does not validate the embedded Seiwal/Legacy sub-configs (no call to their Validate()), so invalid backend-specific settings are only caught (or not) at open time.
  • REVIEW_GUIDELINES.md and cursor-review.md were empty/missing, so no repo-specific guidelines and no Cursor second-opinion pass were available; only the Codex pass produced output (its three findings are incorporated here).
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

if c.RecordSizeBytes < 1 {
return fmt.Errorf("RecordSizeBytes must be at least 1 (got %d)", c.RecordSizeBytes)
}
if c.RandomDataBufferSizeBytes < c.RecordSizeBytes {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Validation gap: this only enforces RandomDataBufferSizeBytes >= RecordSizeBytes, and RecordSizeBytes may be as low as 1, so a config with RandomDataBufferSizeBytes in 1–7 passes Validate(). But crand.NewCannedRandom (sei-db/common/rand/canned_random.go) panics when bufferSize < 8. Since LoadConfigFromFile runs Validate() and then NewWalSim calls NewCannedRandom(int(config.RandomDataBufferSizeBytes), ...), such a config crashes the tool with a panic instead of a clean validation error. Add a minimum (>= 8, or better >= RecordSizeBytes and >= 8) check here.

break
}
// Reset console metrics on resume.
b.totalRecordsWritten = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Resetting totalRecordsWritten to 0 on resume has two side effects beyond console reporting: (1) handleNextRecord gates the periodic flush on totalRecordsWritten % FlushIntervalRecords == 0, so a suspend/resume restarts the flush cadence rather than continuing it; (2) lastConsoleUpdateRecordCount is not reset here, so after resume recordsSinceLastUpdate = totalRecordsWritten - lastConsoleUpdateRecordCount goes negative and the records-based console trigger is effectively disabled until the counter climbs back past the stale value. Consider tracking a separate throughput-window counter, and reset lastConsoleUpdateRecordCount alongside these fields.

return err
}
for _, entry := range entries {
if err := os.RemoveAll(fmt.Sprintf("%s/%s", dir, entry.Name())); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Nit: use filepath.Join(dir, entry.Name()) instead of fmt.Sprintf("%s/%s", ...) for path construction.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — approving despite the nits, since all four inline findings are benchmark-only tooling issues (console-output staleness, suspend/timeout interaction, a benign-error escalation, and a config-validation gap) with no production or consensus code impact.

Extended reasoning...

Overview

This PR adds a new, self-contained benchmark tool (sei-db/seiwal/walsim) for comparing the new seiwal WAL against the legacy sei-db/wal implementation under load. It includes the benchmark driver (walsim.go), a walStore abstraction with a legacy WAL adapter (wal_store.go), a record generator, config/validation, OTel metrics, sample configs, a shell wrapper, and unit tests for the legacy shim's bounds/prune-coalescing behavior. A one-line .gitignore addition ignores the new bin/ build output.

Security risks

None identified. This is standalone CLI benchmark tooling that only touches local WAL files under an operator-specified data directory; it is never imported by or wired into any production/consensus code path (node startup, block execution, RPC, etc.). There is no user-facing input parsing beyond a local JSON config file and command-line args.

Level of scrutiny

Low-to-moderate. This is throwaway benchmark infrastructure (the PR description and code comments explicitly note the legacy WAL/shim are slated for deletion), not production-critical code, so the bar here is correctness of the tool for its intended purpose rather than production hardening. The four issues found by the bug-hunting system are all real but minor: two affect console reporting and suspend/timeout interaction (cosmetic, with authoritative data still exported via Prometheus/OTel), one is a benign-error escalation reachable only via a specific resume-after-reconfiguration scenario on the legacy backend, and one is a validation gap that only surfaces with a deliberately tiny custom buffer size. None of them affect correctness of the core append/flush/prune benchmark loop under normal operation, and none touch production or consensus paths.

Other factors

The PR includes unit tests for the legacy shim's bounds and prune-coalescing logic, and the overall design (shared walStore interface, zero-copy record generation, OTel metrics) is clean and follows patterns used by other DB benchmarks in the repo (cryptosim, blocksim). No CODEOWNER-restricted paths are touched, and there are no unresolved reviewer comments in the timeline.

Comment on lines +172 to +183
if isSuspended {
b.suspend()
}
case <-timeoutChan:
fmt.Printf("\nBenchmark timed out after %s.\n",
utils.FormatDuration(time.Since(b.startTimestamp), 1))
b.cancel()
return
case record := <-b.generator.recordChan:
b.maybeThrottle()
b.handleNextRecord(record)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The MaxRuntimeSeconds timeout branch in WalSim.run() returns without calling generateConsoleReport(true), unlike the ctx.Done() (signal/Close) branch, so a run that ends via the documented timeout mechanism skips the forced final throughput summary and also misses the per-loop generateConsoleReport(false) at the bottom of the loop. This is a minor console-output inconsistency between shutdown paths; a one-line fix (add b.generateConsoleReport(true) before the timeout print) would make it consistent with the other exit path.

Extended reasoning...

The bug: In WalSim.run() (sei-db/seiwal/walsim/walsim.go:166-183), the two "planned" shutdown paths handle the final console report differently:

case <-b.ctx.Done():
    b.generateConsoleReport(true)
    fmt.Printf("\nBenchmark halted.\n")
    return
case isSuspended := <-b.suspendChan:
    ...
case <-timeoutChan:
    fmt.Printf("\nBenchmark timed out after %s.\n", ...)
    b.cancel()
    return

The ctx.Done() case (hit on SIGINT or an explicit Close()) forces a final report with generateConsoleReport(true) before printing its halt message. The timeoutChan case (hit when MaxRuntimeSeconds elapses — a documented, first-class way to end a benchmark run per WalsimConfig.MaxRuntimeSeconds) prints only the timeout message and returns immediately. Because it returns rather than falling through to the bottom of the for loop, it also skips the per-iteration b.generateConsoleReport(false) call that would otherwise run at the end of every loop body.

Why it's not caught elsewhere: teardown() (deferred via defer b.teardown()) only flushes/closes the WAL and cleans directories — it never touches console reporting, so there's no fallback report on the way out.

Concrete walk-through: Configure MaxRuntimeSeconds: 60, ConsoleUpdateIntervalSeconds: 1. The loop prints a throughput line roughly once per second via generateConsoleReport(false) while records stream in. At t=60s, timeoutChan fires mid-iteration (the goroutine is blocked in select, not at the bottom of the loop). The last printed line reflects whatever counters existed at the last periodic tick (up to ~1s / ConsoleUpdateIntervalRecords stale), and no forced, freshly-computed final line is ever printed — contrast with hitting Ctrl+C, which forces one via generateConsoleReport(true).

Impact: This is a purely cosmetic/console-output gap in benchmark-only tooling (sei-db/seiwal/walsim), not a production or consensus code path. It does not lose data: generateConsoleReport(false) already prints a near-current throughput line at least every ConsoleUpdateIntervalSeconds (default 1s) / ConsoleUpdateIntervalRecords (default 10,000) throughout the run, and all authoritative metrics are exported continuously via OpenTelemetry/Prometheus (walsim_records_written_total, walsim_bytes_written_total, etc.), independent of this console line. So no benchmark data is actually lost — only the final console line is up to ~1 second staler on the timeout path than on the signal/Close path.

Fix: Add b.generateConsoleReport(true) immediately before (or after) the "Benchmark timed out" print in the timeoutChan case, mirroring the ctx.Done() case.

All three independent verifiers confirmed this exact code-level discrepancy and converged on nit severity for the reasons above (benchmark-only tooling, near-current stats already visible on screen, and OTel/Prometheus carrying the authoritative data) — this reasoning agrees with that assessment.

Comment on lines +241 to +266
func (b *WalSim) suspend() {
// Flush before suspending so state is durable.
if err := b.store.Flush(); err != nil {
fmt.Printf("failed to flush on suspend: %v\n", err)
}

fmt.Printf("Benchmark suspended.\n")
b.metrics.SetMainThreadPhase("suspended")

for {
select {
case <-b.ctx.Done():
return
case suspended := <-b.suspendChan:
if suspended {
break
}
// Reset console metrics on resume.
b.totalRecordsWritten = 0
b.totalBytesWritten = 0
b.startTimestamp = time.Now()
fmt.Printf("Benchmark resumed.\n")
return
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MaxRuntimeSeconds cannot terminate the run while the benchmark is suspended: run()'s outer select owns the only <-timeoutChan case, but while suspended control sits inside suspend()'s own for/select (only ctx.Done()/suspendChan cases), so a fired deadline goes unnoticed until Resume() is called, at which point it fires immediately. This is benchmark-only, operator-triggered tooling, so it's minor, but worth adding a <-timeoutChan case (or switching to a ctx-based deadline) to suspend()'s loop so a configured run length is a real wall-clock cap even across a manual pause.

Extended reasoning...

run() builds timeoutChan := time.After(MaxRuntimeSeconds) once, and the only place that ever reads from it is the outer loop's select (walsim.go, run()). When that same outer loop receives isSuspended=true on suspendChan, it calls b.suspend() synchronously (line ~174), and suspend() then runs its own blocking for { select { case <-b.ctx.Done(): ...; case suspended := <-b.suspendChan: ... } } } (lines 250-265). That inner select has no timeoutChan case, and while it's running, the outer loop's select (the one that owns timeoutChan) is not being evaluated at all — Go can't observe a channel it isn't currently selecting on.

Concrete trace: start walsim with EnableSuspension=true and MaxRuntimeSeconds=100. At t=10s the operator presses Enter (stdin toggle in cmd/walsim/main.go), Suspend() sends true on suspendChan, and run() calls b.suspend(). Control is now parked in suspend()'s private loop. At t=100s, time.After's channel becomes readable — but nothing is selecting on it, so nothing happens; the benchmark just keeps sitting suspended. Only when the operator presses Enter again does Resume() send false, suspend() returns, and control goes back to run()'s outer select — where timeoutChan is now immediately ready, so the very next iteration terminates the benchmark instantly, regardless of how little "active" time has actually elapsed since resume (e.g., only the original 10s of activity).

So the practical effect is inconsistent, not simply "extended": if the operator never resumes, MaxRuntimeSeconds is never enforced at all (aside from Ctrl+C via ctx.Done()); if they do resume, the benchmark quits at essentially the next event rather than after the intended amount of additional run time. Neither "pause the deadline" nor "treat it as a strict wall-clock cap across pauses" is actually implemented — the timer keeps running invisibly in the background the whole time it's parked in suspend().

I considered the counter-argument (raised by one reviewer) that suspension is a deliberate, operator-driven pause and that having MaxRuntimeSeconds not fire mid-pause is a defensible design choice — the operator is at the keyboard and can Resume() or Ctrl+C at will, and suspend() does reset startTimestamp/counters on resume, suggesting "suspend pauses the run" is intentional. That's a fair reading for the "doesn't self-terminate while paused" half of the behavior. But it doesn't explain the other half: timeoutChan is not actually paused, it keeps counting real wall-clock time from the original start, so resuming after a long pause causes an unrelated-looking near-instant shutdown that has nothing to do with how much the benchmark has run since resume. That's the part that isn't a reasonable design choice so much as a leftover of timeoutChan and the suspend loop not being connected — the fix is cheap (add a case <-timeoutChan: to suspend()'s select, or swap to a single context.WithDeadline that both loops honor via ctx.Done()).

Given this is throwaway benchmark tooling with no consensus/production path, and the worst outcome is a benchmark run's length not matching MaxRuntimeSeconds when the operator manually pauses it, I'm filing this as a nit rather than a blocking issue — it's worth fixing but shouldn't hold up the PR.

Comment on lines +128 to +138
// 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

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.

Comment on lines +154 to +168
return fmt.Errorf("RecordSizeBytes must be at least 1 (got %d)", c.RecordSizeBytes)
}
if c.RandomDataBufferSizeBytes < c.RecordSizeBytes {
return fmt.Errorf("RandomDataBufferSizeBytes must be at least RecordSizeBytes (%d) (got %d)",
c.RecordSizeBytes, c.RandomDataBufferSizeBytes)
}
if c.StagedRecordQueueSize < 1 {
return fmt.Errorf("StagedRecordQueueSize must be at least 1 (got %d)", c.StagedRecordQueueSize)
}
if c.TargetDiskSizeBytes > 0 && c.TargetDiskSizeBytes < c.RecordSizeBytes {
return fmt.Errorf("TargetDiskSizeBytes, when non-zero, must be at least RecordSizeBytes (%d) (got %d)",
c.RecordSizeBytes, c.TargetDiskSizeBytes)
}
if c.PruneRelaxationFactor < 1 {
return fmt.Errorf("PruneRelaxationFactor must be at least 1 (got %d)", c.PruneRelaxationFactor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 WalsimConfig.Validate() only checks RandomDataBufferSizeBytes >= RecordSizeBytes and RecordSizeBytes >= 1, but never enforces crand.NewCannedRandom's 8-byte minimum buffer size. A config with e.g. RecordSizeBytes=1 and RandomDataBufferSizeBytes=4 passes Validate() cleanly but then panics unrecovered in NewWalSim -> crand.NewCannedRandom ("buffer size must be at least 8 bytes, got 4"), so the clean-config-error path Validate() is meant to provide gets bypassed. Fix: also require RandomDataBufferSizeBytes >= 8 (or max(RecordSizeBytes, 8)) in Validate().

Extended reasoning...

WalsimConfig.Validate() (sei-db/seiwal/walsim/walsim_config.go:154-168) enforces RecordSizeBytes >= 1 and RandomDataBufferSizeBytes >= RecordSizeBytes, but never checks that RandomDataBufferSizeBytes meets the 8-byte floor that crand.NewCannedRandom requires (sei-db/common/rand/canned_random.go:40-42):

if bufferSize < 8 {
    panic(fmt.Sprintf("buffer size must be at least 8 bytes, got %d", bufferSize))
}

Code path: LoadConfigFromFile decodes the JSON config and calls Validate() (sei-db/common/utils/config.go), returning any error to the caller as a normal error — this is explicitly the "clean config error" path the codebase relies on so operators get an actionable message instead of a crash. But NewWalSim (walsim.go) calls crand.NewCannedRandom(int(config.RandomDataBufferSizeBytes), config.Seed) unconditionally after Validate() has already passed, and there is no recover() anywhere between main() and this call.

Step-by-step proof:

  1. Operator writes a config JSON with "RecordSizeBytes": 1, "RandomDataBufferSizeBytes": 4.
  2. main() -> utils.LoadConfigFromFile -> WalsimConfig.Validate() runs:
    • RecordSizeBytes (1) >= 1
    • RandomDataBufferSizeBytes (4) >= RecordSizeBytes (1)
    • No other check touches RandomDataBufferSizeBytes, so Validate() returns nil.
  3. main() proceeds to walsim.NewWalSim(ctx, config, wsMetrics).
  4. NewWalSim calls crand.NewCannedRandom(int(config.RandomDataBufferSizeBytes), config.Seed) = NewCannedRandom(4, seed).
  5. NewCannedRandom hits bufferSize < 8 (4 < 8) and panics with "buffer size must be at least 8 bytes, got 4".
  6. No recover() exists in the call chain, so the process crashes with a raw stack trace instead of the clean, actionable error Validate() is supposed to guarantee.

Why existing code doesn't prevent it: the two existing checks only relate RandomDataBufferSizeBytes to RecordSizeBytes, not to the library's own absolute floor. Since the default config uses RandomDataBufferSizeBytes: unit.GB, the gap is invisible unless someone deliberately sets a small custom config (e.g., for a minimal smoke test), which is exactly the kind of edge case Validate() exists to catch cleanly.

Fix: add a check such as if c.RandomDataBufferSizeBytes < 8 { return fmt.Errorf(...) } (or fold it into the existing RandomDataBufferSizeBytes >= RecordSizeBytes check via max(RecordSizeBytes, 8)).

Severity note: this is benchmark-only tooling (sei-db/seiwal/walsim), not a production/consensus path, and triggering it requires deliberately setting an unusually tiny buffer size (the shipped defaults are in the MB/GB range). The panic message is also already clear about the root cause. So while the bug is real and Validate() should be fixed to close this gap, it doesn't block any real workflow — marking as a nit.

// 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


if config.CleanDataOnStart {
fmt.Printf("CleanDataOnStart is enabled, removing contents of: %s\n", config.DataDir)
if err := removeContents(config.DataDir); err != 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.

removeContents(config.DataDir) runs before openWALStore acquires the seiwal directory lock as no path guardrail.

but it's totally fine if we don't have a scenario that we need to run two walsim instance in parallel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants