feat(storage): reclaim the prepared-request captures already on disk - #4738
feat(storage): reclaim the prepared-request captures already on disk#4738Astro-Han wants to merge 6 commits into
Conversation
d8b0213 to
2e08ddc
Compare
Every model call stored a copy of the conversation. The prepared provider request was serialized whole into a private Artifact, and the record beside it carried up to 256 per-segment rows -- so one call cost tens of KB of database plus a file that grew with the Session it belonged to. Nothing read either: the capture's reader shipped in #1277 and was deleted in #2605, which kept the producer, and the per-segment detail's only consumer folded it into four byte totals. Both producers are gone. The fold now runs where the request is dispatched, and its result -- four byte totals plus a capped tool list, 1,971 B flat -- lands on the canonical ModelCallAttempt, which already owns the request's facts. Per model call with 60 tools and a 200-message conversation: database rows 46,077 to 1,971 B, capture file 136,967 B to none. The Artifact store also sealed one snapshot per Session on every load and every mutation, and a snapshot's revision hashes every record in its Session -- so 400 Sessions were sorted and hashed to answer a question about one. Every reader reloads the whole store from the database first, so a kept snapshot never survived to be read. Sealing one when a reader asks for it deletes the map, the two methods that maintained it, and the per-mutation bookkeeping: one listPage at 6,000 records goes 13.45 to 11.54 ms. Compatibility: every decoder stays. `hasExactShape` fails a whole record on an unknown key, so removing `captureArtifactId`, the `provider_request_capture` source, or `PreparedRequestObservation` and its validator would strand exactly the records this stops producing more of -- including their usage and cost. Captures already written are left on disk; #4738 reclaims them. One model-visible change: a sub-agent's spawn tool result listed the private capture in `artifactIds` / `artifactCount`. A child turn now stores nothing of its own, so that list is empty. Also gives `graceful Host shutdown stops and drains an active Turn` the checkpoint its sibling test already used, so the state its drain finds is not left to how fast the machine is. Closes #4082 Refs #4037 Refs #4704 Generated-by: Claude Code
Removing the capture sink stops the growth but leaves the residue, and the residue is not the user's to clear: captures are `userVisible: false`, so no UI lists them, and the only thing that ever deleted one was purging its whole conversation. One workspace measured here holds 772 MB of them. The store made them, so the store disposes of them. `purgeRetiredCaptures` takes a bounded batch through the same mutation queue and purge-intent file as every other deletion, and reports what is left; the sweep started at host composition drains the rest behind live turns and stops when there is none. A store that never held captures does one empty pass. Interrupting it is safe by construction rather than by a checkpoint: each batch is durable on its own and the next pass reads whatever remains, so a crash, a close, or a stop all resume the same way. `purge` now shares its body with the sweep instead of restating it. Refs apache#4037 Generated-by: Claude Code (cherry picked from commit 89628f2)
`captureArtifactId` was a required mapping, so once the sweep reclaimed a capture Artifact, branching or copying any Session holding a historical model call threw and could never succeed again. Every other Artifact reference may keep throwing: the bytes and the events naming them have always been removed together. This one cannot, because the sweep removes bytes an append-only ledger still names — so the key now leaves with them and the attempt keeps its record without the join. Also drops the two capture decoders left behind by apache#4631, which retired both provider-request event types from the emitted catalogue: a copy no longer reaches either one. Refs apache#4037 Generated-by: Claude Code
A write authority refuses every mutation until it has recovered, and the sweep gives up after one failure. Starting it at composition meant its first batch always landed before recovery ran, so it reclaimed nothing and never retried. Both new tests recovered first and missed it. Refs apache#4037 Generated-by: Claude Code (cherry picked from commit d25a8ed)
A batch holds the writer lock and its cost rises with everything the store holds, so a fixed 250 ms pause would not stay behind live turns on a store big enough for the sweep to matter. The pause is now at least three times the batch it follows. Also drops a sweep assertion that could not fail: the batch size is a module constant, so asserting it is a positive integer pinned nothing. Generated-by: Claude Code
2e08ddc to
70fe70b
Compare
…tures A fork copied every Artifact of the source Session, retired provider-request captures included. That put condemned bytes back into a new Session -- and because the sweep stops for good once it sees an empty residue, anything copied after it finished was never reclaimed at all, which is the whole point of retiring them. It also let the copy and the sweep race over the same record. The copy now leaves that source out, which is also the only source a reader never asks for. Generated-by: Claude Code
The sweep gave up on its first failed batch and logged it. Wired ahead of the store's recovery, every batch was refused, so it reclaimed nothing at all for anyone -- and the failure that says the least about the next batch is exactly the one it quit on: a purge that fails part way leaves the write authority refusing every mutation until something recovers it. It now retries, and gives up only after five consecutive failures. `onError` became the place that repairs what made the batch fail, which is why the host recovers the store there -- that recovery is also what hands the live turn's own writes back, not just this sweep's. Generated-by: Claude Code
|
I think the core causal spine is sound: identify the retired However, on the current head (
From an Occam perspective, I would define the minimum complete requirement as:
The source filter, durable purge path, bounded pacing, post-recovery start, droppable Could you either push the missing implementation described in the body or align the body and guarantees with the current head? |
|
Sorry — you were reading the right code; I pushed the fixes to the wrong remote, so the head stayed at
Happy to split the old decoder cleanup out if you'd rather keep this to the minimum. |
jackwener
left a comment
There was a problem hiding this comment.
Reviewed at exact head 82ff4a487d9aae0f7296d62c6c3efb9b36f18aa1. Five independent reviews ran on this, each sealing before reading the others. Four of them cleared the deletion itself. One found a reachable gap that I am raising as [P1], so I am not approving this head — everything else here is sound, and the fix looks small.
[P1] The retired-capture filter guards one of three selection paths
copyConversationArtifacts selects records in three passes. Only the first excludes the source, and its comment states the property the whole design depends on:
// A retired capture is on its way off disk and nothing reads one.
// Copying it would hand the new Session bytes already condemned,
// and would put records back after the sweep finished and stopped
// looking. Leaving them out also keeps the two from racing: the
// sweep can no longer delete a record this copy is holding.
record.source !== RETIRED_CAPTURE_ARTIFACT_SOURCE,
The next two passes do not look at source at all:
linkedArtifacts(artifact-store.ts:440-451) matches onsessionId,idandstatus !== 'deleted', then pushes the record.includedArtifactIds(:453-463) matches onsessionIdand the include/exclude sets.
This is reachable in production, not hypothetical. Side Conversation copy builds a linkedArtifacts request — kind === 'side_conversation' && linkedReferences.references.size > 0 — and one reviewer's probe confirmed by measurement that both unfiltered paths copy a capture into the target with source: provider_request_capture intact.
The consequences follow directly from the comment above:
- Condemned bytes land in a new Session. The copy hands the target rows that are on their way off disk.
- They are re-created after the sweep has finished. The sweep stops when nothing is left; a copy afterwards puts fresh
provider_request_capturerows back, and the sweep is no longer looking. - A later sweep reclaims them, hollowing out the snapshot's rewritten
artifactIds. - The race the comment says is prevented is not prevented on these paths.
linkedArtifactsthrowsLinked Artifact <id> could not be copiedwhen the record is gone — so a sweep that removes a capture between selection and copy turns a Side Conversation into a hard failure rather than a dropped key.
The PR summary's claim — "A conversation copy no longer carries retired captures over … which also stops it racing the sweep over the same record" — holds for the turn-scoped pass and not for the other two.
Smallest fix: apply the same source !== RETIRED_CAPTURE_ARTIFACT_SOURCE exclusion to all three selection passes. A regression should drive a real parent+child state through the Side Conversation coordinator, sweep, and then a real revision — asserting that ordinary child artifacts still copy.
What five reviews cleared
The deletion scope is correct. Exact-source deletion measured at 6/6 including tombstones and copy-rebuilt rows; all 14 remaining rows across the other 13 ArtifactSource values plus source-less rows survive; non-capture hardlink inodes and bytes stay readable.
Nothing else reads a capture reference. Enumerated independently by three seats, converging: the only production readers of captureArtifactId are the Core validator and conversation-copy.ts. Diagnostics and the ledger never dereference it.
Keeping captureArtifactId in the schema is load-bearing, not conservative. hasExactShape rejects any unknown key outright — measured directly: a record carrying the field validates while allowed contains it, and the same record fails the moment the key is removed from allowed. Dropping the field would strand exactly the records this change exists to free the bytes of.
The sweep's failure handling is sound. Five consecutive failures stop the loop in this process only — there is no persistent cursor, and the next Runtime Host start resumes over what remains. A successful batch resets the counter. stop() prevents the next batch rather than interrupting the current one, and an interrupted batch leaves a purge intent that recovery replays.
Two defensive observations that do not block, from the cross-team seat:
- Closure does not wait for an in-flight sweep.
stop()sets a flag andcloseproceeds tostorage.close; if closure lands between file deletion and the metadata write, the batch can delete its targets and fail the metadata write, leaving a purge intent that the next recovery replays. The target set is still limited to retired captures. Worth havingstopawait the in-flight batch, or folding it into a unified drain. - On a large store each batch still resolves removal entries across every non-batch record as a path guard, so the 16–256 adaptive batch cannot shorten lock hold time — shrinking the batch does not help. That is live-turn latency rather than wrong deletion.
(The reference-filter finding above was reached independently by two seats and, from the opposite direction, by a third that had classified it as unreachable in production until the Side Conversation caller was identified.)
Everything except the filter gap is in good shape, and the premise — that these bytes are duplicates nothing reads — is well supported.
简体中文
在 82ff4a487d9aae0f7296d62c6c3efb9b36f18aa1 上评审。五次独立评审在这个 head 上进行,各自封存后才互看。其中四次确认删除本身是正确的;一次找到了一个生产可达的缺口,我按 [P1] 提出,因此这个 head 我不批准——除此之外都是扎实的,而且修法看起来很小。
[P1] retired-capture 过滤只守住了三条选择路径中的一条
copyConversationArtifacts 分三段选记录。只有第一段排除了这个 source,而它的注释恰好道出了整个设计所依赖的性质:
// 一条退役 capture 正在离开磁盘,而且没有人读它。
// 复制它等于把已被判定要删的字节交给新 Session,
// 并且会在 sweep 结束、不再查看之后把记录放回去。
// 把它们排除在外也让两者不再竞争:
// sweep 不能再删掉这次复制正持有的记录。
record.source !== RETIRED_CAPTURE_ARTIFACT_SOURCE,
而后两段根本不看 source:
linkedArtifacts(artifact-store.ts:440-451)只匹配sessionId、id和status !== 'deleted',然后就把记录推入。includedArtifactIds(:453-463)只匹配sessionId和 include/exclude 集合。
这在生产中是可达的,不是假设。 Side Conversation 的复制会构造 linkedArtifacts 请求——kind === 'side_conversation' && linkedReferences.references.size > 0——而一位评审的探针实测确认:这两条未过滤的路径都会把 capture 复制进目标,且 source: provider_request_capture 原样保留。
后果直接来自上面那段注释:
- 被判定要删的字节进入了新 Session。 复制把正在离开磁盘的行交给了目标。
- 它们在 sweep 结束之后被重新造出来。 sweep 在没有剩余时停止;之后的一次复制会放回新的
provider_request_capture行,而 sweep 已经不再查看了。 - 之后的 sweep 会回收它们,把 snapshot 中被重写过的
artifactIds挖空。 - 注释声称被阻止的那个竞争,在这两条路径上并没有被阻止。 当记录已不存在时,
linkedArtifacts会抛出Linked Artifact <id> could not be copied——所以一次在「选择」与「复制」之间移除了 capture 的 sweep,会把一次 Side Conversation 变成硬失败,而不是丢掉一个 key。
PR 摘要里那句——「conversation copy 不再把退役 capture 带过去……这也阻止了它与 sweep 争抢同一条记录」——对 turn 那一段成立,对另外两段不成立。
最小修法: 把同一个 source !== RETIRED_CAPTURE_ARTIFACT_SOURCE 排除应用到全部三段选择上。回归测试应当驱动一个真实的 parent+child 状态,经过 Side Conversation coordinator、sweep,再走一次真实 revision——并断言普通的 child artifacts 仍然会被复制。
五次评审确认没问题的部分
删除范围是正确的。 精确 source 删除实测 6/6(含 tombstone 与两条 copy 重建行);其余 13 个 ArtifactSource 加上无 source 的共 14 行全部保留;非 capture 的 hardlink inode 与字节仍可读。
没有别的地方读 capture 引用。 三个席位独立穷举并收敛:captureArtifactId 的生产读者只有 Core 校验器和 conversation-copy.ts,diagnostics 与 ledger 从不解引用它。
把 captureArtifactId 留在 schema 里是承重的,不是保守。 hasExactShape 对任何未知 key 一律否决——直接实测过:一条带该字段的记录在 allowed 含它时通过校验,而同一条记录在该 key 被移出 allowed 的瞬间失败。删掉这个字段,会让正是这次改动要释放其字节的那批记录全部失效。
sweep 的失败处理是稳的。 连续五次失败只停止本进程内的循环——没有持久游标,下一次 Runtime Host 启动会从剩余部分继续。成功一批会重置计数。stop() 阻止的是下一批而不是打断当前批,而被打断的一批会留下 purge intent 供 recovery 重放。
两条不阻塞的防御性观察,来自跨团队席位:
- 关闭路径不等待进行中的 sweep。
stop()只置标志,close继续走storage.close;若关闭恰好落在文件删除与 metadata 写入之间,该批次可能删完目标却写 metadata 失败,留下一个 purge intent 由下次 recovery 重放。目标集合仍只限于退役 capture。建议让stop等待在飞批次,或纳入统一的 drain。 - 大库上每批仍会把「非本批的每一条记录」都解析一遍作为路径守卫,所以 16–256 的自适应批次缩不短锁持有时间——把批次调小并没有帮助。这是活跃 turn 的延迟问题,不是删错数据。
(上面那条引用过滤的发现由两个席位独立得出;第三个席位从相反方向得到同一处,并在 Side Conversation 调用方被指认之前一直将其归类为「生产不可达」。)
除了这个过滤缺口,其余都处于良好状态;而这次改动的前提——这些字节是无人读取的重复数据——是有充分支撑的。
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
Summary
#4722 stops producing prepared-request captures. This reclaims the ones already on disk. On my own 814 MB installation they are 772.7 MB — 87% of the Artifact population, and 99.4% of their bytes are re-serialized duplicates of messages already in the ledger.
A bounded background sweep drains them through the same purge-intent path a Session delete uses, then stops. It reclaims the files and the Artifact rows naming them — nothing else. The per-segment detail #4722 stopped writing sits in the AgentRun ledger, which is append-only by design, so those rows stay where they are and only new calls are cheaper.
#4722 has landed, so this now stands on its own: 6 commits on current
main.Refs #4037, #4704.
🔀 What this changes about deletion
flowchart TB subgraph BEFORE["until now"] direction LR P1["purgeSessionArtifacts"] --> B1["Artifact bytes"] P1 --> E1["events naming them"] end subgraph AFTER["this sweep"] direction LR P2["purgeRetiredCaptures"] --> B2["capture bytes"] E2["attempts naming them<br/>append-only, cannot be retracted"] end BEFORE -.-> R["⇒ a capture reference<br/>must tolerate a missing referent"] AFTER --> R classDef gone fill:#fcebeb,stroke:#e24b4a,color:#a32d2d classDef live fill:#e1f5ee,stroke:#1d9e75,color:#0f6e56 classDef rule fill:#fff6e0,stroke:#d99a1f,color:#8a5f00 class B1,E1,B2 gone class E2 live class R ruleArtifact bytes and the events naming them have always been removed together. This sweep is the first mechanism that removes bytes while the events referencing them live on, because the AgentRun ledger is append-only: a historical
ModelCallAttemptkeeps itscaptureArtifactIdforever.That is the review question — who reads a capture reference, and does it survive the referent being gone.
captureArtifactIdin a conversation copy was a required mapping. Once a capture was reclaimed, branching or copying any Session holding a historical model call threwConversation copy is missing Artifact …and could never succeed again. The key now leaves with the bytes; the attempt still copies and still decodes.conversation-copy.ts— attachment refs, tool-result Artifacts, snapshot lists, linked children — still throws on a missing target, and should. The sweep filters onsource === 'provider_request_capture', and none of those name that source.captureArtifactIdstays in theModelCallAttemptschema and its validator:hasExactShapefails a whole record on an unknown key, so dropping the field would strand exactly the records this exists to free the bytes of.Also removes the two capture decoders #4631 left behind in
conversation-copy.tswhen it retired both provider-request event types from the emitted catalogue — a copy no longer reaches either.🧹 The sweep
openedArtifactStore.recover()— a write authority refuses every mutation until it has recoveredmax(250 ms, 3 × last batch)— a batch holds the writer lock and its cost rises with the store, so a fixed pause would not stay behind live turnsEach batch is durable on its own, so stopping only means the next one does not start; a later run continues from the residue.
Giving up on the first failed batch is what the earlier revision did, and it was wrong in the way that costs the most: the failure that says the least about the next batch is exactly the one it quit on. A purge that fails part way leaves the write authority refusing every mutation until something recovers it — so a single bad batch stopped all reclamation for that user, permanently and silently.
onErroris now where the repair belongs, and the host recovers the store there; that same recovery is what hands the live turn's own writes back.✅ Verification
format,lintclean.test:dist:@maka/storage1,126,@maka/runtime3,219,@maka/runtime-host1,687 — 0 failures.typecheckalso on@maka/desktop,@maka/ui,@maka/mcp,@maka/eval,maka-agent.rewriteOwnedArtifactIdback:Error: Conversation copy is missing Artifact artifact-gone.AI use
Select exactly one:
Tool(s) and scope: Claude Code — traced the reference points, wrote the implementation and tests. Reviewed and verified by me.
Checklist
Does this PR entail a change in behavior?