Skip to content

perf(app): externalize large draft text into content-addressed chunks - #47706

Merged
Hona merged 5 commits into
v2from
persist-blobs
Sep 7, 2026
Merged

perf(app): externalize large draft text into content-addressed chunks#47706
Hona merged 5 commits into
v2from
persist-blobs

Conversation

@Hona

@Hona Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member

Third layer. Large text leaves the draft document and becomes fixed-size, content-addressed chunks, so typing after a big paste uploads one chunk per save instead of the paste — VS Code's rule that editor content lives in per-resource backups, never in the state database.

Stack: #47704 (namespace cache + bulk IPC) ← #47705 (persisted() as a Memento) ← #47706 (this)

Before

A 25 000-line paste (the composer-large-paste fixture, ~1 MB) was a plain text part in the prompt document. Every save re-serialized it, re-parsed it in the draft store, re-serialized it again, and sent ~1 MB over IPC to be written as one row.

After

flowchart LR
  P["persisted() write(value)"] -- "encoded object" --> D["draftStore.setDocument"]
  D --> E["encode(): strings ≥ 16 KB<br/>→ 64 KB chunks"]
  E -- "cache hit: reuse id" --> R["{ blob: { kind: 'text', ids: [...] } }"]
  E -- "miss: putBlob(chunk) once" --> B[(blob table)]
  R --> W["document write (small)"]
Loading
  • drafts.ts: strings of draftTextThreshold (16 KB) or more are split into draftTextChunk (64 KB) pieces. Each piece is content-addressed via the existing putBlob; a content-keyed cache returns the id without hashing or sending an unchanged chunk. Reads join the chunks. A missing chunk decodes to "" so the rest of the document survives.
  • setDocument(key, document) on the draft store takes the encoded object; persisted() uses it (via a write hook on persistStore) so the draft store never re-parses the serialized form. setItem(key, string) remains for the AsyncStorage contract.
  • Blob collectors understand chunk lists: the desktop SQL walks $.ids with json_each, the browser IndexedDB collector reads blob.ids. The desktop collector also runs after a document flush once a minute when blobs were written, so retired chunks don't accumulate until restart.
  • The image path ({ blob: { id } }) is unchanged.
-- desktop blob GC now keeps both shapes alive
SELECT json_extract(node.value, '$.id') … WHERE json_type(node.value, '$.id') = 'text'
UNION
SELECT chunk.value FROM …, json_each(node.value, '$.ids') AS chunk WHERE json_type(node.value, '$.ids') = 'array'

Typing after a 1 MB paste

In-process benchmark: real createComposerState + real createDraftStore over a counting driver, 50 keystrokes appended to the paste, saves every 5 keys (≈10 keys/s against the 100 ms window from #47705).

v2 #47705 only this PR
UI-thread cost per key incl. persistence 4.61 ms 0.90 ms 0.96 ms
Document payload for 50 keys 1 074 KB 10 × 1 074 KB 3 KB
Blob uploads for 50 keys 10 × 1 074 KB (whole-string blobs) 10 × ≈26 KB (final chunk only)

The middle column is why chunking is not optional: a whole-string blob changes on every keystroke, so content-addressing alone re-uploads the paste each save.

Known limit: inserting in the middle of a large paste shifts every later chunk boundary, so that save re-uploads the tail after the edit point. Appending — the common case after a paste — touches one chunk.

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Hona
Hona force-pushed the persist-blobs branch 2 times, most recently from 26c5990 to f23b9d0 Compare September 7, 2026 02:15
@Hona
Hona force-pushed the persist-blobs branch 2 times, most recently from 8e2e680 to f96ffb5 Compare September 7, 2026 02:33
@Hona
Hona force-pushed the persist-blobs branch 2 times, most recently from 2828c51 to 35504ae Compare September 7, 2026 02:51
Base automatically changed from persist-memento to v2 September 7, 2026 03:01
A pasted crash report lived inline in the draft document, so every save
re-serialized and re-sent hundreds of kilobytes. Strings at or above
draftTextThreshold are now split into draftTextChunk-sized content-addressed
blobs referenced as { blob: { kind: "text", ids: [...] } }. A content-keyed
cache keeps unchanged chunks from being hashed or sent again, so typing after
a paste uploads one chunk per save. persisted() passes the encoded document
to the draft store through setDocument so the store never re-parses the
serialized form. Both blob collectors understand chunk lists, and the desktop
collector also runs after a document flush once a minute so retired chunks do
not accumulate until restart. Follows VS Code's rule that editor content lives
in backups, not the state database.
Live collection deleted blobs that no stored document referenced yet or any
more while the renderer still held their ids: an attachment uploaded ahead of
its document save, or a chunk the renderer cache republished on undo. Blobs
now record touched_at, set on upload and refreshed for every blob a written
document references in the same flush, and live collection only removes blobs
unreferenced and untouched for blobGrace. The renderer reuses a cached chunk
id without an upload for at most draftChunkCacheTtl, well inside that grace.
Chunk boundaries no longer split a surrogate pair, and a failed upload is
evicted from the cache so the next save retries it.
@Hona

Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

All four addressed in 8661d22fb1 (fix(app): keep referenced chunks alive across live blob collection).

#1 and #2 — one mechanism. Both are the same defect: live collection judged liveness by stored documents alone, while the renderer legitimately holds ids that no stored document references yet (an attachment uploaded before its 100 ms save window) or any more (a chunk republished on undo from the content cache). The fix is a grace period keyed on last reference:

  • blob.touched_at (new migration 20260907031611_blob-touched), set on putBlob and refreshed inside the same flush transaction for every blob a written document references — so a chunk republished from the renderer cache is refreshed even though no upload happened.
  • Live collection removes only blobs that are unreferenced and untouched for blobGrace (15 min). Startup collection stays unconditional, since no renderer can hold an id then.
  • The renderer reuses a cached chunk id without an upload for at most draftChunkCacheTtl (5 min) since its last use, and every use produces a document write that touches the blob within the write-behind delay. 5 min ≪ 15 min, so a cache hit can never publish a collected id. Past the ttl the chunk is uploaded again (onConflictDoUpdate on the host just refreshes touched_at).

Desktop tests: collects a retired chunk only once it is unreferenced and past the grace period, a document that republishes a cached chunk id refreshes the chunk without an upload (your undo sequence), an uploaded attachment survives a due collection before its document is written (your attachment sequence). App test: a cached chunk id is uploaded again once it is older than the cache ttl, which also clears the fake host's blobs in between and round-trips through a fresh store.

#3 — surrogate pairs. split() extends a piece by one code unit when it would otherwise end on a high surrogate. Test uses your "x".repeat(draftTextChunk - 1) + "😀tail" and asserts the round trip through the stored blob bytes and a fresh store, plus that the first piece is draftTextChunk + 1 long.

#4 — failed uploads. A rejected putBlob promise is evicted from chunkIds (guarded so a newer entry for the same content is not removed), so the next save retries. persisted()'s draft write now also logs a rejected setDocument instead of leaving it unhandled. Test: a failed chunk upload is retried on the next save instead of being reused.

No collection schedule can be safe while renderers hold blob ids the store
cannot see: another browser tab collects on open, and the composer keeps image
references in its history indefinitely. Every document write now reports the
referenced blob ids the store lacks, and the renderer uploads their bytes
again from the chunk text or the image Blob it still holds. Ids are content
hashes, so the stored reference becomes valid without a rewrite. The renderer
chunk-id cache no longer needs a time limit, which also removes a re-upload
of every chunk each five minutes.
@Hona

Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Both addressed in 6d080ab3ba (fix(app): repair blob references the store no longer holds on write) — and you've convinced me the previous approach was the wrong shape. Timing-based protection (ttl vs grace) cannot be made sound while renderers hold ids the store can't see: the browser has no host process at all, and the composer keeps image ids in savedHistory indefinitely. So the guarantee now comes from validation on write with repair, independent of any collection schedule:

  • Driver.set(key, value) returns the referenced blob ids the store does not hold. Desktop main answers with one SQL query over the (small, chunk-referencing) document — json_tree refs NOT IN blob — no JS parsing; the browser IndexedDB driver checks its blobs store.
  • setDocument uploads the missing bytes again. During encode it records where each referenced blob's bytes can be produced: the chunk text itself, or the image Blob (kept alongside its object URL — URL.createObjectURL already pins it, so this costs no extra memory — with fetch(url) as the fallback). Ids are content hashes, so the queued document's reference becomes valid without a rewrite, and the write-behind flush that follows references the blob before any collection can run.
  • The 5-minute cache ttl is removed: it was only a safety heuristic and it forced a re-upload of every chunk every five minutes. The desktop touched_at/blobGrace stays as an optimisation that keeps repairs rare (one cheap UPDATE per document flush).

Your two sequences are tests: a cached chunk id the store no longer holds is uploaded again on the next save (tab A/B: the driver's blobs are cleared between saves, undo republishes the cached id, a fresh store reads the text back) and an image reference whose blob was collected is restored from its object url (history image whose bytes were collected; the save republishes it and the bytes are back). Desktop: set reports referenced blobs the store does not hold so the renderer can upload them again.

Repair ran after the document was written, so another window could read a
reference without bytes and normalize the image away before the upload
finished; and it assumed re-uploading recreates the same id, which a store
without WebCrypto does not. A strict write is now refused while any
referenced blob is missing, keeping the previous document visible; the
renderer uploads the bytes, renames references to the ids the uploads
returned, updates its caches, and then publishes. The common case is still
one round trip; only a repair pays more.
@Hona

Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Both addressed in 257f94df44 (fix(app): publish a repaired draft only after its blobs are restored).

#1 — publish after restore. Driver.set(key, value, strict) now refuses a strict write while any referenced blob is missing and stores nothing, so the previously stored document stays visible to other windows. setDocument does a strict write first; in the common case that is the only round trip. When ids come back missing it uploads the bytes, then does a non-strict write of the repaired document. Test: the previous document stays visible until missing blobs are restored holds the repair upload on a gate and asserts the stored document is unchanged while it is pending.

#2 — renamed ids. restore() returns a map from the missing id to the id each upload actually came back with; rename() rewrites blob.id and blob.ids in the encoded document before the second write, and the chunk/image caches are updated under the new ids so the next save needs no repair. Test: references are renamed when a restored blob comes back under a different id uses a store that assigns fresh ids to every upload, and asserts the stored references changed, every referenced id is held, a fresh store reads the text and image back, and a subsequent save uploads nothing.

Anything still missing after restore has no bytes anywhere (an image reference whose blob was never loaded); it is published non-strictly and the owning codec drops it on read, as before. Desktop main honours strict (set reports referenced blobs … covers refuse vs. store), and the IPC payload gained the flag.

The browser driver checked blob references in separate IndexedDB
transactions from the document write, so a newer save or removal could
commit in between and be overwritten by the older write. The check and the
put now run in one readwrite transaction over both stores, which IndexedDB
serialises against later writes in creation order.

A restored image only had its bytes registered under the new id while the
live composer reference kept the original, so every later save republished
the missing id and uploaded the image again. Restored image ids are now
aliased, and encode publishes the id the bytes live under.
@Hona

Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Both addressed in e0ce800d32 (fix(app): order browser draft writes and reuse restored image ids).

#1 — browser write ordering. createBrowserDraftStore().set now runs the reference check and the document write in a single readwrite transaction over ["blobs", "documents"]. The put is issued from the last getKey success callback (never from a promise continuation), so the transaction is never left without a pending request and cannot auto-commit early. IndexedDB serialises overlapping readwrite transactions in creation order, so a later small save or removeItem() (also readwrite on documents) is ordered after this one and can no longer be overtaken by the older write. The desktop path was already ordered (one synchronous IPC handler). I couldn't add an automated test for this — the repo has no IndexedDB in the Bun test environment and no fake-indexeddb dependency — so this one rests on the transaction model; your IndexedDB reproduction is the check.

#2 — restored image ids. restore() records aliases: old → new when an image comes back under a different id (re-pointing any earlier chain so lookups stay one step), and encode resolves a live reference's id through it before publishing and before looking up its bytes. The existing random-id test now continues with three cursor-only saves of the still-live reference and asserts the published image id stays the restored one and nothing is uploaded.

@Hona
Hona merged commit 5d971e5 into v2 Sep 7, 2026
8 checks passed
@Hona
Hona deleted the persist-blobs branch September 7, 2026 04:17
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