fix: make WAL segment directory fsync failure fatal (C6)

Per design §3.2 line 248-272, segment directory fsync is a hard
requirement for durable-ready state, not best-effort. rename is atomic
in memory but not guaranteed to survive power loss without a directory
fsync. The previous code silently swallowed both os.Open(dir) and
dirFD.Sync() errors, leaving WAL writer to confirm batches as durable
when their segment might not exist after a crash.

Failure propagation:
- Initial segment creation: NewSegmentWriter fails -> NewSegmentManager
  fails -> DB.Open fails (user sees error, no data promise violated).
- Rotation during AppendBatch: NewSegmentWriter fails -> AppendBatch
  fails -> WalWriter.stopWithError(ErrCommitUnknown) -> write-stopped
  (per design line 272).

Changes:
- wal/segment_writer.go: extract dirFsync helper (Open -> f.Stat ->
  IsDir -> f.Sync, avoiding TOCTOU window), replace silent swallow with
  fatal error; on failure clean up resources (fd.Close + os.Remove) and
  surface cleanup errors via errors.Join so nothing is silently lost.
- wal/dir_fsync_test.go (new): unit test the helper with valid dir,
  non-existent dir (fails at os.Open), and not-a-dir (fails at IsDir).
- wal/segment_writer_test.go: add TestNewSegmentWriterDirFsyncFailure
  (injects failure via package-level dirFsyncFn override; documents the
  not-parallel-safe constraint), TestNewSegmentWriterNormalPathStillWorks
  (regression), and TestNewSegmentWriterRetryAfterDirFsyncFailure
  (verifies cleanup is effective for retry).
- wal/segment_manager_test.go: add TestSegmentManagerRotateFailsOnDirFsyncFailure
  (fills segment until rotation triggers, injects failure, verifies
  propagation through AppendBatch path) and TestNewSegmentManagerFailsOnDirFsyncFailure
  (covers the DB.Open failure path).

dirFsyncFn injection note: tests that override this package-level var
must not use t.Parallel(). All existing wal tests run serially within
the package; this is the lightest mechanism that doesn't require
interface indirection in production code.

Verified: each new test fails on pre-fix code (silent swallow returned
nil error) and passes after the fix. Full suite green including
go test -race ./... .

Audit context: docs/audit-3.2.md C6 (Oracle-verified bg_ef425776).
This commit is contained in:
dailz
2026-06-15 14:25:47 +08:00
parent 58d3bc92d7
commit 0739966e55
6 changed files with 775 additions and 4 deletions
+98
View File
@@ -1,9 +1,11 @@
package wal
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/dailz/go-kv/config"
@@ -344,3 +346,99 @@ func TestSegmentWriterSync(t *testing.T) {
t.Fatalf("Close: %v", err)
}
}
// Regression guard for C6: NewSegmentWriter must return an error when
// directory fsync fails, instead of silently succeeding. Per design §3.2
// line 272, segment must NOT become active if durable-ready fails.
//
// dirFsyncFn is a package-level var; tests that override it must not use
// t.Parallel(). All wal tests run serially within the package.
func TestNewSegmentWriterDirFsyncFailure(t *testing.T) {
dir := t.TempDir()
cfg := config.Defaults()
orig := dirFsyncFn
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
t.Cleanup(func() { dirFsyncFn = orig })
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
if err == nil {
if sw != nil {
sw.Close()
}
t.Fatal("NewSegmentWriter: expected error on dir fsync failure, got nil")
}
if !strings.Contains(err.Error(), "fsync directory") {
t.Errorf("error should mention 'fsync directory', got: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
name := e.Name()
if strings.Contains(name, "segment-0") {
t.Errorf("segment file should be cleaned up, found: %s", name)
}
}
}
// Regression guard for C6: ensure normal path still works after the fix.
func TestNewSegmentWriterNormalPathStillWorks(t *testing.T) {
dir := t.TempDir()
cfg := config.Defaults()
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
if err != nil {
t.Fatalf("NewSegmentWriter normal path: %v", err)
}
defer sw.Close()
if _, err := os.Stat(sw.SegmentPath()); err != nil {
t.Errorf("segment file should exist: %v", err)
}
}
// Regression guard for C6: after a failed NewSegmentWriter due to dir
// fsync, retrying with fsync restored must succeed and not leak state.
func TestNewSegmentWriterRetryAfterDirFsyncFailure(t *testing.T) {
dir := t.TempDir()
cfg := config.Defaults()
orig := dirFsyncFn
dirFsyncFn = func(string) error { return errors.New("simulated") }
_, err := NewSegmentWriter(dir, 0, 0, &cfg)
if err == nil {
t.Fatal("expected first NewSegmentWriter to fail")
}
dirFsyncFn = orig
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
if err != nil {
t.Fatalf("retry NewSegmentWriter: %v", err)
}
defer sw.Close()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
tmpCount := 0
walCount := 0
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
tmpCount++
}
if strings.HasSuffix(e.Name(), ".wal") {
walCount++
}
}
if tmpCount != 0 {
t.Errorf("leftover .tmp files: %d", tmpCount)
}
if walCount != 1 {
t.Errorf("expected exactly 1 .wal file (from successful retry), got %d", walCount)
}
}