Skip to content

fix(app): reuse hydrated composer history blobs - #46761

Merged
Hona merged 4 commits into
anomalyco:v2from
Hona:composer-history-cache
Sep 2, 2026
Merged

fix(app): reuse hydrated composer history blobs#46761
Hona merged 4 commits into
anomalyco:v2from
Hona:composer-history-cache

Conversation

@Hona

@Hona Hona commented Sep 2, 2026

Copy link
Copy Markdown
Member

Problem

  • Every composer mount hydrates both global history documents through the draft store decode path. For each image reference, decode called driver.getBlob(id) and only then consulted the module-level urls cache in blobUrl.
  • Result: a mount with 50 screenshot references issued 50 IndexedDB blob reads and materialized ~12 MB of Blob objects, even when all 50 URLs were already live from a previous mount, and even when the 50 references pointed at only 5 distinct images.

Change

packages/app/src/runtime/persistence/drafts.ts: consult the live URL map before reading, and coalesce concurrent reads of the same id.

const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
  const existing = urls.get(id)
  if (existing) return existing
  const pending = loading.get(id)
  if (pending) return pending
  const next = driver
    .getBlob(id)
    .then((blob) => (blob ? blobUrl(id, blob) : undefined))
    .finally(() => loading.delete(id))
  loading.set(id, next)
  return next
}
  • No store setter, admission, or URL revocation behavior changed. Missing blobs and failed reads are not cached; the next decode retries.
  • Adds test-browser/draft-history-cache.test.ts (concurrent history/draft reads, repeated references in one document, latest document after remount, just-stored attachments, missing blobs, failed reads, distinct ids).
  • Adds the manual e2e/performance/composer-history benchmark (production ComposerEditor, createComposerEditor, createComposerHistory, persistence codec, browser IndexedDB draft store).

Benchmark

  • Workload: 100 normal + 100 shell history entries (two documents, 69–75 KB stored JSON). Image cases add 50 references to 1440×900 PNG screenshots (~244–249 KB each): unique = 50 distinct images (12.43 MB stored), repeated = 50 references to 5 images (1.22 MB stored). text has no images and acts as the control.
  • Each isolated browser context mounts an empty composer cold (no live URLs), then remounts warm. Timing gate is both production history stores populated (historyReadyMs); the test then verifies ArrowUp recall and a decoded image before recording.
  • Baseline 335e4ca vs candidate c8c0b4c, frozen production bundles, counterbalanced ABBA order (baseline, candidate, candidate, baseline), 10 repeats per block, 20 samples per cell. Chromium 147, Windows.

Mechanism (identical in all 20 samples per cell)

Case IndexedDB blob reads Blob bytes read
unique / cold 50 → 50 12.43 MB → 12.43 MB
unique / warm 50 → 0 12.43 MB → 0
repeated / cold 50 → 5 12.17 MB → 1.22 MB
repeated / warm 50 → 0 12.17 MB → 0
text / cold, warm 0 → 0 0 → 0

History availability, historyReadyMs median (p95), baseline → candidate

Case Baseline Candidate
text / cold (control) 48.8 (56.3) 50.9 (77.1)
text / warm (control) 26.8 (29.2) 26.5 (28.9)
unique / cold 71.6 (100.5) 80.5 (140.0)
unique / warm 33.2 (38.2) 31.7 (36.4)
repeated / cold 60.6 (72.9) 61.7 (80.4)
repeated / warm 33.6 (37.5) 31.1 (35.0)
  • Latency did not move outside the band of the text control, which performs no blob reads in either build. The first candidate block was uniformly slower across every case including text (per-block medians below), which inflates the aggregate cold p95 values. No latency, FPS, or memory improvement is claimed from this run.
  • recallObservedMs (ArrowUp → correct text and decoded image, includes Playwright overhead) also stayed within the control band.
  • Blob reads on this path run concurrently, so wall time is bounded by the slowest read rather than the count; the win here is removed redundant IndexedDB work and transient Blob allocation, not a measured latency change.
Per-block historyReadyMs medians (10 samples each)
Case 0 baseline 1 candidate 2 candidate 3 baseline
text / cold 46.5 57.4 50.2 52.6
text / warm 27.3 26.3 26.9 26.5
unique / cold 68.6 87.8 71.1 72.9
unique / warm 32.9 33.0 31.2 34.4
repeated / cold 57.0 62.1 61.1 61.5
repeated / warm 32.0 30.6 31.3 35.6

Desktop path (Electron MessagePort + main-process SQLite)

Added after review to close the desktop evidence gap. Same workload and ABBA protocol, but every blob crosses the production desktop path: renderer api.tsipc-client.ts (Effect RPC, MessagePack) → Electron MessagePortIpcServerProtocolLivestorageHandlersDesktopStoragenode:sqlite.

  • Isolated Electron 42.10.1 (Chrome 148.0.7778.280, Node 24.18.1) main running the production DesktopStorage.layer, storage/event RPC handlers, RpcServer + IpcServerProtocolLive, and the real preload; renderer runs the real api.ts, ipc-client.ts, createDesktopStorage, and the same production composer editor/history fixture. Fixture-owned userData per launch; seeded through DraftsPutBlob/DraftsSet and flushed to SQLite before the cold mount. No app shell, service, updater, or menus.
  • Stored screenshots: 12.49 MB (unique, 50 images) / 1.22 MB (repeated, 5 images); documents 77 KB. Renderer bundles frozen per revision (baseline drafts.ts c16694a7, candidate 544f21dd); main bundle, preload, and Electron shared. 10 repeats per block, 20 samples per cell, Windows.

Per mount, main → renderer (identical in all 20 samples per cell)

Case DraftsGetBlob RPCs Bytes over the MessagePort Messages
unique / cold 50 → 50 16.74 MB → 16.74 MB 52 → 52
unique / warm 50 → 0 16.74 MB → 77.5 KB 52 → 2
repeated / cold 50 → 5 16.38 MB → 1.71 MB 52 → 7
repeated / warm 50 → 0 16.38 MB → 77.5 KB 52 → 2
text / cold, warm 0 → 0 69.2 KB → 69.2 KB 2 → 2

12.49 MB of blobs cross as 16.66 MB because RpcSerialization.layerMsgPack uses the JSON codec, so Schema.Uint8Array travels as base64 inside MessagePack (×4/3). Pre-existing; not changed here.

Median (p95), baseline → candidate

Case History available, ms Mount → recalled text + decoded image, ms Main CPU, ms Main SQLite read + copy, ms Renderer peak working set rise
text / cold (control) 57 (67) → 64 (74) 157 → 172 31 → 31 0 → 0 +17 MB → +17 MB
text / warm (control) 31 (37) → 35 (42) 207 → 226 16 → 31 0 → 0 +6 MB → +6 MB
unique / cold 1141 (1266) → 1431 (1613) 1262 → 1578 953 → 1218 32 → 39 +297 MB → +294 MB
unique / warm 778 (866) → 47 (59) 853 → 158 836 → 31 34 → 0 +98 MB → 0
repeated / cold 1001 (1239) → 216 (296) 1142 → 435 844 → 235 18 → 3.8 +281 MB → +48 MB
repeated / warm 709 (878) → 49 (62) 800 → 143 750 → 32 18 → 0 +94 MB → 0
  • Main CPU is process.cpuUsage() user + system over the mount window (Windows ~16 ms tick). SQLite read + copy is the wall time inside drafts.getBlob (query plus new Uint8Array(data).buffer). Peak working set is the natural (no forced GC) peakWorkingSetSize increase of the renderer process above its pre-mount peak from app.getAppMetrics(); it shows the transient RSS spike of the base64 → string → Uint8ArraysliceBlob chain, not retained memory.
  • unique / cold does identical work in both builds (50 reads, 16.74 MB). The candidate blocks ran ~25% slower, but main-only metrics for identical main work moved by the same ratio across blocks (SQLite read 28/33 → 39/39 ms; main CPU 875/1016 → 1156/1227 ms) and the text control moved the same direction, so this is machine drift during the middle ABBA blocks, not a build effect. Treat that row as unchanged.
  • The recall step alone (ArrowUp → text + decoded 1440×900 image) is 15–20 ms slower in the candidate's warm cells (unique / warm 37 → 59 ms) because the key press now lands ~700 ms sooner and overlaps post-mount work that previously finished during the slow hydration. The user-visible total from mount to recalled image is the column above.
  • Per-block medians for every cell are in the run artifacts; the warm and repeated improvements hold in both candidate blocks against both baseline blocks.

Scope and limitations

  • The browser benchmark covers the IndexedDB path; the desktop section covers the Electron MessagePort and main-process SQLite path without the app shell, background service, packaged build, or non-Windows platforms. Renderer allocations are not attributed by a profiler; blob bytes are never retained on the JS heap.
  • The database is seeded immediately before each context, so cold here means no live object URLs, not a cold disk cache.
  • Object URL revocation is intentionally out of scope. clonePrompt shallow-copies image parts and async submit later calls blobDataUrl on them, so unmount and document deletion are not safe last-consumer boundaries; that needs a separate ownership contract.
  • Both history documents are still read on every mount; only blob hydration changed.

@Hona
Hona marked this pull request as ready for review September 2, 2026 07:47
@Hona
Hona requested a review from Brendonovich as a code owner September 2, 2026 07:47
Copilot AI lite review requested due to automatic review settings September 2, 2026 07:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The caching/coalescing change is localized, preserves existing semantics on missing/failed reads, and is backed by focused automated tests plus a manual benchmark harness.

Pull request overview

This PR reduces redundant IndexedDB blob reads during composer history hydration by reusing already-live object URLs and deduplicating concurrent blob loads for the same blob id, improving efficiency for histories with many image references.

Changes:

  • Update the draft-store decode path to consult the live URL cache before reading blobs and to coalesce concurrent getBlob(id) calls.
  • Add browser-level tests covering concurrent hydration, repeated references, remount behavior, missing blobs, and failed reads.
  • Add a manual Playwright/Vite benchmark fixture to measure blob read counts/bytes and capture hydration timing metrics for cold vs warm mounts.
File summaries
File Description
packages/app/src/runtime/persistence/drafts.ts Reuses cached object URLs and deduplicates in-flight blob reads during decode to avoid redundant IndexedDB work.
packages/app/test-browser/draft-history-cache.test.ts Adds targeted tests validating URL reuse and coalesced blob reads across concurrent and repeated hydration cases.
packages/app/e2e/performance/composer-history/vite.config.ts Adds a Vite build config for the manual composer-history hydration benchmark fixture.
packages/app/e2e/performance/composer-history/playwright.config.ts Adds a Playwright config to run the manual benchmark with a controlled preview server and output directory.
packages/app/e2e/performance/composer-history/index.html Provides the minimal HTML entrypoint for the benchmark fixture.
packages/app/e2e/performance/composer-history/fixture.tsx Implements the production-component benchmark fixture, seeds IndexedDB, and records blob/document read mechanism metrics.
packages/app/e2e/performance/composer-history/composer-history.bench.ts Adds the benchmark test driver that mounts cold/warm, validates recall + image decode, and reports samples.
packages/app/e2e/performance/composer-history/README.md Documents benchmark scope, workload, metrics, and how to run it from packages/app.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@Hona
Hona merged commit fa4f8a6 into anomalyco:v2 Sep 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants