Skip to content

StateStore: flushSync 不与 in-flight writeNow 排序,磁盘可残留旧快照(bounded staleness) #373

Closed

Description

@ranxianglei

来源: #1208 (ranxianglei/billion-context#1208) 分析 CI flaky 单测时发现

问题

persist 模块的 StateStore 中,flushSync(id, build) 只取消该 id 的 pending debounce timer 并同步落盘,但不 join 该 id 的 writeChains。若此前 debounce timer 已触发、一个 writeNow 正在飞行(其 payload 在 chain 入口经 build() 快照),那么:

  1. 状态更新到 v2(发生在 timer 触发之后、其异步 rename 完成之前);
  2. flushSync 同步写入 v2 并返回 true;
  3. 在飞的 writeNow 随后完成 rename —— 磁盘上留下的是 v1 旧快照。

即「flushSync 成功返回 ⇒ 磁盘持有最新状态」这一调用方依赖的不变量不成立。

复现(竞态窗口,CI 负载下实际命中)

billion-context tests/session-gc.test.ts "records rawInputTokens per turn and persists it"(debounceMs: 10):turn-1 的 debounce timer 在两轮请求之间触发 → 在飞 writeNow 快照了 turn-1 状态 → turn-2 更新 rawInputTokens → 测试 flushSync 后读盘,偶发读到旧值。windows-latest Node24 上两次 CI run 各失败一次(master@e8be19a1),重跑即过。billion-context 侧已用「断言前 await flushAll([]) 排空在飞写」规避(PR https://github.com/ranxianglei/billion-context/pull/1224)。

确定性单测思路:debounceMs≈10 的 store;改状态→scheduleSave;等 timer 触发(hasPending 转 false,writeNow 在飞);再改状态→立即 flushSync;在 I/O 可控/加压条件下断言文件内容,可稳定暴露旧值落地。

影响

低危、有界:内存态始终最新,下一次保存(debounce/shutdown flush)会自愈;无数据丢失。但任何依赖「flushSync 成功 ⇒ 盘上为最新」的路径(测试断言、flush 后立即 export、flushSync 与下次保存之间进程崩溃)会读到陈旧字节。

建议方向(二选一或组合)

  1. writeInner 把 build() 从 chain 入口移到最内层(writeFile/rename 之前)重建 payload —— 迟到的写在落地时携带最新状态,不改 API、不改同步语义;需确认 build() 无副作用且成本可接受。
  2. 新增异步 flush(id)(join 该 id 的 writeChain 后写),供需要严格顺序保证的调用方使用;flushSync 保持现状并在文档中注明不与在飞写排序。

跨仓改动按惯例留给人工决策,本 issue 仅跟踪。

Activity

  1. ranxianglei commented on Sep 23, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Triage 结论:问题确认成立,方案有调整,开始实现。

    验证(代码路径级)

    • src/persist/store.ts:395 writeInner 在入口一次性 build() 快照 payload,随后走异步 mkdir/writeFile/rename;而 flushSync(store.ts:226)同步落盘、不 join writeChains。因此:timer 触发 → 在飞 writeNow 快照 v1 → 状态更新到 v2 → flushSync 写 v2 并返回 true → 在飞 rename 随后落地 v1。「flushSync 成功返回 ⇒ 盘上为最新」这一不变量确实不成立。与 CI flaky 的表现吻合(竞态窗口宽、负载下偶发命中、重跑即过)。

    层次判断:根因是两件事叠加——① payload 快照发生在 I/O 之前;② 同步 flush 与在飞异步写无序。issue 的分析打到了核心。

    方案评估(为什么不原样采用两个选项)

    • 选项 1(把 build() 移到最内层)单独不够:「重新快照」与「异步 rename 落地」之间仍有缝隙,而 CI flake 命中的正是负载下慢 rename 那段窗口,选项 1 盖不住。
    • 选项 2(新增异步 flush)只救能 await 的调用方;flushSync 的使用场景(内存驱逐、同步 shutdown)本来就不能 await,依赖该不变量的既有调用方仍然裸露。
    • 采用:选项 1 + per-id save-request epoch + 同步 commit rename。三个入口(scheduleSave/writeNow/flushSync)各自 bump 该 id 的 epoch;在飞写在每次重试前和 commit 前复查 epoch——发现更新的请求到达就丢弃自己的 payload(持久化归更新的请求所有);最后一步 rename 改为 fs.renameSync,保证「新鲜度检查」与「提交」之间无任何 JS(含 flushSync)可插入。这样对所有调用方闭合不变量,不需要新 API。

    实现顺序:先写确定性复现单测(红)→ 修复(绿)→ 全量测试 + typecheck + build → PR。完成后在此回帖 PR 链接。

  2. ranxianglei commented on Sep 23, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Fixed — PR ready: #374 (Closes #374's source issue: Closes #373; branch 2026-09-24_flushsync-inflight-ordering, commit dddf0a4).

    What was verified

    The bug is real, at the code-path level (see my earlier triage comment): writeInner snapshots the payload once at chain entry (build() before any I/O), then commits via an async fsp.rename; flushSync writes synchronously but never joins writeChains. So an in-flight write can rename its stale snapshot after a successful sync flush. Matches the CI flake signature exactly (wide window, load-dependent, passes on rerun).

    Fix (option 1 + hardening; why neither option alone sufficed)

    • Per-id save-request epoch: bumped by scheduleSave / writeNow / flushSync. An in-flight write re-checks it before each attempt and after the temp write; if superseded it drops its payload (no spill, no failure alert) — the newer request owns persistence.
    • Late rebuild: writeInner rebuilds the payload per attempt, so a retry resuming after backoff carries fresh state. This is issue option 1 — but option 1 alone leaves the gap between re-snapshot and the async rename landing, which is precisely the slow-rename window the CI flake hit.
    • Synchronous commit rename (fs.renameSync): closes the remaining gap — nothing (including flushSync) can interleave between the freshness check and the landing. rename is a fast metadata op that fails quickly (not slowly) under hostile locks, so the existing transient-retry loop covers it unchanged.
    • Option 2 (new async flush) not needed: the epoch+sync-commit design guarantees the invariant for all callers, no new API surface.

    Evidence

    • Two deterministic repros (gate-mocked in-flight write; mutation + flushSync inside the widened window — including the exact debounced-write shape of the CI flake): red pre-fix, green post-fix.
    • Failure-injection mocks retargeted fsp.rename → fs.renameSync to match the sync commit (kept their teeth).
    • Full suite 1021/1021 green; typecheck / build / format:check clean.

    Behavior changes (old → new) — flagged per policy

    # Change
    a In-flight async writes may resolve without writing when a newer save request supersedes them mid-flight (old: always landed, possibly stale)
    b flushSync returning true ⇒ disk holds exactly that payload — now actually guaranteed (old: broken; this is the bug)
    c Canonical commit rename: async fsp.rename → sync fs.renameSync (brief event-loop block on a metadata op; fails fast under locks, retried as before)
    d State builders may run more than once under retries (old: exactly once); they must remain side-effect free

    Note: billion-context's workaround (ranxianglei/billion-context#1224, drain flushAll before asserts) stays valid and can be dropped once this kernel version ships — cross-repo sequencing is yours to decide.

    中文摘要:修复了 StateStore 中 flushSync 不与在飞异步写排序、磁盘可残留旧快照的问题(根因是 payload 提前快照 + 异步 rename 落地,二者叠加);方案为 per-id 保存请求 epoch + 每次重试重建 payload + 同步 commit rename,两个确定性单测红转绿、全量 1021 通过;行为变更已在上表逐条披露,可以合并。

  3. ranxianglei commented on Sep 25, 2026

    @ranxianglei
    OwnerAuthor

    Fix in PR #409 — per-id commitSeq fence + synchronous commit block (renameSync), snapshot fence captured before build() so a re-entering flushSync (from inside the builder) bumps the fence and the stale async payload aborts its commit instead of clobbering. Mutation-verified: the 3 new repro cases fail on master, pass on the fix; full suite 1050/1050 green.

  4. added a commit that references this issue on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions