Skip to content

perf(storage): artifact metadata rewrites the full record table on every mutation (O(M×N) during startup) #4037

Description

@me2seeks

Problem

Every artifact metadata mutation rewrites the entire artifact_records table, so a store with N records pays O(N) JSON + SQL + hashing work per mutation — and startup performs M serialized mutations (session-retirement purges, recovery adoptions), making cold start O(M×N).

Mechanism in packages/storage/src/artifact-store.ts + sqlite-artifact-metadata.ts:

  1. Every mutation begins with reloadForMutationUnlocked() → metadataRepository.readAll(): SELECT record_json FROM artifact_records + decodeArtifactRecordJsons() over all N rows.
  2. Every mutation ends with metadataRepository.replaceAll(records): DELETE FROM artifact_records, then re-INSERT all N rows, each with a fresh JSON.stringify(record) and a fresh sha256 artifactIdentityKey(record.id).

Measured impact

On a real store with ~11.7k artifact records and hundreds of accumulated Sessions (details in this #4027 comment), a windowed CPU profile of Runtime Host cold start shows the first ~90 s of the ~130 s total saturated by exactly this churn: ~34% of samples in the per-record INSERT, ~11% in the DELETE, ~6.5% in JSON decode of the full read-back, ~5.4% in the SELECT ... all(), and ~5.6% in sha256 (identity keys are re-hashed for every record on every rewrite). After ~105 s the Host is idle; the metadata phase is the residual cold-start bottleneck.

Steady state pays the same O(N) cost on every single-artifact mutation (create / purge / adopt), just without the M multiplier.

Proposed direction

Change-tracked write-back in replaceAll's place: within the same single write transaction, apply only the delta the mutation actually made — targeted INSERT ... ON CONFLICT(storage_key) DO UPDATE / DELETE WHERE storage_key IN (...) — instead of deleting and re-inserting the full snapshot. Additionally, compute the sha256 artifactIdentityKey once per record (it is a pure function of the record id) rather than once per record per rewrite.

Combined with a batched multi-Session purge (separate proposal), this reduces a retirement batch of M Sessions from M full-table rewrites to one O(changed) commit.

Questions for maintainers

  1. Crash-atomicity contract: today replaceAll persists the post-mutation snapshot as one atomic transaction. Targeted writes would run inside the same single write transaction, so atomicity should be equivalent — is there any consumer that relies on the table being a full rewrite (e.g. for recovery or consistency checking) rather than just "the persisted state after transaction commit"?
  2. Source of truth: the in-memory record map is authoritative during a mutation and the table is its persistence. Any objection to keeping that model with delta persistence, or is a deeper redesign (e.g. querying SQLite instead of holding the map) preferred?
  3. Is a prototype welcome once the direction is confirmed?

Related: #4027 (cold-start investigation), #4031 (bounded purge resolution; review thread identified the O(M×N) structure).

This issue was prepared with AI assistance (Kimi k3-256k), including profiling and analysis.

Activity

  1. me2seeks commented on Aug 27, 2026

    @me2seeks
    ContributorAuthor

    Companion proposal: #4038 (batched multi-Session purge). Batching removes the M multiplier on metadata commits for the retirement path; this issue's change tracking removes the O(N) cost of each remaining commit.

  2. me2seeks commented on Aug 28, 2026

    @me2seeks
    ContributorAuthor

    Heads-up: this proposal is likely superseded by the retirement direction in discussion #4030. That discussion has converged on retiring the Artifact authority entirely, and its stated cutover invariants already subsume this issue ("surviving metadata stores must use indexed row operations rather than readAll/replaceAll"). If retirement proceeds on that timeline, a contract-level change to the current ArtifactStore metadata layer is probably not worth the review investment. I'm repurposing this issue as a fallback stopgap option in case the retirement takes longer than expected — happy to close it if maintainers prefer to track everything under the retirement work.

    This comment was prepared with AI assistance (Kimi k3-256k).

  3. added theissue type on Aug 29, 2026
  4. 12 remaining items

  5. github-actions commented on Sep 5, 2026

    @github-actions
    No description provided.
  6. Astro-Han commented on Sep 5, 2026

    @Astro-Han
    Contributor

    Reopening: #4716 landed only half of this issue.

    The Mechanism section above describes two costs per mutation. #4716 replaced step 2 (replaceAll → change-tracked INSERT ... ON CONFLICT / DELETE), and artifactIdentityKey is no longer re-hashed for unchanged rows. That part is done and should stay closed.

    Step 1 is untouched. ArtifactStore.load() and reloadForMutationUnlocked() both still call metadataRepository.readAll() — a full SELECT record_json plus decodeArtifactRecordJsons() over all N rows — on every read path and again before every mutation. In the profile above that is the ~6.5% decode + ~5.4% SELECT share, and unlike the write side it has no M multiplier to remove: it is paid per operation in steady state too.

    The open design question is Question 2 in the description, which was never answered: does the in-memory record map stay authoritative (and something has to define when it is invalidated), or does ArtifactStore query SQLite by session/id through the indexes and stop holding the full set? The second reading matches the readAll/replaceAll retirement invariant from #4030. This issue is the tracker for that decision — #4030 is closed and lists #4037 as one of its four execution threads, so the "likely superseded" note above no longer applies.

  7. lhpqaq commented on Sep 5, 2026

    @lhpqaq
    Member

    take

  8. Astro-Han commented on Sep 5, 2026

    @Astro-Han
    Contributor

    @lhpqaq thanks for taking this — two things that may save you a round trip.

    Question 2 in the body has an answer already on record. #4030 states the cutover invariant as "surviving metadata stores must use indexed row operations rather than readAll/replaceAll". That settles the direction: query SQLite by index, stop holding the full table in memory. The indexes are already there (artifact_id primary key, plus (session_id, created_at, artifact_id)).

    One seam worth planning for before you start. Revision is computed from the whole session. sessionSnapshot() (packages/storage/src/artifact-store.ts:851) filters, sorts, and hashes every record in the session, and getInSession and listPage both return it. So replacing readAll() with a per-Session query, on its own, still decodes an entire Session to answer a single-record read — the N just changes what it counts. Either revision becomes a value maintained inside the metadata transaction, or its cost needs to be stated explicitly.

    A useful acceptance check: adding records to other Sessions must not change the work done by a read, and adding records to the target Session must not change the work done by a single-record read — including obtaining its revision.

  9. lhpqaq commented on Sep 6, 2026

    @lhpqaq
    Member

    @Astro-Han Thanks for the clarification. I've used indexed queries and transaction-maintained Session revisions, with tests covering both growth checks. The implementation and verification details are in #4874 — feedback welcome.

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workinghelp wantedExtra attention is needed

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions