perf(app): externalize large draft text into content-addressed chunks - #47706
Conversation
26c5990 to
f23b9d0
Compare
8e2e680 to
f96ffb5
Compare
2828c51 to
35504ae
Compare
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.
|
All four addressed in #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:
Desktop tests: #3 — surrogate pairs. #4 — failed uploads. A rejected |
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.
|
Both addressed in
Your two sequences are tests: |
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.
|
Both addressed in #1 — publish after restore. #2 — renamed ids. 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 |
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.
|
Both addressed in #1 — browser write ordering. #2 — restored image ids. |
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-pastefixture, ~1 MB) was a plaintextpart 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)"]drafts.ts: strings ofdraftTextThreshold(16 KB) or more are split intodraftTextChunk(64 KB) pieces. Each piece is content-addressed via the existingputBlob; 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 awritehook onpersistStore) so the draft store never re-parses the serialized form.setItem(key, string)remains for theAsyncStoragecontract.$.idswithjson_each, the browser IndexedDB collector readsblob.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.{ blob: { id } }) is unchanged.Typing after a 1 MB paste
In-process benchmark: real
createComposerState+ realcreateDraftStoreover a counting driver, 50 keystrokes appended to the paste, saves every 5 keys (≈10 keys/s against the 100 ms window from #47705).v2The 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.