Files
go-kv/.omo/plans/fix-c6-segment-dir-fsync.md
dailz 0739966e55 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).
2026-06-15 14:25:47 +08:00

19 KiB
Raw Permalink Blame History

C6 修复方案:SegmentWriter 目录 fsync 失败必须硬错误

TL;DR

目标:修复 wal/segment_writer.go:86-89 静默吞掉目录 fsync 错误的 durability 漏洞。让 NewSegmentWriter 在目录 fsync 失败时返回错误,传播到 SegmentManager.AppendBatch → 触发 ErrCommitUnknown + write-stopped(或 DB.Open 失败)。

交付

  • 抽出 dirFsync helper 函数(独立可测)
  • NewSegmentWriter 调用 helper,失败时清理资源并返回错误
  • 用 package-level 变量支持测试注入失败
  • 新增 3 个测试:helper 单元测试 × 2 + NewSegmentWriter 集成测试 × 1
  • 单次 commit

预估工时1.25-1.75 小时 风险:低(单文件改动 + 测试,production 行为变化是"原本被吞的错误现在会冒出来",这正是设计意图)


Context

Bug 摘要(audit C6

wal/segment_writer.go:85-89

// Sync directory to make rename durable (best-effort on Linux).
if dirFD, derr := os.Open(dir); derr == nil {
    dirFD.Sync()    // 错误被完全忽略
    dirFD.Close()
}

两层错误都被吞:

  1. os.Open(dir) 失败 → 整个 fsync 被跳过
  2. dirFD.Sync() 失败 → 错误丢失

注释写"best-effort on Linux"误导,但设计明确这是硬条件。

设计依据(§3.2 line 248-273

新 segment durable-ready 协议第 5 步:

1. create segment-N.wal.tmp
2. write WAL File Header
3. fsync segment-N.wal.tmp
4. rename segment-N.wal.tmp → segment-N.wal
5. fsync WAL directory              ← 关键
6. segment-N enters durable-ready state
7. WAL writer may append batches whose recovery depends on segment-N

line 272

如果新 segment 进入 durable-ready 之前任一步失败,该 segment 不得成为 active segment,也不得承载可确认写入。如果此时尚未分配 sequence,可以重试创建或切换到其他 segment;如果 sequence 已分配或已有 WAL Batch 依赖该 segment,则按 WAL write failure 处理,引擎进入 write-stopped 状态

为什么是 Always 模式最严重的 durability 漏洞

Linux rename 是内存中的原子操作,不保证掉电后目录项可恢复。要让 rename 落盘必须 fsync 目录本身。掉电场景下:

  • rename 已执行但目录 fsync 没成功 → 重启后 segment 文件可能从目录里消失
  • 但 WAL writer 已经按 "Always 不丢已确认写入" 向调用方返回成功

这个 bug 平时不发作(正常环境下 rename + 异步 write-back 都会成功),只在掉电那一刻有概率中招。是最阴险的类型。

错误传播路径(已正确,只需源头报错)

NewSegmentWriter 失败(dir fsync error
├─ 在 NewSegmentManager 路径(DB.Open 首次创建 segment
│   → NewSegmentManager 失败 → DB.Open 失败
│   → 用户看到 error,没数据承诺被违反 ✓
└─ 在 SegmentManager.rotate 路径(segment 满了轮转)
    → AppendBatch 失败 → WalWriter.stopWithError(ErrCommitUnknown)
    → write-stopped ✓(符合设计 line 272

segment_manager.go:35-37, 81-83 已经透传 error,本次 fix 不需要改它。


执行计划

Phase A:代码改动(30 分钟)

A.1 抽出 dirFsync helper

新文件 wal/dir_fsync.go(或加入现有 segment_writer.go 末尾):

Momus 修订(bg_90bcf359helper 应该校验 path 是目录,否则传文件路径会"成功 fsync 了一个文件"但语义错误。加 os.Stat + IsDir 检查。

Oracle 修订(bg_48d53274:进一步用 os.Open → f.Stat → IsDir → f.Sync 顺序,消除 os.Stat + os.Open 之间的 TOCTOU 窗口。

package wal

import (
	"fmt"
	"os"
)

// dirFsync opens the directory and fsyncs it. Required for durable-ready
// state per design §3.2 line 258. This is a hard requirement, not
// best-effort: rename is atomic in memory but not guaranteed to survive
// power loss without a directory fsync.
//
// dirFsyncFn is a package-level variable so tests can inject failures
// without an interface indirection in production code.
//
// NOT PARALLEL-SAFE: tests that override dirFsyncFn must not use t.Parallel().
// All existing wal tests run serially within the package.
var dirFsyncFn = dirFsync

func dirFsync(dir string) error {
	f, err := os.Open(dir)
	if err != nil {
		return fmt.Errorf("open dir %q: %w", dir, err)
	}
	defer f.Close()
	info, err := f.Stat()
	if err != nil {
		return fmt.Errorf("stat dir %q: %w", dir, err)
	}
	if !info.IsDir() {
		return fmt.Errorf("path %q is not a directory", dir)
	}
	if err := f.Sync(); err != nil {
		return fmt.Errorf("fsync dir %q: %w", dir, err)
	}
	return nil
}

关于 hook 触发的注释:上面 docstring 是必要的 —— 它解释了为什么这是硬条件而不是 best-effort(防止未来工程师把它"简化"成 silent again)。属于 security-related 注释,符合 hook 规则的 necessary comments。

A.2 修改 NewSegmentWriter

替换 wal/segment_writer.go:85-89

// 改前(C6 bug):
// Sync directory to make rename durable (best-effort on Linux).
if dirFD, derr := os.Open(dir); derr == nil {
    dirFD.Sync()
    dirFD.Close()
}

// 改后:
// Per design §3.2 line 258, directory fsync is a hard requirement for
// durable-ready. Without it, the rename above is not guaranteed to survive
// power loss, violating the Always-mode "no loss of acknowledged writes"
// promise.
if err := dirFsyncFn(dir); err != nil {
    closeErr := fd.Close()
    removeErr := os.Remove(finalPath)
    if closeErr != nil || removeErr != nil {
        // Surface cleanup errors alongside the primary fsync error so they
        // are not lost (Go 1.20+ errors.Join).
        cleanup := errors.Join(closeErr, removeErr)
        return nil, fmt.Errorf("wal: fsync directory after segment rename (cleanup: %v): %w", cleanup, err)
    }
    return nil, fmt.Errorf("wal: fsync directory after segment rename: %w", err)
}

清理规则:失败时关闭新打开的 fd,删除已 rename 出来的 finalPath,让磁盘状态回到"没有 segment-N"。如果清理本身也失败,用 errors.Join 把清理错误和主错误一起返回(不丢任何信息)。

关于 hook 触发的注释:这条注释引用设计行号 + 解释 failure 处理逻辑,防止未来"简化"成 silent。属于 security-related 注释。

Phase B:测试(45-60 分钟)

B.1 helper 单元测试

新增 wal/dir_fsync_test.go

package wal

import (
	"os"
	"strings"
	"testing"
)

func TestDirFsyncSuccess(t *testing.T) {
	dir := t.TempDir()
	if err := dirFsync(dir); err != nil {
		t.Errorf("dirFsync on valid dir: %v", err)
	}
}

func TestDirFsyncNonExistentDir(t *testing.T) {
	err := dirFsync("/nonexistent/path/that/should/not/exist")
	if err == nil {
		t.Fatal("expected error on non-existent dir")
	}
	// After Oracle修订 (Open first), non-existent dir fails at os.Open.
	if !strings.Contains(err.Error(), "open dir") {
		t.Errorf("error should mention 'open dir', got: %v", err)
	}
}

func TestDirFsyncNotADirectory(t *testing.T) {
	dir := t.TempDir()
	filePath := dir + "/notadir"
	if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	err := dirFsync(filePath)
	if err == nil {
		t.Fatal("expected error when fsyncing a file as dir")
	}
	if !strings.Contains(err.Error(), "not a directory") {
		t.Errorf("error should mention 'not a directory', got: %v", err)
	}
}

B.2 NewSegmentWriter 集成测试(注入失败)

新增到 wal/segment_writer_test.go

// 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.
func TestNewSegmentWriterDirFsyncFailure(t *testing.T) {
	dir := t.TempDir()
	cfg := config.Defaults()

	// Inject dir fsync failure.
	orig := dirFsyncFn
	dirFsyncFn = func(d 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)
	}

	// Verify cleanup: no segment file should remain.
	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()

	// Verify segment file exists.
	if _, err := os.Stat(sw.SegmentPath()); err != nil {
		t.Errorf("segment file should exist: %v", err)
	}
}

B.3 验证错误传播

wal/segment_manager_test.go 已经测过 NewSegmentManager 正常路径。需补充测试验证错误传播。

Oracle 修订(bg_48d53274:原计划的 _, err = sm.AppendBatch(encoded) 是编译错误(AppendBatch 只返回 error)。另外,单个 {k,v} batch 编码只有 ~24 字节,远不到 512-32=480 阈值,不会触发轮转。需要循环填到 sm.RemainingPayload() < worstCaseSize 再注入失败。

// Regression guard for C6: SegmentManager.AppendBatch must propagate
// rotation failure (which now includes dir fsync failure) as error.
func TestSegmentManagerRotateFailsOnDirFsyncFailure(t *testing.T) {
	dir := t.TempDir()
	cfg := tinyWalConfig()

	sm, err := NewSegmentManager(dir, 0, 0, cfg)
	if err != nil {
		t.Fatalf("NewSegmentManager: %v", err)
	}
	defer sm.Close()

	encoded, err := EncodeWalBatch(0, []*WalEntry{
		{OpType: OpPut, ValueKind: VKInline, Key: []byte("k"), Value: []byte("v")},
	})
	if err != nil {
		t.Fatalf("EncodeWalBatch: %v", err)
	}

	// Fill the active segment until next AppendBatch would trigger rotation.
	// segment_manager.go:62 triggers rotate when
	//   RemainingPayload() < len(encoded) + 2*PhysicalRecordHeaderSize
	worstCaseSize := uint64(len(encoded)) + 2*uint64(PhysicalRecordHeaderSize)
	for sm.RemainingPayload() >= worstCaseSize {
		if err := sm.AppendBatch(encoded); err != nil {
			t.Fatalf("fill AppendBatch: %v", err)
		}
	}

	// Inject dir fsync failure for the next segment creation.
	orig := dirFsyncFn
	dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
	t.Cleanup(func() { dirFsyncFn = orig })

	// Next AppendBatch must trigger rotation and fail with propagated error.
	if err := sm.AppendBatch(encoded); err == nil {
		t.Fatal("AppendBatch: expected rotation failure, got nil")
	}
}

注意:C8 的存在让"填满 segment 触发轮转"的实际行为不可预测(segment_manager.go:64-66 把字节偏移当 startSequence)。这个测试主要验证 dir fsync 失败的传播路径,不验证轮转后的 recovery。如果测试在 C8 修复前运行不稳定,可放宽为"只需证明错误被传播",不验证后续 recovery。

B.4 验证 NewSegmentManager 路径 + 清理(Oracle 新增)

Oracle 修订(bg_48d53274:原计划只覆盖了 NewSegmentWriter 单元层。需要单独覆盖 NewSegmentManager 包装路径(segment_manager.go:35-37),以及验证 dir fsync 失败后 retry 能成功(清理干净)。

// Regression guard for C6: NewSegmentManager must propagate dir fsync
// failure from initial segment creation. This is the DB.Open failure path.
func TestNewSegmentManagerFailsOnDirFsyncFailure(t *testing.T) {
	dir := t.TempDir()
	cfg := tinyWalConfig()

	orig := dirFsyncFn
	dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
	t.Cleanup(func() { dirFsyncFn = orig })

	sm, err := NewSegmentManager(dir, 0, 0, cfg)
	if err == nil {
		if sm != nil {
			sm.Close()
		}
		t.Fatal("NewSegmentManager: expected error on dir fsync failure, got nil")
	}
	if !strings.Contains(err.Error(), "create initial segment") {
		t.Errorf("error should be wrapped as 'create initial segment', got: %v", err)
	}
}

// Regression guard for C6: after a failed NewSegmentWriter due to dir
// fsync, retrying with fsync restored must succeed and not leak state
// (no leftover segment files).
func TestNewSegmentWriterRetryAfterDirFsyncFailure(t *testing.T) {
	dir := t.TempDir()
	cfg := config.Defaults()

	// First attempt: inject failure.
	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

	// Second attempt: must succeed (cleanup was effective).
	sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
	if err != nil {
		t.Fatalf("retry NewSegmentWriter: %v", err)
	}
	defer sw.Close()

	// Verify no leftover .tmp files from the failed attempt.
	entries, err := os.ReadDir(dir)
	if err != nil {
		t.Fatalf("ReadDir: %v", err)
	}
	for _, e := range entries {
		if strings.HasSuffix(e.Name(), ".tmp") {
			t.Errorf("leftover .tmp file: %s", e.Name())
		}
		// segment-0.wal should exist (from successful retry).
	}
}

Phase C:验证(15 分钟)

# 1. 编译通过
go build ./...

# 2. 重点测试(C6 新增的全部)
go test ./wal -run 'TestDirFsync|TestNewSegmentWriter|TestSegmentManagerRotateFailsOnDirFsyncFailure|TestNewSegmentManagerFailsOnDirFsyncFailure' -count=1 -v

# 3. wal 包全量
go test ./wal/... -count=1

# 4. 全仓
go test ./... -count=1

# 5. race 全仓(Oracle 修订:不只 wal + root,跑 ./...
go test -race ./... -count=1

# 6. vet
go vet ./...

# 7. lint(如果装了)
if command -v golangci-lint >/dev/null 2>&1; then
    golangci-lint run ./wal/...
else
    echo "golangci-lint not installed, skipping"
fi

Phase DCommit message draft

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).

验收清单

  • Phase A.1dirFsync helper 存在,docstring 引用设计行号,注释 "NOT PARALLEL-SAFE"
  • Phase A.2NewSegmentWriter 调用 dirFsyncFn,失败时 errors.Join 合并 fd.Close + os.Remove 错误
  • Phase B.13 个 helper 单元测试存在,错误期望正确("open dir" / "not a directory"
  • Phase B.22 个 NewSegmentWriter 测试(failure + regression)存在
  • Phase B.3rotate 失败测试存在,编译正确,用循环填到 RemainingPayload() < worstCaseSize
  • Phase B.4NewSegmentManager 失败测试 + retry-after-failure 测试存在
  • go test ./wal/... -count=1 全绿
  • go test ./... -count=1 全绿
  • go test -race ./... -count=1 全绿
  • go vet ./... 无新增警告
  • 单次 commitmessage 引用 audit C6

不在本次范围内(后续 issue

编号 为什么不放进来
C8 segment_manager 把字节偏移当 startSequence,多 segment recovery 直接坏。和 C6 完全独立,但优先级也很高,可单独做
C4 涉及 RecoverFromSegments 接口变更(isLast 参数),改动面更大
C5 + H8 都在 truncation 路径,应一起做(fsync + 删空 segment + dir fsync + findValidOffset batch 边界)
C1 CRC 多项式错,跨多个文件,需要测试向量
C7 Put/Close 并发竞态,独立
H1-H7 其他 High 级别问题

修订记录

  • v1(原始):C6 修复方案初稿,送 Momus 审
  • v1.1Momus 修订 bg_90bcf359
    • helper 加 os.Stat + IsDir 校验,避免传文件路径误"成功"
    • rotate 测试改用现成的 tinyWalConfig() helper(同时减 BlockSize + MaxBatchSize + MaxSegmentSize),避免单独设小 segment 触发 config.Validate 失败
  • v1.2Oracle 修订 bg_48d53274
    • BLOCKINGhelper 改为 os.Open → f.Stat → IsDir → f.Sync 顺序,消除 TOCTOU 窗口;同步修正 TestDirFsyncNonExistentDir 错误期望("open dir" 而非 "stat dir"
    • BLOCKINGTestDirFsyncNotADirectoryos import,补齐
    • BLOCKINGTestSegmentManagerRotateFailsOnDirFsyncFailure 编译错(_, err = 应为 err =);单个 batch 不会触发轮转,改为循环填到 RemainingPayload() < worstCaseSize
    • NEW:加 TestNewSegmentManagerFailsOnDirFsyncFailure(覆盖 NewSegmentManager→DB.Open 失败路径)
    • NEW:加 TestNewSegmentWriterRetryAfterDirFsyncFailure(验证清理后 retry 能成功)
    • MINORA.2 cleanup 用 errors.Join 把 close/remove 错误和主错误合并,不丢任何信息
    • MINORdirFsyncFn 旁注释 "NOT PARALLEL-SAFE"
    • MINORrace 改成 go test -race ./...(全仓,不只 wal + root