History

fix(client): keep warm thread resumes live instead of flashing sync (#9913)

Julius Marminge committed 2 months ago

Commit f87ecf01 parent fc1f5432 files · +110 −5Browse files

Unified · Split

2 files
  1. packages/client-runtime/src/state/threads-atoms.test.ts +89 −2
  2. packages/client-runtime/src/state/threads.ts +21 −3
packages/client-runtime/src/state/threads-atoms.test.ts+89−2
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
2727 PrimaryConnectionTarget,
2828 type NetworkStatus,
2929 type PreparedConnection,
30+ type SupervisorConnectionState,
3031 } from "../connection/model.ts";
3132 import { EnvironmentSupervisor } from "../connection/supervisor.ts";
3233 import { EnvironmentCacheStore } from "../platform/persistence.ts";
@@ -73,8 +74,18 @@ const THREAD: OrchestrationThread = { · 40 unmodified lines
7374 };
7475 const SNAPSHOT: OrchestrationThreadDetailSnapshot = { snapshotSequence: 7, thread: THREAD };
7576
77+const CONNECTED_STATE: SupervisorConnectionState = {
78+ ...AVAILABLE_CONNECTION_STATE,
79+ desired: true,
80+ network: "online",
81+ phase: "connected",
82+ attempt: 1,
83+ generation: 1,
84+};
85+
7686 const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: {
7787 readonly snapshot?: OrchestrationThreadDetailSnapshot;
88+ readonly connected?: boolean;
7889 }) {
7990 const subscriptions = yield* Queue.unbounded<{
8091 readonly afterSequence: number | undefined;
@@ -123,10 +134,14 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? · 42 unmodified lines
123134 probe: Effect.void,
124135 closed: Effect.never,
125136 };
137+ const connectionState = yield* SubscriptionRef.make(
138+ options?.connected ? CONNECTED_STATE : AVAILABLE_CONNECTION_STATE,
139+ );
140+ const sessionRef = yield* SubscriptionRef.make(Option.some(session));
126141 const supervisor = EnvironmentSupervisor.of({
127142 target: TARGET,
128− state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
129− session: yield* SubscriptionRef.make(Option.some(session)),
143+ state: connectionState,
144+ session: sessionRef,
130145 prepared: yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(
131146 Option.some({
132147 environmentId: TARGET.environmentId,
@@ -228,6 +243,9 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? · 95 unmodified lines
228243 ref,
229244 subscriptions,
230245 olderLoads,
246+ connectionState,
247+ session,
248+ sessionRef,
231249 counts: () => ({ httpLoads, diskLoads, opened, active }),
232250 };
233251 });
@@ -319,6 +337,75 @@ describe("createEnvironmentThreadStateAtoms", () => { · 85 unmodified lines
319337 }),
320338 );
321339
340+ it.effect.each([
341+ { replayed: false, statuses: ["live"] },
342+ { replayed: true, statuses: ["live", "synchronizing", "live"] },
343+ ])(
344+ "keeps a warm resume live until it replays events (replayed: $replayed)",
345+ ({ replayed, statuses }) =>
346+ Effect.gen(function* () {
347+ const h = yield* makeHarness({ connected: true });
348+ const unmount = h.registry.mount(h.stateAtom);
349+ const first = yield* Queue.take(h.subscriptions);
350+ yield* Queue.offer(first.events, { kind: "synchronized" });
351+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live");
352+ unmount();
353+ yield* Deferred.await(first.closed);
354+
355+ const observed: Array<EnvironmentThreadState["status"]> = [];
356+ const stop = h.registry.subscribe(h.stateAtom, (state) => observed.push(state.status), {
357+ immediate: true,
358+ });
359+ const remount = h.registry.mount(h.stateAtom);
360+ const next = yield* Queue.take(h.subscriptions);
361+ expect(next.afterSequence).toBe(7);
362+ if (replayed) {
363+ yield* Queue.offer(next.events, {
364+ kind: "snapshot",
365+ snapshot: { snapshotSequence: 9, thread: { ...THREAD, title: "Replayed" } },
366+ });
367+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "synchronizing");
368+ }
369+ yield* Queue.offer(next.events, { kind: "synchronized" });
370+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live");
371+ expect(observed.filter((status, index) => observed[index - 1] !== status)).toEqual(
372+ statuses,
373+ );
374+ stop();
375+ remount();
376+ yield* Deferred.await(next.closed);
377+ }),
378+ );
379+
380+ it.effect("downgrades a warm resume when the connection dropped while away", () =>
381+ Effect.gen(function* () {
382+ const h = yield* makeHarness({ connected: true });
383+ const unmount = h.registry.mount(h.stateAtom);
384+ const first = yield* Queue.take(h.subscriptions);
385+ yield* Queue.offer(first.events, { kind: "synchronized" });
386+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live");
387+ unmount();
388+ yield* Deferred.await(first.closed);
389+
390+ yield* SubscriptionRef.set(h.sessionRef, Option.none());
391+ yield* SubscriptionRef.set(h.connectionState, AVAILABLE_CONNECTION_STATE);
392+ const remount = h.registry.mount(h.stateAtom);
393+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "cached");
394+ expect(currentThread(h.registry, h.stateAtom)).toBe(THREAD);
395+ expect(h.counts().opened).toBe(1);
396+
397+ yield* SubscriptionRef.set(h.connectionState, CONNECTED_STATE);
398+ yield* SubscriptionRef.set(h.sessionRef, Option.some(h.session));
399+ const next = yield* Queue.take(h.subscriptions);
400+ expect(next.afterSequence).toBe(7);
401+ expect(h.registry.get(h.stateAtom).status).toBe("synchronizing");
402+ yield* Queue.offer(next.events, { kind: "synchronized" });
403+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live");
404+ remount();
405+ yield* Deferred.await(next.closed);
406+ }),
407+ );
408+
322409 it.effect("keeps warm data when the raw atom family's weak entry is collected", () =>
323410 Effect.gen(function* () {
324411 const h = yield* makeHarness();
packages/client-runtime/src/state/threads.ts+21−3
@@ -158,10 +158,18 @@ function matchesThreadSnapshot( · 157 unmodified lines
158158 currentPage.hasMore === page.hasMore;
159159 }
160160
161+// A retained "live" state stays live: the cursor resume that follows only
162+// replays what the thread missed, and on servers that send the completion
163+// marker the first replayed event moves the status to "synchronizing" on its
164+// own. Downgrading here would flash a sync label on every return to a
165+// recently viewed thread.
161166 function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState {
162167 return {
163168 ...value,
164− status: value.status === "deleted" ? "deleted" : statusWithoutLiveData(value.data),
169+ status:
170+ value.status === "deleted" || (value.status === "live" && Option.isSome(value.data))
171+ ? value.status
172+ : statusWithoutLiveData(value.data),
165173 error: Option.none(),
166174 page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })),
167175 };
@@ -647,7 +655,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 479 unmodified lines
647655 service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)),
648656 });
649657
650− yield* setSynchronizing;
658+ // Only the first subscription after a warm live resume keeps the retained
659+ // status. A replacement session or foreground resubscribe on the same scope
660+ // may have missed events, so those show sync progress until confirmed.
661+ const resumingLive = yield* Ref.make(initialState.status === "live");
662+ const markSynchronizing = Effect.gen(function* () {
663+ if (yield* Ref.get(resumingLive)) return;
664+ yield* setSynchronizing;
665+ });
666+
667+ yield* markSynchronizing;
651668 yield* Effect.forkScoped(
652669 subscribeDynamic(
653670 ORCHESTRATION_WS_METHODS.subscribeThread,
@@ -668,7 +685,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 14 unmodified lines
668685 const supportsPagination = config.threadSnapshotPagination === true;
669686 yield* Ref.set(paginationSupported, supportsPagination);
670687 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
671− yield* setSynchronizing;
688+ yield* markSynchronizing;
689+ yield* Ref.set(resumingLive, false);
672690
673691 let current = yield* SubscriptionRef.get(state);
674692 // A windowed cache resuming against a server without pagination is a