feat: recover query caches after a backend restart - #202
Conversation
Adds an optional `subscribeToBackendRestart` prop to `ComapeoCoreProvider`. When the listener fires, every query under this package's shared key prefix is invalidated, so per-project API instances bound to the dead backend are re-fetched instead of being served from a cache that never expires. Also fixes a `map-share` listener leak: the received map shares store attached its client API listener at creation and never removed it, so recreating the store orphaned the previous listener. The listener is now attached from an effect and removed on cleanup.
`SyncStore` attaches and removes its `sync-state` listener on the project wrapper it was built from. A backend restart closes that wrapper, and the removal runs in React effect cleanup, where a throw takes down the tree. Upstream `@comapeo/ipc` is being changed so that `off` on a closed wrapper is a no-op, but this package still supports the versions that throw.
A second `listen()` on the same store registered the client API listener twice, so every incoming share was added to the store twice. It now returns the existing teardown, and a teardown lets a later `listen()` attach again. Also notes at `monitor()` that the download event source has no error path, so a transport failure leaves the share stuck in `downloading`.
Invalidating everything under the shared key prefix does not recover the app. Project-derived queries (settings, members, documents) refetch immediately with a `queryFn` still closed over the project instance from the backend that went away; those calls reject, and a `useSuspenseQuery` with `retry: false` then latches into `status: 'error'`, which `shouldFetchOptionally` in query-core never retries. Queries cached with `staleTime: 'static'` are excluded from invalidation altogether, so the media server origin — whose port is ephemeral — kept every image URL pointing at a dead port for the life of the app. The restart listener now resets in three steps. It removes every query read through a project instance, including the static media server origin, so they cannot refetch with a dead closure. It then resets the cached project instances themselves: removal alone is invisible to a mounted observer, which keeps rendering its last result, whereas a reset suspends the component so it fetches a fresh instance and re-runs the removed queries against it. Finally it invalidates the remainder — device info, invites, the project list — which are read through the client API that survives the restart, so a background refetch is enough. The subscribe effect now depends only on the subscribe function, with the query client parked in a ref, and `SubscribeToBackendRestart` is exported so its contract (including that it should be referentially stable) documents itself. The README describes what the reset actually does and what it does not cover.
|
Reworked in ae9042c, dfb2e2c and 01fced6. The probes were right on all three counts, and I reproduced each one before changing anything. The invalidate was doing the wrong thing. With a mock client API handing out generation-tagged project instances that reject once the generation is bumped, invalidating the root prefix leaves But So the reset is three steps rather than two:
Ordering holds because every project-derived hook calls
Also addressed:
92 tests, lint and typecheck green. |
A query in flight when the backend's RPC transport drops rejects with code RPC_TRANSPORT_CLOSED — a read whose response will never arrive. Without a retry it latches into an error state during the seconds between the drop and the restart reset, flashing error boundaries for what is really continued loading. baseQueryOptions now retries exactly that code (bounded, 1s delay); the retried call sits in the transport's send queue until the restarted backend answers. Matched by error code, not instanceof, so a duplicated @comapeo/ipc in the tree can't break the check. Project-scoped queries reject with a different code on retry (their instance is closed) and stop — those are recovered by resetQueriesAfterBackendRestart as before. Mutations keep retry: false.
|
Follow-up (f60af9f): |
Measurement only — not intended to merge as-is. It answers "what does
node:sqlitecost in the Android lite binary", using the size a consumeractually pays: the stripped
libnode.sothat gradle packages into an APK.Release assets ship the binary unstripped, so that number isn't something
lson an artifact reproduces.Two arm64 legs, both configured
lite. The second drops--without-sqlitefrom the flavor's configure line in
android_configure.py; each leg assertsthat SQLite is (or isn't) present in the resulting binary, so a reworded
configure line fails the job rather than quietly measuring the same build
twice. The baseline reuses
build-android's cache key verbatim, so itrestores the binary the recipe ships instead of recompiling an approximation
of it, and only the sqlite leg pays for a compile.
The delta lands in the
comparejob summary, stripped and gzipped.