Skip to content

perf(app): cache storage namespaces and batch writes in the renderer - #47704

Merged
Hona merged 4 commits into
v2from
persist-storage
Sep 7, 2026
Merged

perf(app): cache storage namespaces and batch writes in the renderer#47704
Hona merged 4 commits into
v2from
persist-storage

Conversation

@Hona

@Hona Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member

First of three layers replacing the renderer's persistence write path with the model VS Code uses (vs/base/parts/storageMemento → per-resource backups). This layer is the transport: one bulk load per namespace, Map reads after that, one bulk write per flush window.

Stack: #47704 (this)#47705 (persisted() as a Memento) ← #47706 (large draft text as chunked blobs)

Before

Every persisted() mount did one StorageGet IPC per key, and every setter call did one StorageSet IPC. A tab close was 5–9 round trips; a window mount was one per persisted key.

After

flowchart LR
  P["persisted(target)"] --> N["NamespaceStorage(name)<br/>cache: Map · inserts · removes"]
  N -- "items(name) once" --> M[main state store]
  N -- "update(name, insert, remove)<br/>one per 100 ms window" --> M
  M -- "StorageChanged" --> O["other windows → accept()"]
Loading
  • packages/app/src/runtime/persistence/namespace.tscreateNamespaceStorage(driver, name): an AsyncStorage that loads the namespace once, serves reads from memory, coalesces writes into one update per namespaceFlushDelay (100 ms), and exposes flush(). A failed update keeps unsuperseded entries queued. Writes queued while the load is in flight win over the loaded snapshot.
  • Desktop platform (renderer/platform/storage.ts) builds one namespace per platform.storage(name), flushes them all before the IPC runtime disposes (onBeforeDispose in ipc-client.ts) and when the window is hidden.
  • IPC: StorageGet/Set/Delete/Keys/LengthStorageItems + StorageUpdate (+ unchanged StorageClear). Main's state store gains items(name) / update(name, insert, remove) over the existing write-behind.
  • Cross-window freshness: StorageUpdate broadcasts StorageChanged to every other window, which applies it via accept() — never overriding a key that window has queued. Same shape as VS Code's onDidChangeItemsExternal.
  • persisted() and every call site are untouched; the seam was already platform.storage(name).
// one round trip for the whole window namespace, then Map lookups
const ns = createNamespaceStorage(driver, "opencode.window.<id>.dat")
await ns.getItem("tabs") // items() once
await ns.getItem("tabs.recent") // cache
ns.setItem("tabs", "[…]") // queued
ns.setItem("tabs.closed", "[…]") // queued
// → one update(name, { tabs, "tabs.closed" }, []) 100 ms later

Host round trips per tab close

In-process benchmark on the real persisted() stores a tab close touches (tabs, tabs.recent, tabs.info, tabs.panes, tabs.closed).

v2 this PR
Host round trips per close 5 1

@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 added 2 commits September 7, 2026 11:59
Every persisted() write was one IPC round trip and every mount one read per key. The desktop platform now hands persisted() a NamespaceStorage: one bulk load per namespace, Map reads after that, and writes coalesced into one StorageUpdate per 100 ms window, flushed before the IPC runtime disposes and when the window is hidden. Other windows learn about writes through a StorageChanged event so their copies stay fresh. Mirrors VS Code's Storage class.
flush() cleared the pending maps before the driver had accepted the batch,
and those maps were the only guard used by the initial load, accept(), and
the retry path. A pending load or an older external change could overwrite a
value in flight, and a failed batch could requeue a value a later batch had
already replaced. Each write now records a sequence number that stays until
the host accepts that exact write. Batches are also handed to the driver
synchronously instead of behind the previous reply, so a flush on pagehide is
on the wire before the renderer goes away.
@Hona

Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Addressed in 37c2ce0a57 (fix(app): track namespace writes by sequence until the host accepts them). All three findings had one root cause — the pending maps were both the write queue and the only ordering guard, and flush() cleared them before the host had accepted anything — so the fix is one mechanism rather than three patches.

#2 / #3 — per-key sequence numbers. Every local write records { seq, value } in local, which is kept until the host confirms that seq. load(), accept(), and the retry path all consult local, so a pending snapshot, an older external change, or a failed older batch can no longer replace a newer local value. Both reviewer sequences are now tests: a pending load or an external change cannot overwrite a value that is in flight and a failed batch does not requeue a value a later batch already replaced.

#1 — posting, not a handshake. The loss you describe comes from inflight.then(() => driver.update(...)): a batch cut while an earlier reply was outstanding waited for that reply before it was even posted, and the renderer could die first. Batches are now handed to the driver synchronously when they are cut. I measured with the transport harness that a request reaches port.postMessage synchronously inside runPromise (before any await), and MessagePort writes go straight into the Mojo pipe, which delivers messages already written when the sender goes away. So a pagehide/onBeforeDispose flush is on the wire before the page unloads, and the host applies it on receipt — the reply is not needed for durability. Ordering across concurrent batches is preserved by the port; the sequence check also makes a stale retry harmless. Test: a batch is handed to the driver synchronously, not behind an earlier reply.

I'd rather not add a close/reload veto handshake on top: it's a new main↔renderer protocol for a case the synchronous post already covers, and the remaining window (writes made in the last ≤100 ms and never reaching pagehide, i.e. a renderer crash) is the same one VS Code accepts.

Acks and StorageChanged events reach a window on different paths, so an
event for an older write could arrive after the ack for a newer one and,
with the local sequence guard already cleared, overwrite the cache. The main
state store now stamps every update with a monotonic revision, returned in
the ack and carried by change events and namespace loads. The renderer keeps
the revision behind each cached key and drops events that are not newer; an
event held back while a local write was in flight is applied after the ack
when the host ordered it later.
@Hona

Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Addressed in 21b08d598e (fix(app): order cross-window storage events by host revision). Agreed that local sequence numbers can't order host events once the ack has cleared the guard — the two paths are independent.

Host revision. The main state store stamps every update with a monotonic revision (++revision, process-local, which is sufficient because renderer caches die with the process too). It is returned in the StorageUpdate ack, carried by StorageChanged, and reported alongside StorageItems so a load has a floor.

Renderer. NamespaceStorage keeps the host revision behind each cached key (applied) plus the load floor. accept(insert, remove, revision) drops anything at or below the floor, and per key drops anything not newer than what the cache already reflects — so your sequence (A acked, B acked, then A's event reaches window 2) leaves "B". Two adjacent cases fall out of the same bookkeeping and are tested too:

  • an event that arrives while a local write for that key is in flight is held (deferred) and applied after the ack if the host ordered it later — otherwise the stale-cache problem would just move to the other side of the ack;
  • a load that resolves after an event with a higher revision does not clobber it.

Tests added in namespace.test.ts: an event that reaches a window after a newer ack for the same key is ignored (your reproduction, two storages over one revisioned host), an event held back during an in-flight write wins after the ack if the host applied it later, an event older than the initial load is ignored; plus state.test.ts asserts items()/update() return the revision.

…olds

The load merged snapshot entries into the cache but never removed keys the
snapshot lacked, so an insert event that arrived before the load resolved
survived a snapshot taken after the key was deleted, and the floor then
rejected the deletion event that would have corrected it. The snapshot is
now applied as the whole truth at its revision: keys it lacks are removed
unless a local write or a newer event already owns them.
@Hona

Hona commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Addressed in ad88059860 (fix(app): drop cached keys the initial namespace snapshot no longer holds).

The load now treats the snapshot as the whole truth at its revision: before merging its entries, any cached key the snapshot lacks is removed, unless a local write owns it or an event newer than the snapshot already placed it. Your sequence (insert @41 event lands before the load, snapshot @42 omits the key, delete @42 event is below the floor) now ends with the cache reading null, matching the host.

Test added: the initial load removes a key an older event inserted while the load was in flight, which also checks the counter-case — a key inserted by an event newer than the snapshot (@43 against a snapshot @42) survives the load.

@Hona
Hona merged commit dc46ecf into v2 Sep 7, 2026
8 checks passed
@Hona
Hona deleted the persist-storage branch September 7, 2026 02:33
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