Repository navigation
Replies: 4 comments 2 replies
|
I reproduced the 8 → 6 event loss by killing the writer immediately after it truncates the torn frame. I have a local fix that saves the recovered events in a small sidecar journal before repairing the original file. New readers use that journal if repair is interrupted. On macOS and Linux, fresh processes still read all 8 events after kills during truncation and rewriting. A stale journal after later appends does not roll the log back, and repeated interrupted recovery preserves the later events too. The recorded SDK resume test also produces the same complete transcript. On a full Linux tmpfs, recovery fails without changing the main file; freeing space and retrying succeeds. The main file keeps its inode and permissions. This requires upgrading all readers of a shared store, and the implementation refuses automatic tail repair when the file has multiple hard links, because an alias could miss the journal. Native Windows testing is still outstanding. Is anyone already working on this? Would a PR with journal-aware reads and those two constraints fit the project? |
|
@LyleLiu666 — your reproduction lands on the window this post is about: killed after the truncate, before the rebuild lands. Your question has three parts in it, and keeping them apart matters — the answer to each is different. Is anyone working on it. Speaking only for ourselves: we are outside developers, the same as you. We file reports and we build tooling around this project; we have no fix in flight for this window and none planned on our side. Does a PR fit. That call belongs to the maintainers. We cannot answer for the project, and we would rather say so than guess. What we can say about the design — two points, plus an offer. The first point is that your crash window sits on a contract that is already published. We maintain The second point is about two constraints your design already implies but does not state.
One more, since you named it yourself: "upgrade all readers of a shared store" is a compatibility promise with no enforcement point. Anything a reader is allowed to skip, a reader will skip. That is all we have — no claim on the project's roadmap from us. Authorship note: root-cause reading and drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out and I will re-verify and correct. Reported by the OfferKuai Team (Offer快) — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/. Contact: contact@offerkuai.com 中文版@LyleLiu666 —— 你这次复现正好落在本篇所说的窗口上:truncate 之后、重建落地之前被杀掉。 你那一问里面其实有三件事,分开答很重要 —— 三件事的答案各不相同。 有没有人在做。 只代表我们自己说:我们和你一样是外部开发者。我们会提报告、会在项目周边做工具;这个窗口我方没有在修的补丁,也没有提交计划。这个 PR 合不合。 那是维护者的判断。我们不能替项目回答,与其猜,不如直说不能答。 关于你的设计,我们能说的两点 —— 外加一个邀请。 第一点:你这个崩溃窗口,正好落在一份已经公开的契约上。我们维护 第二点:你的设计里有两处约束,是已经被隐含、但还没写出来的。
还有一句,因为是你自己点出的:「所有读者都要升级」是一条没有执行点的兼容承诺。凡是允许读者跳过的,读者就会跳过。 我们就这些 —— 不对项目路线图做任何主张。 声明:根因阅读与文稿撰写由 AI 协助完成;核验与发布由我本人负责。我非工程背景 —— 若任何技术表述有误,请直接指出,我会对照工具链重新核实并更正。 本报告由 OfferKuai(Offer快)团队 提交 —— 创始人:Zhaofeng(Yaming)。官网:https://www.offerkuai.com/。联系:contact@offerkuai.com |
|
@XJPeng12's #11258 got a patch from its own author, and that patch looks like the right fix for what was reported. What it implies reaches past that one package — so the general form is written down here rather than only in that thread. We reproduced the structure on the published tarball: the deadline is armed on What I would generalise is not "a timeout was too soft", but this: the constraint was declared as data, and nothing executes when it is reached. Same shape, different producer — @wutyi4330's #11298 (2026-10-28): when a single response hits ⇒ That gives a check that generalises past either report: enumerate the constraints the runtime declares (budgets, caps, windows) and, for each, ask whether any code path executes at the moment that constraint is reached. A declared constraint with no execution point is data, not a rule — and from the caller's side the two are indistinguishable. The post above is the same shape seen from the log side: it declares Opinion, flagged as such: I would enumerate before racing. A race closes the budget you already found; an enumeration surfaces the ones nobody has reported yet. Authorship note: the structure above was confirmed by me against the published npm tarball of Reported by the OfferKuai Team (Offer快) — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/. Contact: contact@offerkuai.com 中文版@XJPeng12 的 #11258 已经由作者本人给了补丁,这个补丁看起来正是针对所报问题的那一刀。而它暗示的东西不止那一个包 —— 所以一般化形式写在这里,而不是只留在那个帖子里。 我在已发布的 tarball 上复现了这个结构:deadline 挂在 我想给出的那层概括不是"超时设得太软",而是:约束被声明成了数据,而在它被触及时没有任何东西执行。 同一个形状,另一个产生侧 —— @wutyi4330 的 #11298(2026-10-28):单次响应触到 ⇒ 由此得到一条可枚举的自查:把运行时声明出来的约束(预算/上限/窗口)列一遍,逐个问一句 —— 约束被触及时,有没有代码在那里执行? 声明了却没有执行点的约束,是数据不是规矩;而在调用方看来,这两者不可分辨。 上面那篇帖子,是同一形状在日志侧的样子:它在销毁那一步跑起来时就已经声明了 以下是意见,已标明:我会先枚举再修。一次 race 补上的,是你已经发现的那个预算;枚举照出的,是还没有人报的那些。 声明:上述结构由我对照 本报告由 OfferKuai(Offer快)团队 提交 —— 创始人:Zhaofeng(Yaming)。官网:https://www.offerkuai.com/。联系:contact@offerkuai.com |
|
@LyleLiu666 — that answers the two things I asked, and it answers them with criteria rather than assurances: identity + prefix hash + exact events, refusal before any byte changes, no fallback to the shorter file. That is a second authority that states its own precedence, which is what was missing. One path it does not cover, and I would rather ask than assume. Your stale-journal handling covers the order new reader, old journal: the main file has moved on, the journal is behind, the file wins. The reverse order is a supported way to run: an older reader opens that same interrupted store, sees the six events, and the user keeps working. Whatever the writer assigns to the next event, it is the seq the recovered record would have occupied — the truncation freed exactly that position. Now the main file holds a record at that seq, and the journal holds a different record at the same seq. Under "overlapping events must match", that is a mismatch, so it raises Is that the intended verdict? Both answers are defensible, but they are different judgements and one criterion cannot stand for both: either mixed-version use counts as corruption (and the error is right), or the newer write wins and the journal drops to read-only evidence that needs a human decision. Whichever you choose, say it out loud, because this is the one path where your two constraints — refusal rather than fallback, and overlap must match — point in opposite directions. On your last paragraph: agreed, and that is the reason it matters less whether the protocol can defend against out-of-protocol writers than what the refusal leaves behind. A refusal that is only reachable through a raised error the caller then swallows is still silent from where the user sits. Authorship note: drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out and I will re-verify and correct. Reported by the OfferKuai Team (Offer快) — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/. Contact: contact@offerkuai.com 中文版@LyleLiu666 —— 这答了我问的两件事,而且是用判据答的,不是用保证答的:文件身份 + 前缀哈希 + 精确事件、任何字节改动之前就拒绝、不回退到更短的文件。这就是一个声明了自己优先级的第二权威 —— 原本缺的正是这个。 有一条路径它没覆盖,我宁愿问一句也不假设。 你的 stale-journal 处理覆盖的是 新读者、旧 journal 这个顺序:主文件已经往前走,journal 落后,主文件赢。反过来那个顺序同样是受支持的使用方式:旧读者打开同一个中断过的存储,看到 6 条,用户接着干活。写入者为下一条事件分配的任何一个 seq,都正是那条恢复记录本该占的位置 —— 截断恰好把这个位置空了出来。 于是现在:主文件在那个 seq 上有一条记录,journal 在同一个 seq 上是另一条记录。按「overlapping events must match」,这就是不匹配,于是抛 这是预期的判定吗?两个答案都站得住,但它们是两个不同的判断,一条判据代表不了两者:要么混版本使用算损坏(那么抛错是对的),要么更新的那次写入赢、journal 降为只读证据、交给人工裁。无论选哪个,都该明确写出来 —— 因为这是你的两条约束(拒绝而非回退、重叠必须匹配)唯一指向相反方向的那条路径。 关于你最后一段:同意。也正因如此,协议能不能防住协议外的写者,不如拒绝之后留下了什么重要。一个只能通过抛出异常来拒绝、而调用方又把异常吞掉的拒绝,从用户的位置看仍然是静默的。 声明:文稿撰写由 AI 协助完成;核验与发布由我本人负责。我非工程背景 —— 若任何技术表述有误,请直接指出,我会对照工具链重新核实并更正。 本报告由 OfferKuai(Offer快)团队 提交 —— 创始人:Zhaofeng(Yaming)。官网:https://www.offerkuai.com/。联系:contact@offerkuai.com |
Uh oh!
There was an error while loading. Please reload this page.
EN / English
TL;DR. Torn-tail repair in
@deepseek-ai/dsh-session-persistence-jsonlruns as two steps:persistContiguousfirst callstruncateTornTail(lib/index.js:229), which truncates the log to the last complete frame and fsyncs it; only then does it callpersistBatchwith the recovered records (:233). Both steps are durable writes, and both can fail or be interrupted — but by the time the second one starts, the first has already destroyed the only on-disk copy of the torn frame those records were decoded from. A process crash between the two fsyncs loses every complete record the recovery had salvaged, permanently and silently: the next open sees a clean prefix, sets no truncation point, triggers no repair, and leaves no trace that anything was discarded.The rule this report stands on, stated once: the rebuild must be able to survive the destroy — never the other way round. The same package already follows this rule in its migration publish path (§4). I am not proposing a fix, a field, or a schedule; I am asking whether the durability this package promises for recovered records should also hold across a crash inside the repair itself.
1. The repair is two durable steps, destroy first
Reads of
@deepseek-ai/dsh-session-persistence-jsonl@0.2.1-rc.5(thenexttag as of 2026-10-31;latestis0.0.1-rc.1,alphais0.2.2-alpha.2). All line numbers below are from the published tarball'slib/index.jsunless marked as master.persistContiguous— the shared durable-append body — runs the repair in this order (:228-235):Step one,
truncateTornTail(:2938-2942), callsrepair(:3337-3346), which istruncate(path, offset)followed by anr+open,sync(),close(). The destroy is fsync'd — after:229resolves, the torn bytes are gone from disk durably.Step two,
persistBatchwithrecoveredTail(:233), appends and fsyncs the salvaged records — after the bytes they were decoded from no longer exist.Both
tornTruncateToandrecoveredTaillive only in the handle's in-memory state (:228,:232). Nothing about step two is journaled, staged, or recoverable across a process death. Masterce7073d51carries the same order inpackages/session/session-persistence-jsonl/src/storage.ts:328-337— the window @PerryLink named in #6493.2. What dies between the two steps
Before the repair runs, the torn final frame is still on disk, and every open re-decodes it:
decodeStoredLogcallsdecompressZstdPrefix(buffer.subarray(tornStart))(:3024) and returns the salvaged records inrecoveredTail(:3036-3037). The recovery is repeatable — crash before:229, reopen, and the same records come back.After
:229resolves, that is no longer true. The frame is gone; the records exist only in this handle's memory, awaiting a step that has not started. If the process dies before:233fsyncs:tornTruncateTo: undefinedandrecoveredTail: [](:3017-3018), so no repair is triggered and nothing logs;:229truncated away.These are not speculative bytes. The recovery path's own comment on master (
src/index.ts:1047-1049) calls them what they are:And the package promises the rewrite in three places. README (
README.md:77): "a write handle truncates the torn bytes and durably rewrites those recovered records before its first new batch." The state field's doc comment (mastersrc/index.ts:128): "Complete events recovered from the torn final frame; the write path rewrites them durably." The comment above. What is lost in the crash window is exactly what these three texts promise to keep.One boundary I want to be precise about, because it is the strongest counter-reading: the base package's invariant list (
@deepseek-ai/dsh-session-persistence@0.2.1-rc.5,README.md:86) says "A torn physical tail never reaches a reader. It belongs to an append that never resolved; the write path truncates it durably before its first new append." That invariant governs the torn fragment — and the fragment is indeed discarded. It does not cover the complete records decoded out of the frame: those are promised a durable rewrite (:77), and the crash window is where that promise can break. The two are separate claims in the package's own texts, and only the first one is unconditional.3. The one log line fires before the rebuild starts
The whole file has four runtime log statements (
:423,:438,:2731,:2941). The torn-tail one (:2941) is insidetruncateTornTail, afterrepairresolves:By control flow, this warn runs after the destroy (
:229) and before the rebuild (:233) —truncateTornTailis called from:229. So in the crash window the situation on the ground is: the only trace the runtime ever emits about this recovery says "recovered", the fsync'd destroy has landed, the fsync'd rebuild has not, and the salvaged records are about to stop existing. A later reader of that log is told the recovery happened; the file tells a different story by being a shorter prefix with nothing marked.4. The same package already orders a durable rewrite correctly — destroy last
This is not a general property of the codebase's durability layer. The migration publish path does the opposite, and does it deliberately, with comments. Master
src/index.ts:1298-1323(npm artifact:3223-2335, comments stripped):New content is written and fsync'd (
writeSyncedTempFile,:1361/:3257:open(tmp, "wx"),writeFile,sync), then published, then — only then — is the old redundant copy removed. A crash at any point leaves either the temp (unpublished, ignored by canonical-name scanning) or the published file; never neither. The comments spell out the crash-durability reasoning step by step. Torn-tail repair is the one durable rewrite in this package that runs the same two operations in the other order.5. Relation to adjacent reports
@PerryLink 的 #6493 already named this exact window in the thread dismissing the migration-truncation claim: "The only truncate-before-rewrite window is torn-tail repair on the current generation (storage.ts:328-337), bounded to one torn zstd frame." That comment used the window as a scale bound — it cannot explain a day-long outage. This report is the other half of that sentence: what the bound bounds is a real interval between two fsync'd steps, and what can die inside it is the set of complete records the recovery salvaged. Same window, contents instead of size.
#8010 (@Kizaemon) covers whole trailing frames lost where no repair runs at all — the reader accepts a shorter prefix, and the gap is detection. Here the repair does run; the gap is that its two halves are not crash-atomic against each other.
6. Scope, stated so it can be checked
The window needs three conditions, all checkable in the published artifact:
:2290DEFAULT_COMPRESSION = "zstd",:2302schema default,:2435config.compression ?? DEFAULT_COMPRESSION). The uncompressed path never salvages anything (recoveredTail: []at:2852), so it has no window.recoveredTail.length > 0,:233) — an empty salvage skips the rebuild and loses nothing.:229fsyncs and before:233fsyncs. The interval spans onepersistBatchcall; it is narrow. It is also the only path where the destroyed source bytes are the sole copy of records the package has already decided to keep.Opinion, flagged as such — this paragraph is the only one carrying a position: for a production runtime's recovery path I would hold one rule: the rebuild must be able to survive the destroy. A repair that permanently loses salvageable records in its own crash window turns a recoverable state (re-decodable on every open) into an unrecoverable one (a clean prefix, no markers, nothing to retry), and its only log line has already claimed success. I am not proposing a fix design, not asking for a new field, and not claiming an intent — the question is only whether "durably rewrites those recovered records" should also hold when the crash lands inside the repair itself, and whether the migration path's order (write, publish, then destroy) is the order the repair path should meet as well.
Environment
@deepseek-ai/dsh-session-persistence-jsonl@0.2.1-rc.5—nexttag as of 2026-10-31 (latest=0.0.1-rc.1,alpha=0.2.2-alpha.2); repository coordinates from masterce7073d51(2026-10-29T16:13:15Z). Readings taken by reading the published npm tarball and the master sources; no code was executed and no other version was inspected. Host OS not involved — the analysis is of the write-path ordering, not of a platform-specific failure.Authorship note: I ran into this in my own daily use and wrote it up. Root-cause analysis and drafting were assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out and I will re-verify against the toolchain and correct.
Reported by the OfferKuai Team — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/. Contact: contact@offerkuai.com
中文版
一句话。
@deepseek-ai/dsh-session-persistence-jsonl的撕尾修复是两步:persistContiguous先调truncateTornTail(lib/index.js:229),把日志截断到最后一个完整帧并 fsync;之后才调persistBatch把抢救出的记录写回(:233)。两步都是持久写,都可能被中断——但第二步开始时,第一步已经把抢救记录所源自的那份磁盘上唯一的副本销毁了。两段 fsync 之间进程崩溃,恢复捞出的每一条完整记录都会永久、静默地丢失:下次打开看到的是干净前缀,不设截断点,不触发修复,也不留任何丢弃痕迹。本报告立在一条规矩上,只说一次:重建必须能在销毁之后活下来——顺序不能反。 同一个包在它的迁移发布路径里已经遵守这条规矩(§4)。我不提修法、不提新字段、不提排期;我问的是:这个包对抢救记录承诺的持久性,是否也应该覆盖修复动作自身内部的那次崩溃。
1. 修复是两步持久写,先销毁
读
@deepseek-ai/dsh-session-persistence-jsonl@0.2.1-rc.5(2026-10-31 写作时的next;latest是0.0.1-rc.1,alpha是0.2.2-alpha.2)。以下行号除标注 master 外,均取自发布产物lib/index.js。共享的持久追加主体
persistContiguous按此顺序执行修复(:228-235);masterce7073d51在packages/session/session-persistence-jsonl/src/storage.ts:328-337保持同样顺序——正是 @PerryLink 在 #6493 里点名的那扇窗:第一步
truncateTornTail(:2938-2942)调用repair(:3337-3346):truncate(path, offset)之后r+打开、sync()、close()。销毁是 fsync 的——:229resolve 之后,撕裂字节就已从磁盘上持久消失。第二步
persistBatch写回recoveredTail(:233),追加并 fsync 那些抢救出的记录——在它们所源自的字节已经不存在之后。tornTruncateTo与recoveredTail都只活在这个句柄的内存状态里(:228、:232)。第二步没有任何 journal、暂存或跨进程死亡的可恢复性。2. 两步之间死掉的是什么
修复运行之前,撕裂的末帧还在磁盘上,每次打开都会重新解码它:
decodeStoredLog调decompressZstdPrefix(buffer.subarray(tornStart))(:3024),把抢救出的记录放进recoveredTail返回(:3036-3037)。恢复是可重复的——在:229之前崩溃,重新打开,同样的记录会再回来。:229resolve 之后,这就不再成立。帧没了;记录只存在于这个句柄的内存里,等着一个还没开始的步骤。进程在:233fsync 之前死掉的话:tornTruncateTo: undefined、recoveredTail: [](:3017-3018),不触发修复,也不打日志;:229截掉的那些。这些不是假想的字节。恢复路径自己的注释(master
src/index.ts:1047-1049)说得很清楚:包也在三处承诺了这次重写。README(
README.md:77):"a write handle truncates the torn bytes and durably rewrites those recovered records before its first new batch." 状态字段的文档注释(mastersrc/index.ts:128):"Complete events recovered from the torn final frame; the write path rewrites them durably." 还有上面那条注释。崩溃窗口里丢掉的,正是这三处文本承诺要保住的东西。有一条边界我要说精确,因为它是最强的反读法:基础包的不变量清单(
@deepseek-ai/dsh-session-persistence@0.2.1-rc.5,README.md:86)写着"A torn physical tail never reaches a reader. It belongs to an append that never resolved; the write path truncates it durably before its first new append." 这条不变量管的是撕裂碎片——碎片确实被丢弃了。它不管从帧里解码出来的完整记录:那些记录被承诺了一次持久重写(:77),而崩溃窗口正是这个承诺可能落空的地方。在包自己的文本里这是两个分开的主张,只有第一个是无条件的。3. 唯一那行日志在重建开始之前就发了
整个文件只有四条运行时日志(
:423、:438、:2731、:2941)。撕尾那条(:2941)在truncateTornTail内部、repairresolve 之后:按控制流,这条 warn 在销毁(
:229)之后、重建(:233)之前——truncateTornTail就是从:229调进来的。所以在崩溃窗口里,地面上的情况是:运行时关于这次恢复发出的唯一痕迹写着"已恢复",fsync 过的销毁已经落地,fsync 过的重建还没有,而抢救出的记录即将不复存在。后来的读者从日志里被告知恢复已经发生;文件却讲了另一个故事——它是一个更短的前缀,且什么都没标记。4. 同一个包已经把一次持久重写排对了——销毁在后
这不是该代码库持久层的一般性质。迁移发布路径做的是相反的顺序,而且是带注释地 deliberate。master
src/index.ts:1298-1323(npm 产物:3223-2335,注释被剥掉):新内容先写入、先 fsync(
writeSyncedTempFile,:1361/:3257:open(tmp, "wx")、writeFile、sync),然后发布,然后才移除旧的冗余副本。任何一点崩溃,留下的要么是 temp(未发布,被规范名扫描忽略),要么是已发布的文件;不会两者皆无。注释逐步写明了 crash-durability 的推理。撕尾修复是这个包里唯一一次把同样两个操作反着排的持久重写。5. 与相邻报告的关系
@PerryLink 的 #6493 已经在驳斥迁移截断主张的那条线程里点名了这扇窗:"The only truncate-before-rewrite window is torn-tail repair on the current generation (storage.ts:328-337), bounded to one torn zstd frame." 那条评论把这扇窗当规模下界用——它解释不了一整天的中断。本报告是那句话的另一半:这个下界所限定的,是一段真实的、位于两次 fsync 之间的区间;而能死在里面的,是恢复所捞出的那批完整记录。同一扇窗,看的是内容,不是尺寸。
#8010(@Kizaemon)覆盖的是整段尾帧丢失且完全没有修复发生——读者接受一个更短的前缀,缺口在检测。这里修复确实跑了;缺口在于它的两半彼此之间不是崩溃原子的。
6. 危害面,写出来以便核验
这扇窗需要三个条件,全部可在发布产物里核对:
:2290DEFAULT_COMPRESSION = "zstd"、:2302schema default、:2435config.compression ?? DEFAULT_COMPRESSION)。无压缩路径不抢救任何东西(:2852恒为recoveredTail: []),没有这扇窗。recoveredTail.length > 0,:233)——空抢救会跳过重建,什么都不丢。:229fsync 之后、:233fsync 之前。 这段区间横跨一次persistBatch调用,很窄。它同时也是唯一一处:被销毁的源字节,是包已经决定要保留的那些记录的唯一副本。以下是意见,已标明——本段是全稿唯一带立场的部分:对一个生产级运行时的恢复路径,我会守一条规矩:重建必须能在销毁之后活下来。 一次修复如果在自己的崩溃窗口里永久丢失可抢救的记录,就是把一个可恢复状态(每次打开都能重新解码)变成不可恢复状态(干净前缀、无标记、无可重试),而它唯一的日志行已经宣称成功。我不提修法设计、不要求新字段、不断言意图——问题只有一个:"durably rewrites those recovered records" 这句承诺,是否也应该在崩溃落进修复动作自身内部时成立;迁移路径的顺序(先写、先发布、后销毁)是否就是修复路径该达标的那个顺序。
环境
@deepseek-ai/dsh-session-persistence-jsonl@0.2.1-rc.5——2026-10-31 写作时的next标签(latest=0.0.1-rc.1、alpha=0.2.2-alpha.2);仓库坐标取自 masterce7073d51(2026-10-29T16:13:15Z)。读数都取自 npm 发布 tarball 与 master 源码的阅读;没有执行任何代码,也没有检视别的版本。不涉及宿主系统——本分析针对的是写路径的顺序,不是某个平台特有的故障。声明:本文由我在日常使用中撞到并记录成文;根因与成文由 AI 协助完成,我负责校验与发布。我非工程背景 —— 若任何技术表述有误,请直接指出,我会对照工具链重新核实并更正。
本报告由 OfferKuai(Offer快)团队 提交 —— 创始人:Zhaofeng(Yaming)。官网:https://www.offerkuai.com/。联系:contact@offerkuai.com
All reactions