feat: recover hook state and query caches after a backend restart - #202
feat: recover hook state and query caches after a backend restart#202gmaclennan wants to merge 17 commits into
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): |
Nothing in the dependency tree has ever thrown RPC_TRANSPORT_CLOSED: an in-flight call whose transport drops rejects with rpc-reflector's ChannelClosedError (code RPC_CHANNEL_CLOSED), which @comapeo/ipc re-exports as RpcChannelClosedError and which is also what notifyTransportReset() rejects with. The retry predicate matched a code that never arrives, so it never fired. Also make PROJECT_LEFT explicitly non-retryable. It is already excluded by only retrying the channel-closed code, but a left project is the one rejection that is guaranteed never to resolve by waiting, so it is worth naming.
Under @comapeo/ipc v10 a project client reference is permanent, so resetting the project-instance query hands back the same reference and useSyncStore, which caches one SyncStore per reference in a WeakMap, hands back the same store. The store keeps throwing the error it latched when the backend went away, and nothing clears it. Under v9 a restart produced a new reference, so a fresh store was created as a side effect of the reset. A WeakMap cannot be iterated, so track the stores that currently have listeners in a module-level Set - joined on the first listener, left on the last, so an unused store is not retained - and re-read state on each of them as the last step of the restart recovery.
A download only ever leaves the downloading status because the map server tells it to over a server-sent event stream. The event source reconnects on its own and the promise had no failure path, so a map server that is gone for good - it dies with the backend when Android kills the process - left the share downloading for the life of the app. Give up after three reconnect attempts that deliver no event in between and move the share to error with code EVENT_STREAM_ERROR. A single dropped connection still recovers silently.
Invites are in-memory actors on the backend, so a restart drops them all and `invite.getMany()` is the only way to learn what the new backend has. Nothing invalidates that query on its own once the events raised during the disconnect window are lost, so assert that the root invalidation reaches it. Also cover a project this device has left, in both shapes @comapeo/ipc v10 produces it: a call on a reference acquired before leaving, and `getProject()` for a project left before it was ever fetched. Both must reach the error boundary with `code: PROJECT_LEFT` and without being retried.
A backend restart no longer closes project references: under @comapeo/ipc v10 a reference is permanent and its channel re-opens transparently against the new backend. Rewrite the restart section around what actually goes stale - the data read through the reference, including the sync state store added in the previous commit - and document the one case that is not recoverable, a project this device has left. Also flag that the media server origin is still read through a project instance with a fake blob id, which is why the restart reset has to reach outside the project namespace to drop it (#96).
The assertion encoded v9 semantics, where re-joining produced a new wrapper because the old instance was closed. Under @comapeo/ipc v10 the reference is permanent and the same wrapper comes back, so the identity is no longer a stable fact to assert on either major. What the regression actually needs - that the hooks reach a live instance after re-joining, rather than a closed one - is already covered by the settings query above.
Nothing in this package requires v10 to be correct: the channel-closed retry, the sync store refresh and the query reset all work against either major. Widen the range so consumers can move at their own pace. The devDependency stays on the published 9.0.1 until v10 ships.
An error latched by the store is thrown from getStateSnapshot(), which runs during render, so it reaches an error boundary - and the boundary unmounting the subtree removes the last listener. Two things then went wrong. The store left the active-store registry as it unsubscribed, so the restart refresh could not reach it; and the error stayed set, so the remount threw it again during render, before subscribe could run. With a permanent project reference (@comapeo/ipc v10) the remount is handed back that very store, so the screen was wedged for the life of the app - with or without a restart notification. Scope the error to the subscription that produced it: #connect() clears it before each read attempt, and #stopSubscription() clears it when the last listener goes. An unsubscribed store then needs no refresh at all - the next subscribe reads from scratch - so the registry rule stays as it was, and a store still being listened to is reached the way it always was intended to be. Also drop the state, not just the progress baselines, when refreshing: getDataProgressSnapshot() divides by the largest sync count seen so far, so the previous backend's state over cleared baselines read as 1 and every restart flashed "sync complete" before the real value arrived.
Three problems compounded. The give-up threshold was three reconnect attempts, which at eventsource-client's 2s default with no backoff is about six seconds - shorter than the Android backend restart this whole branch exists to handle, so it would fail exactly the downloads it was meant to rescue. The error status was absorbing, and the share stores are never reset, so a failed share stayed a dead row for the life of the app. And the downloads map was only cleared on success, so anything that did retry would abort against a download id that no longer existed. Measure the outage in elapsed reconnect delay against a 60s budget instead of counting attempts - there is no option to set the delay, and the server can change it with a `retry:` field, so summing the delays the client reports follows whatever it actually uses. Allow error -> downloading, the one status the user did not choose. Clear the downloads entry in `finally`.
The comment block still explained the reset as recovering from closed project instances and queryFn closures over a dead backend, and counted three steps where there are four - contradicting the README, which was already rewritten for v10. State the actual reason each step exists: references survive, the data read through them does not, the static-staleTime media server origin cannot be invalidated at all, and a mounted observer only refetches if its query is reset. Give the same treatment to the media server origin hack, which is what forces the reset to reach outside the projects namespace, and to the generational client-api fake, whose closed-instance behaviour models v9 rather than v10 - the harsher of the two, so it still covers both.
Dropping the identity assertion left the regression without anything to fail on. Assert instead what #199 was actually about: a call made directly through the reference the hooks hand out after re-joining reaches a live instance rather than the closed one that was cached before the leave. Asserted through the reference rather than only through the settings query, and deliberately not through the pre-leave reference - that one recovers under v10, where references are permanent, but stays closed under v9, so it is not a property both majors share.
The committed file carried v10 type names, but the devDependency and lockfile pin 9.0.1, so `npm ci && npm run docs:generate` silently reverted it for anyone who ran it. Generate it from the pinned version so the checked-in file is the one the toolchain reproduces.
RangerMauve
left a comment
There was a problem hiding this comment.
LGTM but maybe andrew can take a peek too?
When the backend process is killed and restarted (Android low-memory kills of the foreground service), the transport layer can reconnect and resubscribe — but this library's caches don't know anything happened: queries hold data from the dead process, the sync store's
useSyncExternalStoresnapshot freezes or wedges on a sticky error, and in-memory share state hangs. This PR gives the host app one entry point to fix that, plus the error-handling that makes the disconnect window itself survivable.API
ComapeoCoreProvideracceptssubscribeToBackendRestart, which the host calls after the transport has reconnected and resubscribed (comapeo-core-react-native exposes exactly this signal — digidem/comapeo-core-react-native#226). On the notification,resetQueriesAfterBackendRestartruns four steps: remove the queries whose data died with the process (everything below a project instance, plus the media-server origin, whosestaleTime: 'static'makes it structurally un-invalidatable); reset the project-instance queries so mounted observers suspend and refetch; invalidate the manager-level rest — which also re-pulls invites, whose backend state machines are in-memory and silently dropped by a restart; and refresh every sync store with an active subscription.That last step exists because of
@comapeo/ipcv10 (digidem/comapeo-ipc#88): project references are now permanent, so the v9-era accidental recovery — new instance ⇒ newSyncStorevia the WeakMap — is gone. Stores register while subscribed; refresh drops the stale state and per-device progress baselines (the snapshot honestly reportsnullrather than flashing 100%), clears any sticky error, and re-reads$sync.getState(). Sticky errors are also now scoped to the subscription that raised them — cleared on re-attach and on last-unsubscribe — which fixes a wedge where an error thrown fromgetStateSnapshotunmounted the subtree, unregistered the store, and then greeted every error-boundary retry with the same stale throw.Transport errors, retry, and left projects
Queries retry on
RPC_CHANNEL_CLOSED(the code rpc-reflector actually ships) — bounded, 3 retries at 1 s, queries only; mutations keepretry: false, since a mutation in flight at a process death is at-most-once and must surface, not repeat.PROJECT_LEFTis explicitly never retried. Note the matching is bycodestring deliberately: errors deserialize across the IPC boundary as plainErrors, soinstanceofis not just fragile, it is wrong.Map shares
The SSE monitor now has an error path: a stream that stays down for a ~60 s outage budget (summed from the client's reported reconnect delays; any delivered message resets it) transitions the share to
errorinstead of hanging indownloadingforever — anderroris no longer absorbing:download()may retry it. In-memory share state received before a restart is not recoverable (the backend neither persists nor re-serves it); that loss is documented rather than papered over.Compatibility
Peer range is
^9.0.0 || ^10.0.0. The suite passes against both (112 tests: v9 via the lockfile'snpm cistate, v10 via locally packed tarballs of the #88 branch); on v9 the new machinery is simply inert where v9 lacks the signals. The invite-rejoin regression test now pins re-join recovery through a direct call on the reference the hooks hand out, which holds under both majors' semantics.