You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 asyncfsp.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).
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.
Fix in PR #409 — per-id commitSeq fence + synchronous commit block (renameSync), snapshot fence captured beforebuild() 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.
Activity
ranxianglei commentedon Sep 23, 2026
[bot] 🏷 Triage 结论:问题确认成立,方案有调整,开始实现。
验证(代码路径级)
src/persist/store.ts:395writeInner在入口一次性build()快照 payload,随后走异步mkdir/writeFile/rename;而flushSync(store.ts:226)同步落盘、不 joinwriteChains。因此:timer 触发 → 在飞 writeNow 快照 v1 → 状态更新到 v2 →flushSync写 v2 并返回 true → 在飞 rename 随后落地 v1。「flushSync 成功返回 ⇒ 盘上为最新」这一不变量确实不成立。与 CI flaky 的表现吻合(竞态窗口宽、负载下偶发命中、重跑即过)。层次判断:根因是两件事叠加——① payload 快照发生在 I/O 之前;② 同步 flush 与在飞异步写无序。issue 的分析打到了核心。
方案评估(为什么不原样采用两个选项)
build()移到最内层)单独不够:「重新快照」与「异步 rename 落地」之间仍有缝隙,而 CI flake 命中的正是负载下慢 rename 那段窗口,选项 1 盖不住。flush)只救能 await 的调用方;flushSync的使用场景(内存驱逐、同步 shutdown)本来就不能 await,依赖该不变量的既有调用方仍然裸露。scheduleSave/writeNow/flushSync)各自 bump 该 id 的 epoch;在飞写在每次重试前和 commit 前复查 epoch——发现更新的请求到达就丢弃自己的 payload(持久化归更新的请求所有);最后一步 rename 改为fs.renameSync,保证「新鲜度检查」与「提交」之间无任何 JS(含 flushSync)可插入。这样对所有调用方闭合不变量,不需要新 API。实现顺序:先写确定性复现单测(红)→ 修复(绿)→ 全量测试 + typecheck + build → PR。完成后在此回帖 PR 链接。
ranxianglei commentedon Sep 23, 2026
[bot] 🏷 Fixed — PR ready: #374 (Closes #374's source issue: Closes #373; branch
2026-09-24_flushsync-inflight-ordering, commitdddf0a4).What was verified
The bug is real, at the code-path level (see my earlier triage comment):
writeInnersnapshots the payload once at chain entry (build()before any I/O), then commits via an asyncfsp.rename;flushSyncwrites synchronously but never joinswriteChains. 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)
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.writeInnerrebuilds 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.fs.renameSync): closes the remaining gap — nothing (includingflushSync) 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.flush) not needed: the epoch+sync-commit design guarantees the invariant for all callers, no new API surface.Evidence
flushSyncinside the widened window — including the exact debounced-write shape of the CI flake): red pre-fix, green post-fix.fsp.rename→fs.renameSyncto match the sync commit (kept their teeth).Behavior changes (old → new) — flagged per policy
flushSyncreturning true ⇒ disk holds exactly that payload — now actually guaranteed (old: broken; this is the bug)fsp.rename→ syncfs.renameSync(brief event-loop block on a metadata op; fails fast under locks, retried as before)Note: billion-context's workaround (ranxianglei/billion-context#1224, drain
flushAllbefore 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 通过;行为变更已在上表逐条披露,可以合并。
ranxianglei commentedon Sep 25, 2026
Fix in PR #409 — per-id
commitSeqfence + synchronous commit block (renameSync), snapshot fence captured beforebuild()so a re-enteringflushSync(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.Merge pull request #409 from ranxianglei/2026-09-25_flushsync-fence