From dd95e3f2ed31f3a4fcb93e5c433f24e4c8d2d159 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:34:25 +1000 Subject: [PATCH] perf(desktop): collect orphaned draft blobs after the window is up (#49797) --- .../desktop/src/main/storage/drafts.test.ts | 22 ++++++++++++++----- packages/desktop/src/main/storage/drafts.ts | 14 ++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/main/storage/drafts.test.ts b/packages/desktop/src/main/storage/drafts.test.ts index da61d73624ba..21a3ba4cbb19 100644 --- a/packages/desktop/src/main/storage/drafts.test.ts +++ b/packages/desktop/src/main/storage/drafts.test.ts @@ -17,17 +17,29 @@ describe("draft store", () => { expect(database.db.all(sql`SELECT key FROM document`)).toEqual([]) }) - test("stores blobs by content hash and collects unreferenced ones on open", () => { + test("stores blobs by content hash and collects unreferenced ones shortly after open", async () => { const database = openDatabase(":memory:") - const first = createDraftStore(database.db, { delay: 1_000 }) + let clock = 0 + const first = createDraftStore(database.db, { delay: 1_000, now: () => clock }) const used = first.putBlob(new Uint8Array([1, 2, 3])) const unused = first.putBlob(new Uint8Array([4, 5, 6])) expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used) first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] })) first.flush() - const second = createDraftStore(database.db, { delay: 1_000 }) - expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3])) - expect(second.getBlob(unused)).toBeNull() + first.close() + // The startup collection runs off the window's critical path and keeps the usual grace, so a + // blob from a session that ended within the grace period is still there... + const second = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 }) + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(second.getBlob(unused)).toEqual(new Uint8Array([4, 5, 6])) + second.close() + // ...and gone once the grace has passed. + clock = blobGrace + 1 + const third = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 }) + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(third.getBlob(used)).toEqual(new Uint8Array([1, 2, 3])) + expect(third.getBlob(unused)).toBeNull() + third.close() }) test("collects a retired chunk only once it is unreferenced and past the grace period", () => { diff --git a/packages/desktop/src/main/storage/drafts.ts b/packages/desktop/src/main/storage/drafts.ts index c50b4d9b64f0..b1710b9d7bc9 100644 --- a/packages/desktop/src/main/storage/drafts.ts +++ b/packages/desktop/src/main/storage/drafts.ts @@ -27,11 +27,14 @@ const referenced = (value: unknown) => sql` export function createDraftStore( db: Database, - input: { delay?: number; onError?: (error: unknown) => void; now?: () => number } = {}, + input: { delay?: number; collectDelay?: number; onError?: (error: unknown) => void; now?: () => number } = {}, ) { const now = input.now ?? Date.now - // Nothing outside this process can hold a blob id at startup, so no grace applies. - collectBlobs(db, Infinity) + // Orphans left by an earlier session are collected once the window is up rather than before it: + // the scan walks every stored document, and the usual grace keeps anything a renderer has + // uploaded in the meantime. + const startup = setTimeout(() => collectBlobs(db, now() - blobGrace), input.collectDelay ?? 10_000) + startup.unref() let collected = now() let orphans = false const byKey = eq(document.key, sql.placeholder("key")) @@ -104,7 +107,10 @@ export function createDraftStore( return data ? (data as Uint8Array) : null }, flush: writer.flush, - close: writer.close, + close() { + clearTimeout(startup) + writer.close() + }, } }