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).
19 KiB
C6 修复方案:SegmentWriter 目录 fsync 失败必须硬错误
TL;DR
目标:修复
wal/segment_writer.go:86-89静默吞掉目录 fsync 错误的 durability 漏洞。让NewSegmentWriter在目录 fsync 失败时返回错误,传播到SegmentManager.AppendBatch→ 触发ErrCommitUnknown+ write-stopped(或DB.Open失败)。交付:
- 抽出
dirFsynchelper 函数(独立可测)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()
}
两层错误都被吞:
os.Open(dir)失败 → 整个 fsync 被跳过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_90bcf359):helper 应该校验 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 D:Commit 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.1:
dirFsynchelper 存在,docstring 引用设计行号,注释 "NOT PARALLEL-SAFE" - Phase A.2:
NewSegmentWriter调用dirFsyncFn,失败时errors.Join合并 fd.Close + os.Remove 错误 - Phase B.1:3 个 helper 单元测试存在,错误期望正确("open dir" / "not a directory")
- Phase B.2:2 个 NewSegmentWriter 测试(failure + regression)存在
- Phase B.3:rotate 失败测试存在,编译正确,用循环填到
RemainingPayload() < worstCaseSize - Phase B.4:NewSegmentManager 失败测试 + retry-after-failure 测试存在
go test ./wal/... -count=1全绿go test ./... -count=1全绿go test -race ./... -count=1全绿go vet ./...无新增警告- 单次 commit,message 引用 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.1(Momus 修订 bg_90bcf359):
- helper 加
os.Stat+IsDir校验,避免传文件路径误"成功" - rotate 测试改用现成的
tinyWalConfig()helper(同时减 BlockSize + MaxBatchSize + MaxSegmentSize),避免单独设小 segment 触发 config.Validate 失败
- helper 加
- v1.2(Oracle 修订 bg_48d53274):
- BLOCKING:helper 改为
os.Open → f.Stat → IsDir → f.Sync顺序,消除 TOCTOU 窗口;同步修正TestDirFsyncNonExistentDir错误期望("open dir" 而非 "stat dir") - BLOCKING:
TestDirFsyncNotADirectory缺osimport,补齐 - BLOCKING:
TestSegmentManagerRotateFailsOnDirFsyncFailure编译错(_, err =应为err =);单个 batch 不会触发轮转,改为循环填到RemainingPayload() < worstCaseSize - NEW:加
TestNewSegmentManagerFailsOnDirFsyncFailure(覆盖 NewSegmentManager→DB.Open 失败路径) - NEW:加
TestNewSegmentWriterRetryAfterDirFsyncFailure(验证清理后 retry 能成功) - MINOR:A.2 cleanup 用
errors.Join把 close/remove 错误和主错误合并,不丢任何信息 - MINOR:
dirFsyncFn旁注释 "NOT PARALLEL-SAFE" - MINOR:race 改成
go test -race ./...(全仓,不只 wal + root)
- BLOCKING:helper 改为