authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 10:35:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 10:35:27-07:00
log50bfca43d76ced00f5d67cfbab8bc44c50eb0e53
treea1fe8742e34af45f1d19632e17e5aa4a2663a71c
parentc4353bc6b972fa527579b530f01cc8744b2407d2
signature Signed by PGP key B5690EEEBB952194

perf(server): replay only the selected thread (#9726)

Read only the selected thread's events when resuming its detail stream. Measure bounded row counts and serialized payload bytes before replay, using the existing aggregate index and a captured authoritative head. Keep snapshot resets for oversized or invalid cursors. Reset recreated threads when a snapshot exists, and keep bounded replay for deleted threads whose snapshot is absent. Shell replay and the detail-event filter are unchanged. Created with GPT-6 Astra (preview) in Codex.

12 files changed, 781 insertions(+), 94 deletions(-)

apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+6
......@@ -325,6 +325,8 @@ describe("OrchestrationEngine", () => {
325325 }),
326326 ),
327327 hasEventAfter: () => Effect.succeed(false),
328 readAggregateRange: () => Stream.die("unused aggregate replay"),
329 getAggregateReplayStats: () => Effect.die("unused aggregate replay stats"),
328330 };
329331
330332 const projectionSnapshot = {
......@@ -1235,6 +1237,8 @@ describe("OrchestrationEngine", () => {
12351237 return Stream.fromIterable(events);
12361238 },
12371239 hasEventAfter: () => Effect.succeed(false),
1240 readAggregateRange: () => Stream.die("unused aggregate replay"),
1241 getAggregateReplayStats: () => Effect.die("unused aggregate replay stats"),
12381242 };
12391243
12401244 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
......@@ -1473,6 +1477,8 @@ describe("OrchestrationEngine", () => {
14731477 return Stream.fromIterable(events);
14741478 },
14751479 hasEventAfter: () => Effect.succeed(false),
1480 readAggregateRange: () => Stream.die("unused aggregate replay"),
1481 getAggregateReplayStats: () => Effect.die("unused aggregate replay stats"),
14761482 };
14771483
14781484 let shouldFailProjection = true;
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+15
......@@ -381,6 +381,19 @@ const makeOrchestrationEngine = Effect.gen(function* () {
381381 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) =>
382382 eventStore.readFromSequence(fromSequenceExclusive, limit);
383383
384 const readThreadEvents: OrchestrationEngineShape["readThreadEvents"] = ({ threadId, ...range }) =>
385 eventStore.readAggregateRange({ ...range, aggregateKind: "thread", aggregateId: threadId });
386
387 const getThreadReplayStats: OrchestrationEngineShape["getThreadReplayStats"] = ({
388 threadId,
389 ...range
390 }) =>
391 eventStore.getAggregateReplayStats({
392 ...range,
393 aggregateKind: "thread",
394 aggregateId: threadId,
395 });
396
384397 const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) =>
385398 Effect.gen(function* () {
386399 const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>();
......@@ -395,6 +408,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
395408
396409 return {
397410 readEvents,
411 readThreadEvents,
412 getThreadReplayStats,
398413 dispatch,
399414 subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)),
400415 // Each access creates a fresh PubSub subscription so that multiple
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2
......@@ -418,6 +418,8 @@ describe("ProviderCommandReactor", () => {
418418 const engine = yield* OrchestrationEngineService;
419419 return {
420420 readEvents: engine.readEvents,
421 readThreadEvents: engine.readThreadEvents,
422 getThreadReplayStats: engine.getThreadReplayStats,
421423 dispatch: (command) => {
422424 if (command.type === "thread.title.regeneration.complete") {
423425 titleRegenerationCompletionDispatchAttempts += 1;
apps/server/src/orchestration/Services/OrchestrationEngine.ts+19-2
......@@ -14,6 +14,7 @@ import type {
1414 OrchestrationClientOrigin,
1515 OrchestrationCommand,
1616 OrchestrationEvent,
17 ThreadId,
1718} from "@t3tools/contracts";
1819import * as Context from "effect/Context";
1920import type * as Effect from "effect/Effect";
......@@ -22,6 +23,13 @@ import type * as Stream from "effect/Stream";
2223
2324import type { OrchestrationDispatchError } from "../Errors.ts";
2425import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";
26import type { OrchestrationAggregateReplayStats } from "../../persistence/Services/OrchestrationEventStore.ts";
27
28export interface OrchestrationThreadReplayRange {
29 readonly threadId: ThreadId;
30 readonly fromSequenceExclusive: number;
31 readonly toSequenceInclusive: number;
32}
2533
2634/**
2735 * OrchestrationEngineShape - Service API for orchestration command and event flow.
......@@ -33,8 +41,7 @@ export interface OrchestrationEngineShape {
3341 * @param fromSequenceExclusive - Sequence cursor (exclusive).
3442 * @param limit - Maximum number of events to read. Defaults to the event
3543 * store's page-bounded default; pass a higher value when the caller must
36 * read every event after the cursor (e.g. per-thread catch-up that filters
37 * a small subset out of a potentially larger global range).
44 * read a wider global range. Thread subscriptions use readThreadEvents.
3845 * @returns Stream containing ordered events.
3946 */
4047 readonly readEvents: (
......@@ -42,6 +49,16 @@ export interface OrchestrationEngineShape {
4249 limit?: number,
4350 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;
4451
52 /** Read only this thread's events through a captured authoritative head. */
53 readonly readThreadEvents: (
54 input: OrchestrationThreadReplayRange & { readonly limit?: number },
55 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
56
57 /** Measure a bounded thread replay without decoding its event bodies. */
58 readonly getThreadReplayStats: (
59 input: OrchestrationThreadReplayRange & { readonly maxEvents: number },
60 ) => Effect.Effect<OrchestrationAggregateReplayStats, OrchestrationEventStoreError>;
61
4562 /**
4663 * Dispatch a validated orchestration command.
4764 *
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+183-2
......@@ -1,4 +1,11 @@
1import { CommandId, EventId, ProjectId } from "@t3tools/contracts";
1import {
2 CommandId,
3 EventId,
4 MessageId,
5 ProjectId,
6 ThreadId,
7 type OrchestrationEvent,
8} from "@t3tools/contracts";
29import { assert, it } from "@effect/vitest";
310import * as Effect from "effect/Effect";
411import * as Layer from "effect/Layer";
......@@ -12,6 +19,31 @@ import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts";
1219import { SqlitePersistenceMemory } from "./Sqlite.ts";
1320const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);
1421
22function messageEvent(threadId: ThreadId, id: string): Omit<OrchestrationEvent, "sequence"> {
23 const now = "2026-01-01T00:00:00.000Z";
24 return {
25 type: "thread.message-sent",
26 eventId: EventId.make(id),
27 aggregateKind: "thread",
28 aggregateId: threadId,
29 occurredAt: now,
30 commandId: null,
31 causationEventId: null,
32 correlationId: null,
33 metadata: {},
34 payload: {
35 threadId,
36 messageId: MessageId.make(id),
37 role: "assistant",
38 text: id,
39 turnId: null,
40 streaming: false,
41 createdAt: now,
42 updatedAt: now,
43 },
44 };
45}
46
1547const layer = it.layer(
1648 OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
1749);
......@@ -79,7 +111,7 @@ layer("OrchestrationEventStore", (it) => {
79111 const sql = yield* SqlClient.SqlClient;
80112 const now = "2026-01-01T00:00:00.000Z";
81113
82 yield* sql`
114 const invalidRows = yield* sql<{ readonly sequence: number }>`
83115 INSERT INTO orchestration_events (
84116 event_id,
85117 aggregate_kind,
......@@ -108,6 +140,7 @@ layer("OrchestrationEventStore", (it) => {
108140 ${"{"},
109141 ${"{}"}
110142 )
143 RETURNING sequence
111144 `;
112145
113146 const replayResult = yield* Effect.result(
......@@ -122,6 +155,154 @@ layer("OrchestrationEventStore", (it) => {
122155 ),
123156 );
124157 }
158 const scopedResult = yield* eventStore
159 .readAggregateRange({
160 aggregateKind: "project",
161 aggregateId: "project-invalid-json",
162 fromSequenceExclusive: 0,
163 toSequenceInclusive: invalidRows[0]!.sequence,
164 })
165 .pipe(Stream.runCollect, Effect.result);
166 assert.equal(scopedResult._tag, "Failure");
167 if (scopedResult._tag === "Failure") {
168 assert.ok(isPersistenceDecodeError(scopedResult.failure));
169 assert.ok(
170 scopedResult.failure.operation.includes(
171 "OrchestrationEventStore.readAggregateRange:decodeRows",
172 ),
173 );
174 }
175 }),
176 );
177
178 it.effect("reads one aggregate through the captured head across pruned global gaps", () =>
179 Effect.gen(function* () {
180 const store = yield* OrchestrationEventStore;
181 const sql = yield* SqlClient.SqlClient;
182 const threadId = ThreadId.make("shared-stream-id");
183 const first = yield* store.append(messageEvent(threadId, "scoped-first"));
184 const pruned = yield* store.append(
185 messageEvent(ThreadId.make("pruned-thread"), "pruned-event"),
186 );
187 const second = yield* store.append(messageEvent(threadId, "scoped-second"));
188 // The same stream ID in a different aggregate is not part of this thread.
189 // Its invalid JSON must never reach the event decoder.
190 yield* sql`
191 INSERT INTO orchestration_events (
192 event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
193 actor_kind, payload_json, metadata_json
194 ) VALUES (
195 'same-id-project', 'project', ${threadId}, 0, 'project.created',
196 '2026-01-01T00:00:00.000Z', 'server', '{', '{'
197 ), (
198 'unrelated-invalid', 'thread', 'unrelated-invalid-thread', 0, 'thread.activity-appended',
199 '2026-01-01T00:00:00.000Z', 'server', '{', '{'
200 )
201 `;
202 const last = yield* store.append(messageEvent(threadId, "scoped-last"));
203 yield* sql`DELETE FROM orchestration_events WHERE sequence = ${pruned.sequence}`;
204 yield* store.append(messageEvent(threadId, "after-captured-head"));
205
206 const events = yield* store
207 .readAggregateRange({
208 aggregateKind: "thread",
209 aggregateId: threadId,
210 fromSequenceExclusive: first.sequence,
211 toSequenceInclusive: last.sequence,
212 limit: 100,
213 })
214 .pipe(Stream.runCollect);
215 assert.deepEqual(
216 events.map((event) => event.sequence),
217 [second.sequence, last.sequence],
218 );
219 }),
220 );
221
222 it.effect("bounds thread replay metadata and counts UTF-8 bytes without decoding payloads", () =>
223 Effect.gen(function* () {
224 const store = yield* OrchestrationEventStore;
225 const sql = yield* SqlClient.SqlClient;
226 const rows = yield* sql<{ readonly sequence: number }>`
227 INSERT INTO orchestration_events (
228 event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
229 actor_kind, payload_json, metadata_json
230 ) VALUES
231 ('stats-1', 'thread', 'stats-thread', 0, 'thread.message-sent',
232 '2026-01-01T00:00:00.000Z', 'provider', '{"output":"😀"}', '{}'),
233 ('stats-unrelated', 'thread', 'another-thread', 0, 'thread.created',
234 '2026-01-01T00:00:00.000Z', 'provider', printf('%.*c', 10000, 'x'), '{}'),
235 ('stats-2', 'thread', 'stats-thread', 1, 'thread.activity-appended',
236 '2026-01-01T00:00:00.000Z', 'provider', '{', '{}'),
237 ('stats-other-kind', 'project', 'stats-thread', 0, 'project.deleted',
238 '2026-01-01T00:00:00.000Z', 'provider', printf('%.*c', 20000, 'x'), '{}'),
239 ('stats-3', 'thread', 'stats-thread', 2, 'thread.deleted',
240 '2026-01-01T00:00:00.000Z', 'provider', '{"output":"é"}', '{}'),
241 ('stats-4', 'thread', 'stats-thread', 3, 'thread.created',
242 '2026-01-01T00:00:00.000Z', 'provider', printf('%.*c', 2000, 'x'), '{}')
243 RETURNING sequence
244 `;
245 const range = {
246 aggregateKind: "thread" as const,
247 aggregateId: "stats-thread",
248 fromSequenceExclusive: 0,
249 toSequenceInclusive: rows.at(-1)!.sequence,
250 };
251 assert.deepEqual(yield* store.getAggregateReplayStats({ ...range, maxEvents: 2 }), {
252 eventCount: 3,
253 payloadBytes: 33,
254 hasCreateEvent: false,
255 });
256 assert.deepEqual(yield* store.getAggregateReplayStats({ ...range, maxEvents: 10 }), {
257 eventCount: 4,
258 payloadBytes: 2033,
259 hasCreateEvent: true,
260 });
261 assert.deepEqual(
262 yield* store.getAggregateReplayStats({
263 ...range,
264 toSequenceInclusive: rows[2]!.sequence,
265 maxEvents: 10,
266 }),
267 {
268 eventCount: 2,
269 payloadBytes: 18,
270 hasCreateEvent: false,
271 },
272 );
273 }),
274 );
275
276 it.effect("keeps later pages below the captured head when new events are appended", () =>
277 Effect.gen(function* () {
278 const store = yield* OrchestrationEventStore;
279 const threadId = ThreadId.make("paged-thread");
280 const persisted = yield* Effect.forEach(
281 Array.from({ length: 502 }, (_, index) => index),
282 (index) => store.append(messageEvent(threadId, `paged-${index}`)),
283 );
284 const head = persisted.at(-1)!.sequence;
285 let appendedDuringReplay = false;
286 const replayed = yield* store
287 .readAggregateRange({
288 aggregateKind: "thread",
289 aggregateId: threadId,
290 fromSequenceExclusive: 0,
291 toSequenceInclusive: head,
292 limit: 1_000,
293 })
294 .pipe(
295 Stream.tap(() => {
296 if (appendedDuringReplay) return Effect.void;
297 appendedDuringReplay = true;
298 return store.append(messageEvent(threadId, "appended-during-replay"));
299 }),
300 Stream.runCollect,
301 );
302 assert.deepEqual(
303 replayed.map((event) => event.sequence),
304 persisted.map((event) => event.sequence),
305 );
125306 }),
126307 );
127308});
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+132
......@@ -72,6 +72,18 @@ const ReadFromSequenceRequestSchema = Schema.Struct({
7272 sequenceExclusive: NonNegativeInt,
7373 limit: Schema.Number,
7474});
75const AggregateReplayRequestSchema = Schema.Struct({
76 aggregateKind: OrchestrationAggregateKind,
77 aggregateId: Schema.String,
78 fromSequenceExclusive: NonNegativeInt,
79 toSequenceInclusive: NonNegativeInt,
80 limit: Schema.Number,
81});
82const AggregateReplayStatsRowSchema = Schema.Struct({
83 eventCount: Schema.Number,
84 payloadBytes: Schema.Number,
85 hasCreateEvent: Schema.Number,
86});
7587const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000;
7688const READ_PAGE_SIZE = 500;
7789
......@@ -189,6 +201,57 @@ const makeEventStore = Effect.gen(function* () {
189201 `,
190202 });
191203
204 const readAggregateEventRows = SqlSchema.findAll({
205 Request: AggregateReplayRequestSchema,
206 Result: OrchestrationEventPersistedRowSchema,
207 execute: (request) =>
208 sql`
209 SELECT
210 sequence,
211 event_id AS "eventId",
212 event_type AS "type",
213 aggregate_kind AS "aggregateKind",
214 stream_id AS "aggregateId",
215 occurred_at AS "occurredAt",
216 command_id AS "commandId",
217 causation_event_id AS "causationEventId",
218 correlation_id AS "correlationId",
219 payload_json AS "payload",
220 metadata_json AS "metadata"
221 FROM orchestration_events
222 WHERE aggregate_kind = ${request.aggregateKind}
223 AND stream_id = ${request.aggregateId}
224 AND sequence > ${request.fromSequenceExclusive}
225 AND sequence <= ${request.toSequenceInclusive}
226 ORDER BY sequence ASC
227 LIMIT ${request.limit}
228 `,
229 });
230
231 const readAggregateReplayStats = SqlSchema.findOne({
232 Request: AggregateReplayRequestSchema,
233 Result: AggregateReplayStatsRowSchema,
234 execute: (request) =>
235 sql`
236 SELECT
237 COUNT(*) AS "eventCount",
238 COALESCE(SUM(octet_length(payload_json)), 0) AS "payloadBytes",
239 COALESCE(MAX(event_type IN (
240 'thread.created', 'project.created'
241 )), 0) AS "hasCreateEvent"
242 FROM (
243 SELECT payload_json, event_type
244 FROM orchestration_events
245 WHERE aggregate_kind = ${request.aggregateKind}
246 AND stream_id = ${request.aggregateId}
247 AND sequence > ${request.fromSequenceExclusive}
248 AND sequence <= ${request.toSequenceInclusive}
249 ORDER BY sequence ASC
250 LIMIT ${request.limit}
251 )
252 `,
253 });
254
192255 const append: OrchestrationEventStoreShape["append"] = (event) =>
193256 appendEventRow({
194257 eventId: event.eventId,
......@@ -295,9 +358,78 @@ const makeEventStore = Effect.gen(function* () {
295358 ),
296359 );
297360
361 const readAggregateRange: OrchestrationEventStoreShape["readAggregateRange"] = (input) => {
362 const limit = Math.max(0, Math.floor(input.limit ?? DEFAULT_READ_FROM_SEQUENCE_LIMIT));
363 if (limit === 0 || input.fromSequenceExclusive >= input.toSequenceInclusive) {
364 return Stream.empty;
365 }
366 const readPage = (
367 cursor: number,
368 remaining: number,
369 ): Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError> =>
370 Stream.fromEffect(
371 readAggregateEventRows({
372 ...input,
373 fromSequenceExclusive: cursor,
374 limit: Math.min(remaining, READ_PAGE_SIZE),
375 }).pipe(
376 Effect.mapError(
377 toPersistenceSqlOrDecodeError(
378 "OrchestrationEventStore.readAggregateRange:query",
379 "OrchestrationEventStore.readAggregateRange:decodeRows",
380 ),
381 ),
382 Effect.flatMap((rows) =>
383 Effect.forEach(rows, (row) =>
384 decodeEvent(row).pipe(
385 Effect.mapError(
386 toPersistenceDecodeError("OrchestrationEventStore.readAggregateRange:rowToEvent"),
387 ),
388 ),
389 ),
390 ),
391 ),
392 ).pipe(
393 Stream.flatMap((events) => {
394 const last = events.at(-1);
395 if (last === undefined) {
396 return Stream.empty;
397 }
398 const nextRemaining = remaining - events.length;
399 if (
400 events.length < READ_PAGE_SIZE ||
401 nextRemaining === 0 ||
402 last.sequence >= input.toSequenceInclusive
403 ) {
404 return Stream.fromIterable(events);
405 }
406 return Stream.concat(Stream.fromIterable(events), readPage(last.sequence, nextRemaining));
407 }),
408 );
409 return readPage(input.fromSequenceExclusive, limit);
410 };
411
412 const getAggregateReplayStats: OrchestrationEventStoreShape["getAggregateReplayStats"] = (
413 input,
414 ) =>
415 readAggregateReplayStats({
416 ...input,
417 limit: Math.max(0, Math.floor(input.maxEvents)) + 1,
418 }).pipe(
419 Effect.mapError(
420 toPersistenceSqlOrDecodeError(
421 "OrchestrationEventStore.getAggregateReplayStats:query",
422 "OrchestrationEventStore.getAggregateReplayStats:decodeRow",
423 ),
424 ),
425 Effect.map((row) => ({ ...row, hasCreateEvent: row.hasCreateEvent !== 0 })),
426 );
427
298428 return {
299429 append,
300430 readFromSequence,
431 readAggregateRange,
432 getAggregateReplayStats,
301433 readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER),
302434 hasEventAfter,
303435 } satisfies OrchestrationEventStoreShape;
apps/server/src/persistence/Services/OrchestrationEventStore.ts+27
......@@ -16,6 +16,20 @@ import type * as Stream from "effect/Stream";
1616
1717import type { OrchestrationEventStoreError } from "../Errors.ts";
1818
19export interface OrchestrationAggregateReplayRange {
20 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
21 readonly aggregateId: string;
22 readonly fromSequenceExclusive: number;
23 readonly toSequenceInclusive: number;
24}
25
26export interface OrchestrationAggregateReplayStats {
27 readonly eventCount: number;
28 readonly payloadBytes: number;
29 /** A creation in this range does not prove that the aggregate still exists. */
30 readonly hasCreateEvent: boolean;
31}
32
1933/**
2034 * OrchestrationEventStoreShape - Service API for orchestration event persistence.
2135 */
......@@ -46,6 +60,19 @@ export interface OrchestrationEventStoreShape {
4660 limit?: number,
4761 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
4862
63 /** Read one aggregate through a captured global head, without decoding other streams. */
64 readonly readAggregateRange: (
65 input: OrchestrationAggregateReplayRange & { readonly limit?: number },
66 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
67
68 /**
69 * Measure at most maxEvents + 1 rows without decoding payloads. The extra
70 * row tells the caller to use a snapshot instead of a truncated replay.
71 */
72 readonly getAggregateReplayStats: (
73 input: OrchestrationAggregateReplayRange & { readonly maxEvents: number },
74 ) => Effect.Effect<OrchestrationAggregateReplayStats, OrchestrationEventStoreError>;
75
4976 /**
5077 * Read all events from the beginning of the stream.
5178 *
apps/server/src/relay/AgentAwarenessRelay.test.ts+4
......@@ -459,6 +459,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
459459
460460 const orchestrationEngine = {
461461 readEvents: () => Stream.empty,
462 readThreadEvents: () => Stream.empty,
463 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
462464 dispatch: () => Effect.succeed({ sequence: 1 }),
463465 streamDomainEvents: Stream.fromQueue(events),
464466 subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)),
......@@ -653,6 +655,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
653655 }),
654656 Layer.succeed(OrchestrationEngineService, {
655657 readEvents: () => Stream.empty,
658 readThreadEvents: () => Stream.empty,
659 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
656660 dispatch: () => Effect.succeed({ sequence: 1 }),
657661 streamDomainEvents: Stream.fromQueue(events),
658662 subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)),
apps/server/src/server.test.ts+337-68
......@@ -115,6 +115,8 @@ import {
115115import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
116116import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
117117import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
118import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts";
119import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";
118120import { PersistenceSqlError } from "./persistence/Errors.ts";
119121import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
120122import * as ProviderService from "./provider/Services/ProviderService.ts";
......@@ -918,6 +920,13 @@ const buildAppUnderTest = (options?: {
918920 Layer.mergeAll(
919921 Layer.mock(OrchestrationEngine.OrchestrationEngineService)({
920922 readEvents: () => Stream.empty,
923 readThreadEvents: () => Stream.empty,
924 getThreadReplayStats: () =>
925 Effect.succeed({
926 eventCount: 0,
927 payloadBytes: 0,
928 hasCreateEvent: false,
929 }),
921930 dispatch: () => Effect.succeed({ sequence: 0 }),
922931 streamDomainEvents: Stream.empty,
923932 latestSequence: Effect.succeed(0),
......@@ -8047,50 +8056,57 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
80478056 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
80488057 );
80498058
8050 it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () =>
8051 Effect.gen(function* () {
8052 let readEventsCalls = 0;
8053 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
8059 it.effect(
8060 "subscribeThread sends a fresh snapshot when its event count exceeds the replay limit",
8061 () =>
8062 Effect.gen(function* () {
8063 let readEventsCalls = 0;
8064 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
80548065
8055 yield* buildAppUnderTest({
8056 layers: {
8057 orchestrationEngine: {
8058 // Head is far ahead of the client's afterSequence (gap > 1000).
8059 latestSequence: Effect.succeed(100_000),
8060 readEvents: () =>
8061 Stream.sync(() => {
8062 readEventsCalls += 1;
8063 return {} as OrchestrationEvent;
8064 }),
8065 },
8066 projectionSnapshotQuery: {
8067 getThreadDetailSnapshot: () =>
8068 Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })),
8066 yield* buildAppUnderTest({
8067 layers: {
8068 orchestrationEngine: {
8069 latestSequence: Effect.succeed(100_000),
8070 getThreadReplayStats: () =>
8071 Effect.succeed({
8072 eventCount: 1_001,
8073 payloadBytes: 1_000,
8074 hasCreateEvent: false,
8075 }),
8076 readThreadEvents: () =>
8077 Stream.sync(() => {
8078 readEventsCalls += 1;
8079 return {} as OrchestrationEvent;
8080 }),
8081 },
8082 projectionSnapshotQuery: {
8083 getThreadDetailSnapshot: () =>
8084 Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })),
8085 },
80698086 },
8070 },
8071 });
8087 });
80728088
8073 const wsUrl = yield* getWsServerUrl("/ws");
8074 const items = yield* Effect.scoped(
8075 withWsRpcClient(wsUrl, (client) =>
8076 client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8077 threadId: defaultThreadId,
8078 afterSequence: 5,
8079 requestCompletionMarker: true,
8080 }).pipe(Stream.take(2), Stream.runCollect),
8081 ),
8082 );
8089 const wsUrl = yield* getWsServerUrl("/ws");
8090 const items = yield* Effect.scoped(
8091 withWsRpcClient(wsUrl, (client) =>
8092 client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8093 threadId: defaultThreadId,
8094 afterSequence: 5,
8095 requestCompletionMarker: true,
8096 }).pipe(Stream.take(2), Stream.runCollect),
8097 ),
8098 );
80838099
8084 const [first, second] = Array.from(items);
8085 // Large gap => fresh thread snapshot, and the global replay never starts.
8086 assert.equal(first?.kind, "snapshot");
8087 if (first?.kind === "snapshot") {
8088 assert.equal(first.snapshot.thread.id, defaultThreadId);
8089 assert.equal(first.snapshot.snapshotSequence, 100_000);
8090 }
8091 assert.equal(second?.kind, "synchronized");
8092 assert.equal(readEventsCalls, 0);
8093 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8100 const [first, second] = Array.from(items);
8101 // Never truncate a thread's replay at the event limit.
8102 assert.equal(first?.kind, "snapshot");
8103 if (first?.kind === "snapshot") {
8104 assert.equal(first.snapshot.thread.id, defaultThreadId);
8105 assert.equal(first.snapshot.snapshotSequence, 100_000);
8106 }
8107 assert.equal(second?.kind, "synchronized");
8108 assert.equal(readEventsCalls, 0);
8109 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
80948110 );
80958111
80968112 it.effect(
......@@ -8104,7 +8120,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
81048120 const releaseAck = yield* Deferred.make<void>();
81058121 const firstApplied = yield* Deferred.make<void>();
81068122 const replayStarted = yield* Deferred.make<void>();
8107 const replayCalls: Array<{ afterSequence: number; limit: number | undefined }> = [];
8123 const replayCalls: Array<{ afterSequence: number; headSequence: number }> = [];
81088124 let headSequence = 0;
81098125 let snapshotCalls = 0;
81108126 const message = (sequence: number, text: string) =>
......@@ -8148,24 +8164,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
81488164 return Stream.fromSubscription(subscription);
81498165 }),
81508166 ).pipe(Stream.ensuring(Deferred.succeed(detached, undefined))),
8151 readEvents: (afterSequence, limit) => {
8152 replayCalls.push({ afterSequence, limit });
8167 readThreadEvents: ({ fromSequenceExclusive, toSequenceInclusive }) => {
8168 replayCalls.push({
8169 afterSequence: fromSequenceExclusive,
8170 headSequence: toSequenceInclusive,
8171 });
81538172 const range = events.filter(
8154 (event) => event.sequence > afterSequence && event.sequence <= headSequence,
8173 (event) =>
8174 event.sequence > fromSequenceExclusive && event.sequence <= toSequenceInclusive,
81558175 );
81568176 return Stream.concat(
81578177 Stream.fromEffect(Deferred.succeed(replayStarted, undefined)).pipe(Stream.drain),
81588178 Stream.fromIterable(range),
81598179 );
81608180 },
8161 },
8162 projectionSnapshotQuery: {
8163 getThreadDetailSnapshot: () =>
8164 Effect.sync(() => {
8165 snapshotCalls += 1;
8166 return Option.none();
8167 }),
8168 getEventReplayStats: ({ fromSequenceExclusive, toSequenceInclusive }) => {
8181 getThreadReplayStats: ({ fromSequenceExclusive, toSequenceInclusive }) => {
81698182 const range = events.filter(
81708183 (event) =>
81718184 event.sequence > fromSequenceExclusive && event.sequence <= toSequenceInclusive,
......@@ -8176,9 +8189,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
81768189 (bytes, event) => bytes + Buffer.byteLength(jsonRequestBody(event.payload)),
81778190 0,
81788191 ),
8192 hasCreateEvent: false,
81798193 });
81808194 },
81818195 },
8196 projectionSnapshotQuery: {
8197 getThreadDetailSnapshot: () =>
8198 Effect.sync(() => {
8199 snapshotCalls += 1;
8200 return Option.none();
8201 }),
8202 },
81828203 },
81838204 });
81848205
......@@ -8242,8 +8263,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
82428263 );
82438264 assert.equal(completed.event.payload.activity.kind, "tool.completed");
82448265 assert.deepEqual(replayCalls, [
8245 { afterSequence: 0, limit: 0 },
8246 { afterSequence: 1, limit: 3 },
8266 { afterSequence: 0, headSequence: 0 },
8267 { afterSequence: 1, headSequence: 4 },
82478268 ]);
82488269 assert.equal(snapshotCalls, 0);
82498270 }),
......@@ -8369,7 +8390,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
83698390 layers: {
83708391 orchestrationEngine: {
83718392 latestSequence: Effect.succeed(5),
8372 readEvents: () =>
8393 getThreadReplayStats: () =>
8394 Effect.die("An invalid cursor must not start a replay query"),
8395 readThreadEvents: () =>
83738396 Stream.sync(() => {
83748397 readEventsCalls += 1;
83758398 return {} as OrchestrationEvent;
......@@ -8397,9 +8420,225 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
83978420 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
83988421 );
83998422
8423 it.effect("subscribeThread replays a small thread range across a large global gap", () =>
8424 Effect.gen(function* () {
8425 const event = makeLiveToolActivityEvent(99_999, "tool.completed");
8426 yield* buildAppUnderTest({
8427 layers: {
8428 orchestrationEngine: {
8429 latestSequence: Effect.succeed(100_000),
8430 getThreadReplayStats: () =>
8431 Effect.succeed({
8432 eventCount: 1,
8433 payloadBytes: Buffer.byteLength(jsonRequestBody(event.payload)),
8434 hasCreateEvent: false,
8435 }),
8436 readThreadEvents: () => Stream.make(event),
8437 readEvents: () => Stream.die("Thread replay must not read the global log"),
8438 },
8439 projectionSnapshotQuery: {
8440 getEventReplayStats: () => Effect.die("Thread replay must not measure the global log"),
8441 getThreadDetailSnapshot: () =>
8442 Effect.die("Unrelated activity must not force a snapshot"),
8443 },
8444 },
8445 });
8446 const wsUrl = yield* getWsServerUrl("/ws");
8447 const items = yield* Effect.scoped(
8448 withWsRpcClient(wsUrl, (client) =>
8449 client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8450 threadId: defaultThreadId,
8451 afterSequence: 5,
8452 requestCompletionMarker: true,
8453 }).pipe(
8454 Stream.takeUntil((item) => item.kind === "synchronized"),
8455 Stream.runCollect,
8456 ),
8457 ),
8458 );
8459 assert.deepEqual(
8460 items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)),
8461 [99_999, "synchronized"],
8462 );
8463 const first = items[0];
8464 assertTrue(first?.kind === "event" && first.event.type === "thread.activity-appended");
8465 assert.equal(first.event.payload.activity.kind, "tool.completed");
8466 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8467 );
8468
8469 it.effect("subscribeThread resets cached history when its ID is created again", () =>
8470 Effect.gen(function* () {
8471 const thread = {
8472 ...makeDefaultOrchestrationReadModel().threads[0]!,
8473 title: "Recreated thread",
8474 };
8475 let requestedTurnLimit: number | undefined;
8476 yield* buildAppUnderTest({
8477 layers: {
8478 orchestrationEngine: {
8479 latestSequence: Effect.succeed(5),
8480 getThreadReplayStats: () =>
8481 Effect.succeed({
8482 eventCount: 3,
8483 payloadBytes: 1_000,
8484 hasCreateEvent: true,
8485 }),
8486 readThreadEvents: () => Stream.die("A recreated thread must not reuse cached history"),
8487 },
8488 projectionSnapshotQuery: {
8489 getThreadDetailSnapshot: (_threadId, options) => {
8490 requestedTurnLimit = options?.turnLimit;
8491 return Effect.succeed(Option.some({ snapshotSequence: 5, thread }));
8492 },
8493 },
8494 },
8495 });
8496 const wsUrl = yield* getWsServerUrl("/ws");
8497 const items = yield* Effect.scoped(
8498 withWsRpcClient(wsUrl, (client) =>
8499 client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8500 threadId: defaultThreadId,
8501 afterSequence: 2,
8502 turnLimit: 1,
8503 requestCompletionMarker: true,
8504 }).pipe(
8505 Stream.takeUntil((item) => item.kind === "synchronized"),
8506 Stream.runCollect,
8507 ),
8508 ),
8509 );
8510 const first = items[0];
8511 assertTrue(first?.kind === "snapshot");
8512 assert.equal(first.snapshot.thread.title, "Recreated thread");
8513 assert.equal(first.snapshot.snapshotSequence, 5);
8514 assert.equal(requestedTurnLimit, 1);
8515 assert.deepEqual(items[1], { kind: "synchronized" });
8516 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8517 );
8518
8519 for (const { createBeforeDelete, oversized } of [
8520 { createBeforeDelete: false, oversized: false },
8521 { createBeforeDelete: true, oversized: false },
8522 { createBeforeDelete: true, oversized: true },
8523 ]) {
8524 it.effect(
8525 oversized
8526 ? "keeps the missing-snapshot error when an absent thread exceeds the replay limit"
8527 : `synchronizes an absent thread and removes its shell after ${createBeforeDelete ? "creation and deletion" : "deletion"}`,
8528 () =>
8529 Effect.gen(function* () {
8530 const store = yield* OrchestrationEventStore;
8531 const base = makeLiveToolActivityEvent(0, "tool.completed");
8532 if (createBeforeDelete) {
8533 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
8534 yield* store.append({
8535 ...base,
8536 eventId: EventId.make(`create-before-final-delete-${oversized}`),
8537 type: "thread.created",
8538 payload: {
8539 threadId: defaultThreadId,
8540 projectId: thread.projectId,
8541 title: thread.title,
8542 modelSelection: thread.modelSelection,
8543 runtimeMode: thread.runtimeMode,
8544 interactionMode: thread.interactionMode,
8545 branch: thread.branch,
8546 worktreePath: thread.worktreePath,
8547 createdAt: thread.createdAt,
8548 updatedAt: thread.updatedAt,
8549 },
8550 });
8551 }
8552 if (oversized) {
8553 yield* Effect.forEach(
8554 Array.from({ length: 1_000 }, (_, index) => index + 1),
8555 (sequence) => store.append(makeLiveToolActivityEvent(sequence, "tool.completed")),
8556 { discard: true },
8557 );
8558 }
8559 const deleted = yield* store.append({
8560 ...base,
8561 eventId: EventId.make(`deleted-replay-${createBeforeDelete}-${oversized}`),
8562 type: "thread.deleted",
8563 payload: { threadId: defaultThreadId, deletedAt: base.occurredAt },
8564 });
8565 yield* buildAppUnderTest({
8566 layers: {
8567 orchestrationEngine: {
8568 latestSequence: Effect.succeed(deleted.sequence),
8569 getThreadReplayStats: ({ threadId, ...range }) =>
8570 store.getAggregateReplayStats({
8571 ...range,
8572 aggregateKind: "thread",
8573 aggregateId: threadId,
8574 }),
8575 readThreadEvents: ({ threadId, ...range }) =>
8576 store.readAggregateRange({
8577 ...range,
8578 aggregateKind: "thread",
8579 aggregateId: threadId,
8580 }),
8581 readEvents: store.readFromSequence,
8582 },
8583 projectionSnapshotQuery: {
8584 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
8585 },
8586 },
8587 });
8588 const wsUrl = yield* getWsServerUrl("/ws");
8589 yield* Effect.scoped(
8590 withWsRpcClient(wsUrl, (client) =>
8591 Effect.gen(function* () {
8592 const threadResult = yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8593 threadId: defaultThreadId,
8594 afterSequence: 0,
8595 requestCompletionMarker: true,
8596 }).pipe(
8597 Stream.takeUntil((item) => item.kind === "synchronized"),
8598 Stream.runCollect,
8599 Effect.result,
8600 );
8601 if (oversized) {
8602 assertTrue(threadResult._tag === "Failure");
8603 assert.equal(threadResult.failure._tag, "OrchestrationGetSnapshotError");
8604 assert.equal(
8605 threadResult.failure.message,
8606 `Thread ${defaultThreadId} was not found`,
8607 );
8608 return;
8609 }
8610 assertTrue(threadResult._tag === "Success");
8611 assert.deepEqual(threadResult.success, [{ kind: "synchronized" }]);
8612 const shellItems = yield* client[ORCHESTRATION_WS_METHODS.subscribeShell]({
8613 afterSequence: 0,
8614 requestCompletionMarker: true,
8615 }).pipe(
8616 Stream.takeUntil((item) => item.kind === "synchronized"),
8617 Stream.runCollect,
8618 );
8619 assert.deepEqual(shellItems, [
8620 { kind: "thread-removed", sequence: deleted.sequence, threadId: defaultThreadId },
8621 { kind: "synchronized" },
8622 ]);
8623 }),
8624 ),
8625 );
8626 }).pipe(
8627 Effect.provide(
8628 Layer.mergeAll(
8629 OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
8630 NodeHttpServer.layerTest,
8631 ),
8632 ),
8633 ),
8634 );
8635 }
8636
84008637 it.effect("subscribeThread bounds catch-up replay to the captured head", () =>
84018638 Effect.gen(function* () {
8402 let replayLimit: number | undefined;
8639 let replayHead: number | undefined;
8640 let headSequence = 50;
8641 const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>();
84038642 const now = "2026-01-01T00:00:00.000Z";
84048643 const messageEvent = {
84058644 sequence: 3,
......@@ -8427,10 +8666,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
84278666 yield* buildAppUnderTest({
84288667 layers: {
84298668 orchestrationEngine: {
8430 latestSequence: Effect.succeed(50),
8431 readEvents: (_afterSequence, limit) => {
8432 replayLimit = limit;
8433 return Stream.make(messageEvent);
8669 latestSequence: Effect.sync(() => headSequence),
8670 streamDomainEvents: Stream.fromPubSub(liveEvents),
8671 getThreadReplayStats: () =>
8672 Effect.sync(() => {
8673 headSequence = 100;
8674 return { eventCount: 1, payloadBytes: 100, hasCreateEvent: false };
8675 }),
8676 readThreadEvents: ({ toSequenceInclusive }) => {
8677 replayHead = toSequenceInclusive;
8678 return Stream.fromEffect(
8679 PubSub.publish(liveEvents, {
8680 ...messageEvent,
8681 sequence: 51,
8682 eventId: EventId.make("event-live-after-head"),
8683 payload: {
8684 ...messageEvent.payload,
8685 messageId: MessageId.make("message-live-after-head"),
8686 },
8687 }),
8688 ).pipe(Stream.flatMap(() => Stream.make(messageEvent)));
84348689 },
84358690 },
84368691 },
......@@ -8443,17 +8698,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
84438698 threadId: defaultThreadId,
84448699 afterSequence: 0,
84458700 requestCompletionMarker: true,
8446 }).pipe(Stream.take(2), Stream.runCollect),
8701 }).pipe(
8702 Stream.takeUntil((item) => item.kind === "synchronized"),
8703 Stream.runCollect,
8704 ),
84478705 ),
84488706 );
84498707
8450 const [first, second] = Array.from(items);
8451 assert.equal(first?.kind, "event");
8452 assert.equal(first?.kind === "event" ? first.event.sequence : null, 3);
8453 assert.equal(second?.kind, "synchronized");
8454 // The replay is bounded to the head captured before the read, not
8455 // Number.MAX_SAFE_INTEGER.
8456 assert.equal(replayLimit, 50);
8708 assert.deepEqual(
8709 items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)),
8710 [3, 51, "synchronized"],
8711 );
8712 assert.equal(replayHead, 50);
84578713 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
84588714 );
84598715
......@@ -8530,6 +8786,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
85308786 layers: {
85318787 orchestrationEngine: {
85328788 latestSequence: Effect.succeed(5),
8789 getThreadReplayStats: () =>
8790 Effect.sync(() => {
8791 replayStatsCalls += 1;
8792 return {
8793 eventCount: 5,
8794 payloadBytes: 8 * 1024 * 1024 + 1,
8795 hasCreateEvent: false,
8796 };
8797 }),
8798 readThreadEvents: () => {
8799 readEventsCalls += 1;
8800 return Stream.empty;
8801 },
85338802 readEvents: () =>
85348803 Stream.sync(() => {
85358804 readEventsCalls += 1;
apps/server/src/serverRuntimeStartup.reconcile.test.ts+4
......@@ -90,6 +90,8 @@ const runReconciliation = (input: {
9090 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory),
9191 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
9292 readEvents: () => Stream.empty,
93 readThreadEvents: () => Stream.empty,
94 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
9395 dispatch: input.dispatch,
9496 streamDomainEvents: Stream.empty,
9597 subscribeDomainEvents: Effect.succeed(Stream.empty),
......@@ -653,6 +655,8 @@ it.effect("does not fail startup when the live provider session inventory cannot
653655 }),
654656 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
655657 readEvents: () => Stream.empty,
658 readThreadEvents: () => Stream.empty,
659 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
656660 dispatch: () => Effect.die("unused"),
657661 streamDomainEvents: Stream.empty,
658662 subscribeDomainEvents: Effect.succeed(Stream.empty),
apps/server/src/serverRuntimeStartup.test.ts+6
......@@ -220,6 +220,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
220220 }),
221221 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
222222 readEvents: () => Stream.empty,
223 readThreadEvents: () => Stream.empty,
224 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
223225 dispatch: (command) =>
224226 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
225227 Effect.as({ sequence: 1 }),
......@@ -275,6 +277,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
275277 }),
276278 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
277279 readEvents: () => Stream.empty,
280 readThreadEvents: () => Stream.empty,
281 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
278282 dispatch: (command) =>
279283 Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe(
280284 Effect.as({ sequence: 1 }),
......@@ -339,6 +343,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
339343 }),
340344 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
341345 readEvents: () => Stream.empty,
346 readThreadEvents: () => Stream.empty,
347 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
342348 dispatch: (command) =>
343349 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
344350 Effect.as({ sequence: 1 }),
apps/server/src/ws.ts+46-22
......@@ -333,12 +333,9 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200;
333333// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
334334const SHELL_RESUME_MAX_GAP = 1_000;
335335
336// Same bound for thread resume. The replay reads the *global* event range and
337// filters per-thread afterwards, so a stale cursor far behind the head would
338// otherwise decode every intervening event's payload — reconnects with cursors
339// hundreds of thousands of events behind have OOM-killed servers on large
340// databases. Past this gap the client is reset with a fresh thread snapshot.
341const THREAD_RESUME_MAX_GAP = 1_000;
336// Thread replay counts only this thread's rows. Busy or pruned unrelated
337// streams must not force a full thread snapshot.
338const THREAD_RESUME_MAX_EVENTS = 1_000;
342339// Row count alone does not bound replay memory: a few events with large tool
343340// payloads can decode to gigabytes. Before replaying, sum the serialized
344341// payload bytes of the range in SQL and reset with a snapshot past this budget.
......@@ -1594,6 +1591,7 @@ const makeWsRpcLayer = (
15941591 { startImmediately: true },
15951592 );
15961593 const bufferedLiveStream = liveBuffer.stream;
1594 let replayOnMissingSnapshot: typeof bufferedLiveStream | undefined;
15971595
15981596 // When the client already loaded the snapshot over HTTP it passes
15991597 // that snapshot's sequence, and we resume the live subscription by
......@@ -1608,24 +1606,41 @@ const makeWsRpcLayer = (
16081606 // catch-up followed by the buffered/ongoing live events. Overlapping
16091607 // events are deduped by sequence on the client.
16101608 //
1611 // The replay is bounded to the projection head captured below. The
1612 // catch-up range is normally tiny (a fresh HTTP snapshot sequence),
1613 // but a stale cached cursor can sit hundreds of thousands of global
1614 // events behind — replaying that decodes every intervening event
1615 // (including every other thread's tool payloads) only to discard
1616 // almost all of them, which has OOM-killed servers on large
1617 // databases. A truncated replay would silently drop this thread's
1618 // events, so past the gap cap we reset the client with a fresh
1619 // thread snapshot instead, exactly like subscribeShell above.
1609 // Measure only this thread's rows. Global sequence gaps can
1610 // contain unrelated or pruned streams. Keep an explicit upper
1611 // bound so events after the captured head stay in the live tail.
16201612 if (input.afterSequence !== undefined) {
16211613 const afterSequence = input.afterSequence;
16221614 const headSequence = yield* orchestrationEngine.latestSequence;
1623 const replayGap = headSequence - afterSequence;
1615 const range = {
1616 threadId: input.threadId,
1617 fromSequenceExclusive: afterSequence,
1618 toSequenceInclusive: headSequence,
1619 };
1620 const replayStats =
1621 afterSequence > headSequence
1622 ? null
1623 : yield* orchestrationEngine
1624 .getThreadReplayStats({
1625 ...range,
1626 maxEvents: THREAD_RESUME_MAX_EVENTS,
1627 })
1628 .pipe(
1629 Effect.mapError(
1630 (cause) =>
1631 new OrchestrationGetSnapshotError({
1632 message: `Failed to measure thread ${input.threadId} replay range`,
1633 cause,
1634 }),
1635 ),
1636 );
16241637 if (
1625 yield* canReplayPersistedRange(afterSequence, headSequence, THREAD_RESUME_MAX_GAP)
1638 replayStats !== null &&
1639 replayStats.eventCount <= THREAD_RESUME_MAX_EVENTS &&
1640 replayStats.payloadBytes <= ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES
16261641 ) {
16271642 const catchUpStream = orchestrationEngine
1628 .readEvents(afterSequence, replayGap)
1643 .readThreadEvents({ ...range, limit: THREAD_RESUME_MAX_EVENTS })
16291644 .pipe(
16301645 Stream.filter(isThisThreadDetailEvent),
16311646 Stream.map((event) => ({
......@@ -1648,11 +1663,14 @@ const makeWsRpcLayer = (
16481663 .pipe(Effect.as(bufferedLiveStream)),
16491664 )
16501665 : bufferedLiveStream;
1651 return Stream.concat(catchUpStream, afterCatchUp);
1666 const replay = Stream.concat(catchUpStream, afterCatchUp);
1667 if (!replayStats.hasCreateEvent) {
1668 return replay;
1669 }
1670 replayOnMissingSnapshot = replay;
16521671 }
1653 // Gap too large (or cursor ahead of authoritative state): fall
1654 // through to the snapshot path so the client converges from a
1655 // fresh thread detail instead of an unbounded replay.
1672 // A recreated thread needs a fresh snapshot if it still exists.
1673 // Oversized replays and invalid cursors also use the snapshot path.
16561674 }
16571675
16581676 const snapshot = yield* projectionSnapshotQuery
......@@ -1675,6 +1693,12 @@ const makeWsRpcLayer = (
16751693 );
16761694
16771695 if (Option.isNone(snapshot)) {
1696 // The recreated thread can already be deleted. Preserve the
1697 // bounded replay and shell removal instead of retrying a
1698 // snapshot that cannot exist. Oversized ranges still fail.
1699 if (replayOnMissingSnapshot !== undefined) {
1700 return replayOnMissingSnapshot;
1701 }
16781702 return yield* new OrchestrationGetSnapshotError({
16791703 message: `Thread ${input.threadId} was not found`,
16801704 cause: input.threadId,