Skip to content

Commit 3da3a63

Browse files
perf(client): batch large thread sync updates (upstream pingdotgg#5344) (#328)
Imported from pingdotgg#5344 at source SHA 783fd02 (commits b623dc2 + 783fd02 squashed into one provenance commit). Imported behavior: - `reduceThreadStreamItems`, a pure reducer that folds a batch of thread stream items into one state and one persistable snapshot. - `Stream.groupedWithin(64, 16ms)` on the live subscription so a burst of thread events publishes the `SubscriptionRef` once instead of per event, and web/mobile stop rebuilding large thread views per streamed event. - `eventBatchSize` on `EnvironmentThreadStateOptions`, plus the upstream regression tests for ordered single-publication bursts and for persisting a settled snapshot when a batch ends with a non-persistable turn start. Local adaptations: - Kept our `httpSnapshotLoadAttempted` guard around the HTTP snapshot fallback; the call now goes through `applyItems([...])`. - Restored `setDeleted` (removed upstream) for the terminal `thread-deleted` subscription failure, which never reaches the item stream and so cannot go through the batch reducer. Cache removal is shared with the reducer path via `removeCachedThread`. Excluded: - `tasks/todo.md`, the author's scratch checklist. Follow-up (fork/changes, not this layer): our `reload-required` branch and `reloadFromServer` are built on the deleted `setThread`, so rebasing fork/changes onto this layer must re-express them against the reducer (split the batch at the reload point, then re-enter `applyItems` with the remainder). Co-authored-by: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com>
1 parent d5dd4eb commit 3da3a63

3 files changed

Lines changed: 279 additions & 62 deletions

File tree

.github/upstream-candidates.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
"sourceSha": "a27510d060645809ae1472bba4dbb248dc624e25",
3737
"status": "active",
3838
"purpose": "Refresh open workspace file previews when disk contents change"
39+
},
40+
{
41+
"upstreamPr": 5344,
42+
"sourceSha": "783fd023c69f7ebc9c0ae657ce124cce77d94587",
43+
"status": "active",
44+
"purpose": "Batch live thread stream items so large-thread sync publishes client state once per batch"
3945
}
4046
]
4147
}

packages/client-runtime/src/state/threads-sync.test.ts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
TurnId,
1010
type OrchestrationThread,
1111
type OrchestrationThreadDetailSnapshot,
12+
type OrchestrationSession,
1213
type OrchestrationThreadStreamItem,
1314
} from "@t3tools/contracts";
1415
import { describe, expect, it } from "@effect/vitest";
@@ -136,10 +137,12 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
136137
readonly cached?: OrchestrationThread;
137138
readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>;
138139
readonly completionMarker?: boolean;
140+
readonly eventBatchSize?: number;
139141
}) {
140142
const inputs = yield* Queue.unbounded<TestThreadInput>();
141143
const observed = yield* Queue.unbounded<EnvironmentThreadState>();
142144
const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE);
145+
const statePublicationCount = yield* Ref.make(0);
143146
const retryCount = yield* Ref.make(0);
144147
const subscriptionCount = yield* Ref.make(0);
145148
const loaderCalls = yield* Ref.make(0);
@@ -224,7 +227,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
224227
clearVcsRefs: () => Effect.void,
225228
clear: () => Effect.void,
226229
});
227-
const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
230+
const threadState = yield* makeEnvironmentThreadState(THREAD_ID, {
231+
eventBatchSize: options?.eventBatchSize ?? 1,
232+
}).pipe(
228233
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
229234
Effect.provideService(Persistence.EnvironmentCacheStore, cache),
230235
Effect.provideService(ThreadSnapshotLoader, snapshotLoader),
@@ -235,7 +240,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
235240
);
236241
yield* SubscriptionRef.changes(threadState).pipe(
237242
Stream.runForEach((state) =>
238-
Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))),
243+
Ref.update(statePublicationCount, (count) => count + 1).pipe(
244+
Effect.andThen(Ref.set(latest, state)),
245+
Effect.andThen(Queue.offer(observed, state)),
246+
),
239247
),
240248
Effect.forkScoped,
241249
);
@@ -244,6 +252,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
244252
inputs,
245253
observed,
246254
latest,
255+
statePublicationCount,
247256
retryCount,
248257
subscriptionCount,
249258
loaderCalls,
@@ -276,6 +285,44 @@ const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem =>
276285

277286
const synchronized = (): OrchestrationThreadStreamItem => ({ kind: "synchronized" });
278287

288+
const sessionUpdated = (
289+
status: OrchestrationSession["status"],
290+
sequence: number,
291+
activeTurnId: TurnId | null,
292+
): OrchestrationThreadStreamItem => ({
293+
kind: "event",
294+
event: {
295+
eventId: EventId.make(`event-session-${sequence}`),
296+
sequence,
297+
occurredAt:
298+
sequence === CACHED_SNAPSHOT_SEQUENCE + 1
299+
? "2026-04-01T08:00:00.000Z"
300+
: "2026-04-01T09:00:00.000Z",
301+
commandId: null,
302+
causationEventId: null,
303+
correlationId: null,
304+
metadata: {},
305+
aggregateKind: "thread",
306+
aggregateId: THREAD_ID,
307+
type: "thread.session-set",
308+
payload: {
309+
threadId: THREAD_ID,
310+
session: {
311+
threadId: THREAD_ID,
312+
status,
313+
providerName: "codex",
314+
runtimeMode: "full-access",
315+
activeTurnId,
316+
lastError: null,
317+
updatedAt:
318+
sequence === CACHED_SNAPSHOT_SEQUENCE + 1
319+
? "2026-04-01T08:00:00.000Z"
320+
: "2026-04-01T09:00:00.000Z",
321+
},
322+
},
323+
},
324+
});
325+
279326
const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({
280327
kind: "event",
281328
event: {
@@ -350,6 +397,84 @@ describe("EnvironmentThreads", () => {
350397
}),
351398
);
352399

400+
it.effect("applies a live burst in order with one state publication", () =>
401+
Effect.gen(function* () {
402+
const harness = yield* makeHarness({
403+
cached: BASE_THREAD,
404+
completionMarker: true,
405+
eventBatchSize: 64,
406+
});
407+
yield* awaitThreadState(
408+
harness.observed,
409+
(value) => value.status === "synchronizing" && Option.isSome(value.data),
410+
);
411+
const publicationsBeforeBurst = yield* Ref.get(harness.statePublicationCount);
412+
413+
const finalSequence = CACHED_SNAPSHOT_SEQUENCE + 63;
414+
for (let sequence = CACHED_SNAPSHOT_SEQUENCE + 1; sequence <= finalSequence; sequence += 1) {
415+
yield* Queue.offer(
416+
harness.inputs,
417+
titleUpdated(
418+
sequence === finalSequence
419+
? "Final title"
420+
: sequence === CACHED_SNAPSHOT_SEQUENCE + 1
421+
? "First title"
422+
: "Interim title",
423+
sequence,
424+
),
425+
);
426+
}
427+
yield* Queue.offer(harness.inputs, synchronized());
428+
429+
const state = yield* awaitThreadState(
430+
harness.observed,
431+
(value) =>
432+
value.status === "live" &&
433+
Option.isSome(value.data) &&
434+
value.data.value.title === "Final title",
435+
);
436+
437+
expect(Option.getOrThrow(state.data).title).toBe("Final title");
438+
expect(yield* Ref.get(harness.statePublicationCount)).toBe(publicationsBeforeBurst + 1);
439+
}),
440+
);
441+
442+
it.effect("persists a settled snapshot before a batched turn starts", () =>
443+
Effect.gen(function* () {
444+
const harness = yield* makeHarness({
445+
cached: ACTIVE_THREAD,
446+
eventBatchSize: 2,
447+
});
448+
449+
yield* Queue.offer(
450+
harness.inputs,
451+
sessionUpdated("ready", CACHED_SNAPSHOT_SEQUENCE + 1, null),
452+
);
453+
yield* Queue.offer(
454+
harness.inputs,
455+
sessionUpdated("running", CACHED_SNAPSHOT_SEQUENCE + 2, TurnId.make("turn-2")),
456+
);
457+
yield* Queue.offer(harness.inputs, synchronized());
458+
459+
const state = yield* awaitThreadState(
460+
harness.observed,
461+
(value) =>
462+
value.status === "live" &&
463+
Option.isSome(value.data) &&
464+
value.data.value.session?.status === "running" &&
465+
value.data.value.session.activeTurnId === TurnId.make("turn-2"),
466+
);
467+
468+
expect(Option.getOrThrow(state.data).session?.status).toBe("running");
469+
yield* TestClock.adjust("500 millis");
470+
yield* Effect.yieldNow;
471+
472+
const saved = (yield* Ref.get(harness.savedThreads)).at(-1);
473+
expect(saved?.snapshotSequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1);
474+
expect(saved?.thread.session?.status).toBe("ready");
475+
}),
476+
);
477+
353478
it.effect("reduces live events and persists the latest thread", () =>
354479
Effect.gen(function* () {
355480
const harness = yield* makeHarness({ cached: BASE_THREAD });

0 commit comments

Comments
 (0)