perf: bound what one model call costs to store - #4722
Draft
Astro-Han wants to merge 6 commits into
Draft
Conversation
Astro-Han
force-pushed
the
perf/runtime-bound-per-call-durable-writes
branch
from
September 4, 2026 04:16
462207f to
f65a47f
Compare
Astro-Han
force-pushed
the
perf/runtime-bound-per-call-durable-writes
branch
from
September 4, 2026 07:06
5b059f8 to
b80b903
Compare
6 tasks
Each dispatched provider request was serialized whole and written to the artifact store. The request is built from the conversation the run already holds, so every capture was another copy of the same messages, and each one grew with the conversation: one session here reached 772 MB of captures carrying 4.8 MB of distinct content. The bytes were the smaller cost. Captures were 87% of the artifact population, and every artifact write paid for the whole population, so the capture sink is what turned a growing conversation into quadratic write amplification. Nothing read them. The reader shipped with the capture in apache#1277 and was deleted by apache#2605; the producer stayed. What the panels and diagnostics actually read is the bounded observation on the canonical ModelCallAttempt, which is unchanged. The request is still serialized in memory to size and identify it, and is then dropped. `PreparedRequestMaterial` collapses into the observation it wrapped, and the tracker's per-step capture memo goes with it: its key was the digest, so it never saved the work it appeared to cache. Decoders stay. `captureArtifactId`, the `provider_request_captured` event and the `provider_request_capture` artifact source all still resolve, so attempts and sessions already on disk keep decoding and keep copying. Removing them would fail exactly the records this change is meant to stop producing more of. Tests that used the sink as a hook now use the dispatch gate, and the ones that used it to inspect the outgoing request assert against the provider request bodies instead — the stronger evidence of the two. Closes apache#4082 Generated-by: Claude Code
…s made from Every completed call stored a `PreparedRequestObservation`: up to 256 ordered segments, each with an index, a cacheable flag, a comparison mode, a sha256 digest, a byte count and a role. One reader existed, and it did one thing with all of it — `foldPromptComposition`, into four byte totals and a capped tool list. The other four fields per segment had no reader anywhere. So the fold moves to where the request is prepared, and the attempt carries its result. `PromptComposition` lives in core, next to the record that stores it, and the diagnostics types are now aliases of it rather than a second spelling kept in step by hand. What this stops doing per model call: serializing the entire request payload to hash it, hashing each of up to 256 segments, and writing that array into the run's event log. What it still answers is exactly what the panel and `/context` asked before. Attempts recorded before this still carry their segments, and folding them on read is the only way to say what those requests were made of, so that path stays. It is the same shape `readPromptCompositionEvent` already had for the generation before it. The 256-segment cap goes with the array. The fold's output was always the bound that mattered — four kinds and 64 named tools — and that constant now has one definition the producer and the decoder share. `hasRequestObservation` on the metering anchor becomes redundant once the compat fold happens inside it: it only ever meant "this anchor has no composition", which the composition itself now says. Closes apache#4082 Generated-by: Claude Code
…ation left Diagnostics declared its own segment, tool and composition types over the ones a ModelCallAttempt durably carries. Folding them onto the record left those as aliases with no consumer outside the package, which is two spellings of one fact kept in step by hand. Prepared-value normalization also tracked whether a value could be compared exactly. That fed the retired observation's per-segment comparison mode; the one caller left takes the normalized value and sizes it, so the flag was accumulated through every branch and read nowhere. Refs apache#4082 Generated-by: Claude Code
…'s buckets The snapshot validator spelled the four segment kinds twice and capped the tool list with a literal 64 beside the constant that sets it. Both now read from one list and one constant, so a change to the fold's buckets cannot leave the validator agreeing with a stale copy of itself. 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
`observe` was the seam the capture machinery hung on. With that gone it named nothing: two calls, one line, two call sites. Generated-by: Claude Code
Every load and every mutation rebuilt a map holding one sealed snapshot per session, and a snapshot's revision hashes every record in its session — so a store with 400 sessions sorted and hashed all 400 to answer a question about one. The map never earned that: each of the five readers reloads the whole store from the database first, so a kept snapshot never survived to be read. Sealing on the way out instead deletes the map, the two methods that maintained it, and the per-mutation bookkeeping that told them what changed. At 6,000 records across 400 sessions, same machine, same run: one listPage 13.45 to 11.54 ms, one create 38.32 to 37.42 ms. Refs apache#4037 Generated-by: Claude Code
Astro-Han
force-pushed
the
perf/runtime-bound-per-call-durable-writes
branch
from
September 4, 2026 07:33
b80b903 to
8c35544
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every model call stored a copy of the conversation. The prepared provider request was serialized whole into an Artifact, and the record beside it carried up to 256 per-segment rows — so the cost of storing one call 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; the per-segment detail's only consumer folded it into four byte totals.
flowchart LR REQ(["one provider request"]) subgraph B["before"] direction TB CAP["full request body<br/>private Artifact<br/>grows with the conversation"] OBS["up to 256 segment rows<br/>index · cacheable · comparison<br/>digest · bytes · role"] end subgraph A["after"] direction TB FOLD["4 byte totals<br/>+ capped tool list<br/>1,971 B, flat"] end REQ --> B REQ --> A CAP -.- X1["reader deleted · PR 2605"] OBS -.- X2["folded by its one reader"] classDef dead fill:#fcebeb,stroke:#e24b4a,color:#a32d2d classDef live fill:#e1f5ee,stroke:#1d9e75,color:#0f6e56 class CAP,OBS,X1,X2 dead class FOLD liveThis removes both producers, and seals an Artifact session snapshot when a reader asks for one instead of keeping a map of all of them. #4716 landed the persistence half of #4037 first; this is rebased on it and built on its
applyChangesshape. Reclaiming the captures already on disk is #4738, stacked on this.Closes #4082
Refs #4037
Refs #4704
📉 Before / after
Against
780dc4b4, same machine.Durable bytes per model call — 60 tools, conversation of N messages. The record is written twice (AgentRun event log,
usage_model_call_attempts); the capture is the request stored whole.One Session, every call summed
The curve is the point: before, 4× the turns costs 8× the bytes, because each call copies a conversation that is itself growing. After, 4× the turns costs 4×.
One real workspace — 814 MB installation of mine, before this change:
Deduplicating messages by content within their own Session across all 379 capture files: 4.8 MB unique, 763.3 MB re-serialized duplicates (99.4%). Captures are 87% of the Artifact population, which is also what made the metadata write path expensive — the two problems were never independent.
Artifact store — 6,000 records across 400 Sessions, same machine, one run:
listPage, mean of 50create, mean of 20A snapshot's revision hashes every record in its Session, and the store kept one snapshot per Session, rebuilt on every load and every mutation — so 400 Sessions were sorted and hashed to answer a question about one. The map never earned that: each of the five readers reloads the whole store from the database first, so a kept snapshot never survived to be read. Sealing on the way out deletes the map, the two methods that maintained it, and the per-mutation bookkeeping. The
createfigure is small because the rest of it is ~24 ms of filesystem durability and the fullreadAllbelow.🚧 Still open
Step 1 of #4037. Every mutation and every read begins with
readAll(): fullSELECTplus a JSON decode of every row, scaling with the store. The reseal that used to sit beside it is gone with this PR; the reload is what is left.That reload dates from the
metadata.jsonlera, where re-reading before a mutation was how a writer stayed correct against another process. Making it cheap needs a way to ask whether anything changed since the last read, and SQLite'sdata_versionis not it: the operational-state database is one shared connection per process, so it does not move for a sibling store's writes. Who may invalidate the in-memory mirror is a design question, not a tidy-up, so it belongs in its own change.🧹 What came out
Removed
ContextDiagnosticsSegmentKind/Segment/Tool/Composition— aliases of theModelCallAttempttypes with no consumer outside the package.opaqueflag threaded through every branch of prepared-value normalization. It fed the retired observation's per-segmentcomparisonmode; the one caller left sizes the value and never reads it.request-shape.ts: 430 → 369 lines.64beside the constant that sets it.Kept, every one —
captureArtifactId, theprovider_request_capturesource, andPreparedRequestObservationwith its validator.hasExactShaperejects a record carrying an unknown key, so removing any of these fails exactly the records this PR exists to stop producing more of.✅ Verification
format,lintclean.test:dist:@maka/core807,@maka/runtime3,216,@maka/storage1,117,@maka/runtime-host1,687 — 0 failures.typecheckalso on@maka/desktop,@maka/ui,@maka/mcp,@maka/eval,maka-agent./contextanswer what they answered before, from the same fold, and byte accounting is unchanged —sizedSegmentserializes a given value to the same bytes the retired path did, so a Session's numbers stay comparable across the upgrade.One model-visible change: a sub-agent's
spawntool result listed the capture Artifact inartifactIds/artifactCount. A child turn now stores nothing of its own, so that list is empty. Nothing but the private capture ever appeared there.🔍 Review focus
The compatibility boundary: the producers are gone but every decoder stays, because real stores hold these records today.
hasExactShapefails a whole record on an unknown key, so a decoder removed here is a record that stops decoding — including its usage and cost.AI use
Select exactly one:
Tool(s) and scope: Claude Code — traced the demand chains, wrote the implementation and tests, ran the measurements. Reviewed and verified by me.
Checklist
Does this PR entail a change in behavior?