Skip to content

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

Draft
Astro-Han wants to merge 6 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 6 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, 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 applyChanges shape. 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.

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 store — 6,000 records across 400 Sessions, same machine, one run:

before after
one listPage, mean of 50 13.45 ms 11.54 ms −14%
one create, mean of 20 38.32 ms 37.42 ms −2%

A 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 create figure is small because the rest of it is ~24 ms of filesystem durability and the full readAll below.

🚧 Still open

Step 1 of #4037. Every mutation and every read begins with readAll(): full SELECT plus 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.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.
  • Two spellings of the fold's four buckets, and a literal 64 beside the constant that sets it.

Kept, every onecaptureArtifactId, the provider_request_capture source, 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 807, @maka/runtime 3,216, @maka/storage 1,117, @maka/runtime-host 1,687 — 0 failures. typecheck also on @maka/desktop, @maka/ui, @maka/mcp, @maka/eval, maka-agent.
  • Every figure above is measured on both trees on the same machine.
  • Not run: full-repo suite, Playwright E2E. The context panel and /context answer what they answered before, from the same fold, and byte accounting is unchanged — sizedSegment serializes 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 spawn tool result listed the capture Artifact in artifactIds / 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. hasExactShape fails 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:

  • 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 (prompt-composition decoding, the fold's buckets) 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
@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
@Astro-Han
Astro-Han force-pushed the perf/runtime-bound-per-call-durable-writes branch from 5b059f8 to b80b903 Compare September 4, 2026 07:06
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
Astro-Han force-pushed the perf/runtime-bound-per-call-durable-writes branch from b80b903 to 8c35544 Compare September 4, 2026 07:33
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