Skip to content

Incremental provider normalization across sends (journal-backed history send cost grows with row count) #3849

Open

Description

@acoliver

Problem

After #854, conversation history lives in a disk journal and every send streams rows from it. A warm send under the context limit still costs more than it did with in-memory history. The per-send cost grows with the number of rows, because provider normalization rebuilds its snapshot over every surviving row each time.

Measured on the #854 branch (commit aebb531), single warm send, local HTTP provider, mixed text/tool/media history:

Rows #854 branch main (in-memory)
1,000 349 ms ~104 ms
8,192 2,955 ms ~1,275 ms

At 8,192 rows the time splits roughly as: normalization snapshot open 0.77 s, projectPromptEnvelope 0.8 s, estimatePrompt 0.4 s, the rest in enforcement and body production. Journal folds are not the cost; the durable fold checkpoint restores in 1 to 3 ms.

Two earlier hot spots were already fixed on the branch: the o200k estimator (5.9 s to 0.25 s at 167k tokens) and a quadratic tool-pairing rescan in the Responses source input (27.9 s to 3.7 s at 8,192 rows).

Proposal

Checkpoint the provider normalization result across sends, the same way DurableFoldCheckpoint checkpoints the fold. When the surviving prefix is unchanged, normalize only the appended rows.

Normalization lives in packages/core/src/services/history/provider-curated-stream.ts (prepareProviderContentSnapshot) and runs four stages over a ProviderNormalizationDisk. Two of them make a naive prefix checkpoint wrong:

  • completeResponses synthesizes orphan tool responses using a responded set filled over the whole stream, including later rows and the pending tail.
  • indexResponses lets a later duplicate response for the same call id replace an earlier response.

So the checkpoint can only cover a "closed" cut c, where every tool call in [0, c) has its response inside [0, c). It must be invalidated when appended rows carry a duplicate response for a call in the prefix.

Suggested key: pinned journal identity, projection identity, resume boundary, c, and a SHA-256 over the first c 64-byte row-directory entries. Directory entries determine durable row content (source, offset, bytes, chronology overlay, invalidation flags), so rewind, compression, replacement, density, purge and chronology changes all change the digest. The cut must stop before the first pending source row.

Open question: whether streamCuratedProviderHistory is a per-row stateless filter, so that a curated index maps to a fold index.

Acceptance

  • Request bodies stay byte-identical. A differential test over generated sessions with rewinds, compression, density, inserts, purges, media and tool pairs compares incremental and full normalization at every send.
  • A counting seam shows rows normalized per send is bounded by the appended rows plus a constant when the prefix is unchanged, and returns to a full rebuild after an invalidating mutation.
  • A corrupted or mismatched checkpoint falls back to a full rebuild.
  • No context-sized state is kept in memory between turns. The checkpoint is disk-backed and released on store dispose.
  • Warm send at 8,192 rows under about 1.5 s; at 1,000 rows under about 200 ms.

Follow-up from #854.

Activity

  1. github-actions commented on Oct 10, 2026

    @github-actions
    Contributor

    LLxprt Issue Planner — infrastructure failure

    The autonomous planner failed with exit code 1. Please inspect the workflow logs (Run planner agent section) and re-run once resolved.

  2. added theissue type on Oct 13, 2026
  3. added this to the 0.12.0 milestone on Oct 13, 2026
  4. acoliver commented on Oct 14, 2026

    @acoliver
    CollaboratorAuthor

    Status after the PR #3852 perf work: per-send cost is down a lot, but a warm send on the journal-backed path is still slower than main's in-memory path, so this issue stays open.

    Same bench as before (real ChatSession and runtime wiring, local HTTP provider, mixed text, tool and media rows, warm send under the limit, median of 5). Branch and main ran back to back per cell. The machine was heavily loaded (load average 40 to 69), so CPU time is the fairer comparison:

    Provider Rows Branch CPU ms Main CPU ms Ratio
    OpenAI chat 1,000 323 180 1.8x
    OpenAI chat 4,000 1,406 922 1.5x
    OpenAI chat 8,192 3,924 3,192 1.2x
    Responses 1,000 475 228 2.1x
    Responses 4,000 1,763 1,042 1.7x
    Responses 8,192 4,161 3,865 1.1x
    Anthropic 1,000 469 222 2.1x
    Anthropic 4,000 1,831 1,001 1.8x
    Anthropic 8,192 5,175 3,215 1.6x

    Before these changes the wall-clock ratio was 2.3x to 5.6x (for example 4,560 ms vs 811 ms for chat at 4,000 rows).

    What changed (all exact, request bytes and token counts unchanged):

    • c8247bc: prompt-key writer buffers 64 KiB instead of one writeSync per JSON piece.
    • 9552ebe: detached journal appends go through bounded tail buffers.
    • 0811634: provider normalization checks the scratch directory per row batch instead of a statSync per row.
    • d1b0567: normalization index probes read a slot window instead of one syscall per slot.
    • 4185448: each normalization stage keeps its rows in an append-only log read by position.
    • c4b62cd: lookups of never-stored flag keys are answered by a fixed-size membership filter.
    • 5a78cd5: the counter schema is built once.
    • a671205: chunked o200k counting batches small pieces.
    • 0d1051f: tool output limiting skips tokenizing output whose byte count already fits the limit.

    What remains is the work this issue describes: every send still normalizes every surviving row. At 1,000 rows that fixed cost is most of the gap (about 150 to 250 ms). Checkpointing normalization across sends, as proposed above, is the next step.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Context ManagementIssues related to managing the context sent to the model.

    Type

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions