Files
go-kv/.omo/plans/fix-c7-put-close-race.md
T
dailz 408138b3c8 fix: prevent send-on-closed-channel panic in concurrent Put/Delete + Close (C7)
Put/Delete checked writeStopped then called queue.Submit (which sends on
a channel). Close set writeStopped then closed the channel. With no
synchronization between the check and Submit, a concurrent Close could
close the channel during the window, causing "send on closed channel"
panic in Put/Delete.

The CommitQueue.Close comment said "Callers must stop submitting before
Close" but Put/Delete didn't enforce this.

Changes:
- wal/writer.go: add submitMu sync.RWMutex. Put/Delete construct entry
  outside the lock, then hold RLock during writeStopped check + Submit;
  release before <-req.Result. Close holds write lock during
  writeStopped.Store + queue.Close, guaranteeing no Submit is in progress
  when the channel is closed.
- wal/writer_test.go: add TestConcurrentPutCloseNoPanic and
  TestConcurrentDeleteCloseNoPanic. 100 goroutines + 1 closer, recover
  panics. Run with -count=50 for regression detection.

Verified: all existing tests pass. New concurrent tests pass with
-count=50 (0 panics). go test -race ./... green.

Audit context: docs/audit-3.2.md C7.
2026-06-18 13:25:30 +08:00

10 KiB
Raw Blame History

C7 修复方案:Put/Delete vs Close 并发竞态

TL;DR

目标:消除 Put/Delete 检查 writeStoppedSubmit 之间的时间窗,防止 Close 并发关闭 channel 时 send-on-closed panic。

交付

  • WalWritersubmitMu sync.RWMutex
  • Put/Delete 持读锁保护 check+Submit 临界区
  • Close 持写锁保护 writeStopped+queue.Close
  • 2 个并发测试(Put+Close、Delete+Close
  • 单次 commit

预估工时1.5-2 小时 风险:低。改动局限在 WalWriter 一个文件,3 个函数


Context

Bug 摘要

wal/writer.go:313-326 PutDelete 同理 328-341):

func (ww *WalWriter) Put(key, value []byte) error {
    if ww.writeStopped.Load() {        // ① 检查
        return errkit.ErrWriteStopped
    }
    req := ww.queue.Submit(...)        // ② Submit(内部 `cq.ch <- req`
    result := <-req.Result
    return result.Err
}

wal/writer.go:369-382 Close

func (ww *WalWriter) Close() error {
    ww.closeOnce.Do(func() {
        ww.writeStopped.Store(true)    // A 设标志
        ww.queue.Close()               // B 关 channel
        close(ww.done)
        ww.wg.Wait()
        ...
    })
}

wal/commit_queue.go:30-37 Submit

func (cq *CommitQueue) Submit(entries []*WalEntry) *CommitRequest {
    req := &CommitRequest{...}
    cq.ch <- req                       // ← send on possibly-closed channel
    return req
}

CommitQueue.Close 的注释自己写着 "Callers must stop submitting before Close" —— 但 Put/Delete 并没有保证这一点。

竞态场景

Goroutine 1 (Put)               Goroutine 2 (Close)
────────────────                ────────────────────
writeStopped.Load() = false
                                writeStopped.Store(true)
                                queue.Close() → close(cq.ch)
queue.Submit():
  cq.ch <- req  ← PANIC
                 "send on closed channel"

时间窗:Put 的 ①→② 之间,Close 的 A→B 发生。

为什么没被抓到

现有测试串行调用 Put → Close,不模拟并发。要抓 C7 需要:

  • 多 goroutine 并发 Put + 1 goroutine Close
  • defer recover 捕获 panic
  • 高并发 + 多次运行提高触发概率

属于"低概率高破坏"型 —— 平时不发作,发作就 panic 整个进程。


执行计划

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

A.1 加 submitMu sync.RWMutex 到 WalWriter

type WalWriter struct {
    ...
    writeStopped atomic.Bool
    submitMu     sync.RWMutex  // C7 fix: 保护 Submit vs Close 临界区
    done         chan struct{}
    ...
}

A.2 修改 Put

// 改后:
func (ww *WalWriter) Put(key, value []byte) error {
    // Construct entry outside the lock to minimize critical section
    // (Oracle nice-to-have: reduces contention under high concurrency).
    entry := &WalEntry{
        OpType:    OpPut,
        ValueKind: VKInline,
        Key:       cloneBytes(key),
        Value:     cloneBytes(value),
    }

    // C7 fix: RLock 保护 check + Submit 临界区。Close 持写锁时,
    // 所有 RLock 释放后才能 queue.Close,保证 Submit 不会 send on closed channel。
    ww.submitMu.RLock()
    if ww.writeStopped.Load() {
        ww.submitMu.RUnlock()
        return errkit.ErrWriteStopped
    }
    req := ww.queue.Submit([]*WalEntry{entry})
    ww.submitMu.RUnlock()

    result := <-req.Result
    return result.Err
}

关键RLock 在 Submit 之后、<-req.Result 之前释放。这样:

  • Submit 在锁保护内(防止 Close 并发关 channel
  • <-req.Result 在锁外(不阻塞其他 Put,不阻塞 Close 拿写锁)

A.3 修改 Delete

同 Put 模式。

A.4 修改 Close

// 改后:
func (ww *WalWriter) Close() error {
    ww.closeOnce.Do(func() {
        // C7 fix: 写锁等待所有 Put/Delete 的 RLock 释放。
        // 持锁期间关 channel 是安全的:没有 Submit 在进行中。
        ww.submitMu.Lock()
        ww.writeStopped.Store(true)
        ww.queue.Close()
        ww.submitMu.Unlock()

        close(ww.done)
        ww.wg.Wait()
        if err := ww.segManager.Sync(); err != nil {
            ww.closeErr = err
            return
        }
        ww.closeErr = ww.segManager.Close()
    })
    return ww.closeErr
}

关键顺序

  1. 写锁(等所有 in-flight Submit 完成)
  2. writeStopped = true(新 Put 看到后返回错误)
  3. queue.Close(此刻无 Submit 在进行,安全)
  4. 写锁释放
  5. close(done) + wg.WaitrunLoop 退出)

死锁分析

场景 分析
Put 持 RLock + Submit 阻塞(channel 满) runLoop 持续消费(不持任何锁),Submit 最终完成,Put 释放 RLock
Close 等写锁 等 RLock 释放;RLock 释放依赖 Submit 完成;Submit 完成依赖 runLoop 消费;runLoop 不受锁影响 → 最终完成
processBatch 失败后 writeStopped=true 不影响 runLoop 继续消费 channel,不持任何锁,无死锁

无死锁。Close 可能因 Submit 阻塞而延迟(等 I/O),但最终完成。这是 graceful shutdown 的正确语义 —— 保留 backpressure,不创建锁环

Phase B:测试(45 分钟)

B.1 TestConcurrentPutCloseNoPanic

新增到 wal/writer_test.go

// Regression guard for C7: concurrent Put + Close must not panic.
// Buggy code has a race window between writeStopped check and Submit's
// send on channel; Close closing the channel during that window causes
// "send on closed channel" panic.
func TestConcurrentPutCloseNoPanic(t *testing.T) {
    dir := t.TempDir()
    cfg := config.Defaults()
    ww, err := NewWalWriter(&cfg, dir, 0, 0)
    if err != nil {
        t.Fatal(err)
    }

    const goroutines = 100
    var wg sync.WaitGroup
    var panicCount atomic.Int32
    start := make(chan struct{})

    for i := 0; i < goroutines; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            defer func() {
                if r := recover(); r != nil {
                    panicCount.Add(1)
                }
            }()
            <-start
            _ = ww.Put([]byte("k"), []byte("v"))
        }()
    }

    wg.Add(1)
    go func() {
        defer wg.Done()
        <-start
        _ = ww.Close()
    }()

    close(start)
    wg.Wait()

    if panicCount.Load() > 0 {
        t.Fatalf("concurrent Put + Close caused %d panic(s) — C7 regression",
            panicCount.Load())
    }
}

B.2 TestConcurrentDeleteCloseNoPanic

同 B.1 但调 ww.Delete([]byte("k"))

B.3 测试稳定性说明

Oracle 修订(bg_96db47f5:测试定位是"fix 后保证不 panic",不是"fix 前必 panic"。buggy 代码的 race 窗口非常窄(Put check → Submit 之间几条指令),默认 queue capacity=10000100 goroutines 通常不足以触发 channel 满阻塞。-count=50 能提高触发概率但不保证每次都抓到。

建议用 go test -race ./wal -run TestConcurrent -count=10 作为可选补充。确定性测试需要在 check 和 Submit 之间注入 test seam(过度侵入性,Phase 1 不值得)。

Phase C:验证(15 分钟)

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

# 2. 并发测试(单次)
go test ./wal -run 'TestConcurrent' -count=1 -v

# 3. 并发测试(多次,抓 flaky race)
go test ./wal -run 'TestConcurrent' -count=50

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

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

# 6. race
go test -race ./... -count=1

# 7. vet
go vet ./...

Phase DCommit message draft

fix: prevent send-on-closed-channel panic in concurrent Put/Delete + Close (C7)

Put/Delete checked writeStopped then called queue.Submit (which sends on
a channel). Close set writeStopped then closed the channel. With no
synchronization between the check and Submit, a concurrent Close could
close the channel during the window, causing "send on closed channel"
panic in Put/Delete.

The CommitQueue.Close comment said "Callers must stop submitting before
Close" but Put/Delete didn't enforce this.

Changes:
- wal/writer.go: add submitMu sync.RWMutex. Put/Delete hold RLock during
  writeStopped check + Submit; release before <-req.Result. Close holds
  write lock during writeStopped.Store + queue.Close, guaranteeing no
  Submit is in progress when the channel is closed.
- wal/writer_test.go: add TestConcurrentPutCloseNoPanic and
  TestConcurrentDeleteCloseNoPanic. 100 goroutines + 1 closer, recover
  panics. On buggy code, this test triggers "send on closed channel"
  panic (may require -count=N for reliable reproduction due to narrow
  race window). On fixed code, never panics.

Verified: all existing tests pass. New concurrent tests pass with
-count=50. go test -race ./... green.

Audit context: docs/audit-3.2.md C7.

验收清单

  • Phase A.1submitMu sync.RWMutex 字段存在
  • Phase A.2Put 在 RLock 内 check + SubmitRUnlock 后 <-req.Result
  • Phase A.3Delete 同 Put 模式
  • Phase A.4Close 在写锁内 writeStopped + queue.Close
  • Phase B.1TestConcurrentPutCloseNoPanic 存在
  • Phase B.2TestConcurrentDeleteCloseNoPanic 存在
  • go test ./wal/... -count=1 全绿
  • go test ./wal -run TestConcurrent -count=50 全绿
  • go test -race ./... -count=1 全绿
  • go vet ./... 无新增警告
  • 单次 commitmessage 引用 audit C7

不在本次范围内

为什么不放进来
CommitQueue API 改动 不需要。RWMutex 在 WalWriter 层就够
DB 层并发保护 不需要。WalWriter 的保护对 DB 层透明生效(db.Put 调 writer.Putwriter 的 submitMu 已经保护)
processBatch 失败后的 channel 状态 不影响。writeStopped 后 runLoop 继续消费,不关 channel

修订记录

  • v1(原始):C7 修复方案初稿,送 Momus 审
  • v1.0Momus 审核 bg_66fea405[OKAY],无 blocking
  • v1.1Oracle 审核 bg_96db47f5approve(无 blocking)。3 个 nice-to-have 已采纳:
    • 缩小临界区:WalEntry 构造移到 RLock 之前
    • 死锁分析补充"保留 backpressure,不创建锁环"
    • 测试定位澄清:是"fix 后保证不 panic",不是"fix 前必 panic"