Skip to content

perf: bound what one model call costs to store - #4722

Draft
Astro-Han wants to merge 8 commits into
apache:mainfrom
Astro-Han:perf/runtime-bound-per-call-durable-writes
Draft

perf: bound what one model call costs to store#4722
Astro-Han wants to merge 8 commits into
apache:mainfrom
Astro-Han:perf/runtime-bound-per-call-durable-writes

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 live
Loading

This removes both producers, reclaims the captures already on disk, and reseals only the Sessions a mutation touched. #4716 landed the persistence half of #4037 first; this is rebased on it and built on its applyChanges shape.

Closes #4082
Refs #4037
Refs #4704

Before / after

Against this branch's parent 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.

conversation before after
40 messages 103,723 B 3,942 B 26×
200 messages 229,121 B 3,942 B 58×
600 messages 405,729 B 3,944 B 103×

One Session, every call summed

turns before after prepare CPU
50 5.61 MiB 0.19 MiB 30× 26 → 14 ms
200 44.48 MiB 0.75 MiB 59× 138 → 42 ms
500 193.44 MiB 1.88 MiB 103× 607 → 139 ms

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:

artifacts/                     776.0 MB   436 records
  provider_request_capture     772.7 MB   379 files, 2.04 MB average
  everything else                3.3 MB    57 files
core_agent_run_events           11.6 MB
usage_model_call_attempts       10.6 MB   371 rows

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 mutation — real store, 6,000 records across 400 Sessions:

before after
one create, mean of 20 39.21 ms 37.82 ms
6,000 creates 193.2 s 175.7 s (−9%)

Modest, and worth stating precisely: the isolated reseal goes 6.90 ms → 0.22 ms at 14,361 records, but end to end that is 3.5% of a create — the rest is ~24 ms of filesystem durability and ~8.8 ms of the read below.

Still open

Step 1 of #4037. Every mutation begins with reloadForMutationUnlocked()readAll(): full SELECT plus a JSON decode of every row, then a full reseal — 8.8 + 2.3 ms of that 37.8 ms create, both scaling with the store.

That reload dates from the metadata.jsonl era, 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's data_version is 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 the ModelCallAttempt types with no consumer outside the package.
  • The opaque flag threaded through every branch of prepared-value normalization. It fed the retired observation's per-segment comparison mode; the one caller left sizes the value and never reads it. request-shape.ts: 430 → 369 lines.
  • ProviderRequestTelemetry.observe — the seam the capture machinery hung on. Inlined.
  • Two spellings of the fold's four buckets, and a literal 64 beside the constant that sets it.

Kept, every onecaptureArtifactId, the provider_request_captured and provider_request_attempt_recorded events, the provider_request_capture source, the conversation-copy rewrite branches, and PreparedRequestObservation with its validator. hasExactShape rejects 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, lint clean. test:dist: @maka/core 804, @maka/runtime 3,216, @maka/storage 1,104, @maka/runtime-host 1,674 — 0 failures. typecheck also on @maka/desktop, @maka/cli, @maka/ui, @maka/protocol.
  • Every figure above is measured on both trees on the same machine.
  • Not run: full-repo suite, Playwright E2E — no user-visible surface changes. The context panel and /context answer what they answered before, from the same fold.

Review focus

The compatibility boundary: the producers are gone but every decoder stays, because real stores hold these records today.

One caveat on the new store-level test. It audits row writes through a real create, but #4716's WHERE ... IS NOT excluded.* means a store handing over its whole record set would still write no extra rows — so it cannot catch that regression. It catches a create that rewrites rows belonging to other Artifacts, and says so. Guarding the wider property needs a count of statement executions, not row writes.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — traced the demand chains, wrote the implementation and tests, ran the measurements. Reviewed and verified by me.

Checklist

  • Tests cover the change and fail without it
    • New behavior (bounded capture reclamation, the sweep's pacing and stop, prompt-composition decoding) has tests that fail without it. The reseal change preserves behavior exactly and is shown by the measurements rather than a new failing test.
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 4, 2026
A session snapshot's revision hashes every record in that session, and
every mutation resealed every session. One new artifact therefore cost as
much as the whole population, which is the shape the metadata write path
had just stopped having.

A mutation knows which sessions it changed, so it now reseals those and
carries the rest forward. Full reload keeps rebuilding everything, which
is the one caller that has that much to say.

At 14,361 records across 400 sessions, one mutation drops from 6.90 ms to
0.22 ms.

Refs apache#4037

Generated-by: Claude Code
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
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
…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
A create now hands the repository a delta, but the repository's WHERE
clause skips unchanged rows either way, so nothing at the row level said
whose rows a create may touch. This audits the table through a real
create against a seeded store: one row, whatever else is in there.

Refs apache#4037

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the perf/runtime-bound-per-call-durable-writes branch from 462207f to f65a47f Compare September 4, 2026 04:16
@Astro-Han Astro-Han changed the title perf: make durable writes proportional to the call that caused them perf: bound what one model call costs to store Sep 4, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(runtime): eliminate unbounded provider request diagnostics

1 participant