authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-10-07 23:16:52-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 20:16:52-07:00
log04ad17425a80484bf323a7d5076bd477a9499119
tree476435246b7d7c6e4f9147264c50f786550091a8
parent9fba2093e50f208c52d9041c70a793296e311477
signature Signed by PGP key B5690EEEBB952194

fix(server): opening a long thread no longer blocks the server for a second (#17029)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 90 insertions(+), 9 deletions(-)

apps/server/src/orchestration-v2/ProjectionStore.test.ts+44
......@@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime";
2727import * as Effect from "effect/Effect";
2828import * as Layer from "effect/Layer";
2929import * as Schema from "effect/Schema";
30import * as Tracer from "effect/Tracer";
3031import * as SqlClient from "effect/sql/SqlClient";
3132import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness";
3233
......@@ -588,12 +589,26 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
588589 });
589590 yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`;
590591 }
592 const statements: Array<string> = [];
593 const tracer = Tracer.make({
594 span(options) {
595 const span = new Tracer.NativeSpan(options);
596 const end = span.end.bind(span);
597 span.end = (endTime, exit) => {
598 end(endTime, exit);
599 const query = span.attributes.get("db.query.text");
600 if (typeof query === "string") statements.push(query);
601 };
602 return span;
603 },
604 });
591605 const initial = yield* Effect.acquireUseRelease(
592606 Effect.sync(() => vi.spyOn(JSON, "parse")),
593607 (parse) =>
594608 projectionStore
595609 .getThreadSnapshotWindow(threadId, { rowLimit: 77, userTurnLimit: 10 })
596610 .pipe(
611 Effect.withTracer(tracer),
597612 Effect.tap(() =>
598613 Effect.sync(() => {
599614 // Tool outputs must not be allocated again just to collect cohort IDs.
......@@ -606,6 +621,35 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
606621 ),
607622 (parse) => Effect.sync(() => parse.mockRestore()),
608623 );
624 // Large threads blocked the server for over a second here. The boundary
625 // must come from the user-message index rather than a scan of every tool
626 // row, and payloads must not go through a sort after they are fetched.
627 const windowStatement = statements.find((statement) => statement.includes("turn_anchors"));
628 assert.isDefined(windowStatement);
629 const windowPlan = yield* sql.unsafe<{ readonly parent: number; readonly detail: string }>(
630 `EXPLAIN QUERY PLAN ${windowStatement}`,
631 );
632 assert.match(
633 windowPlan.map((row) => row.detail).join("\n"),
634 /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/,
635 );
636 const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail);
637 assert.include(
638 topLevel,
639 "SEARCH item USING INDEX sqlite_autoindex_orchestration_v2_projection_turn_items_1 (turn_item_id=?)",
640 );
641 assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY");
642 const nodeStatement = statements.find((statement) =>
643 statement.includes("WITH RECURSIVE retained"),
644 );
645 assert.isDefined(nodeStatement);
646 const nodePlan = yield* sql.unsafe<{ readonly detail: string }>(
647 `EXPLAIN QUERY PLAN ${nodeStatement}`,
648 );
649 assert.include(
650 nodePlan.map((row) => row.detail),
651 "SEARCH orchestration_v2_projection_nodes USING INDEX orchestration_v2_projection_nodes_live_idx (thread_id=?)",
652 );
609653 // Only the selected turn cohort and two lookahead anchors are decoded.
610654 assert.lengthOf(initial.projection.turnItems, 12 * 102);
611655 const bounded = buildBoundedThreadProjection({
apps/server/src/orchestration-v2/ProjectionStore.ts+16-8
......@@ -2762,7 +2762,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
27622762 END AS ordinal
27632763 FROM user_anchors
27642764 ), selected AS (
2765 SELECT payload_json, ordinal, turn_item_id, run_id, type
2765 -- Choose and sort rows by ID, then fetch payloads in that
2766 -- order. A turn window can hold megabytes of tool output, and
2767 -- carrying it through the union and sort cost about a second.
2768 SELECT ordinal, turn_item_id, run_id, type
27662769 FROM eligible
27672770 WHERE ordinal >= (SELECT ordinal FROM boundary)
27682771 ORDER BY ordinal DESC, turn_item_id DESC
......@@ -2771,10 +2774,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
27712774 WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1
27722775 ELSE ${window.rowLimit}
27732776 END
2774 ), retained AS (
2775 SELECT payload_json, ordinal, turn_item_id FROM selected
2777 ), retained AS MATERIALIZED (
2778 SELECT ordinal, turn_item_id FROM selected
27762779 UNION
2777 SELECT request.payload_json, request.ordinal, request.turn_item_id
2780 SELECT request.ordinal, request.turn_item_id
27782781 FROM orchestration_v2_projection_turn_items AS request
27792782 WHERE request.run_id IN (
27802783 SELECT run_id FROM selected
......@@ -2782,9 +2785,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
27822785 )
27832786 AND request.type = 'run_interrupt_request'
27842787 UNION
2785 SELECT latest.payload_json, latest.ordinal, latest.turn_item_id
2788 SELECT latest.ordinal, latest.turn_item_id
27862789 FROM (
2787 SELECT payload_json, ordinal, turn_item_id
2790 SELECT ordinal, turn_item_id
27882791 FROM orchestration_v2_projection_turn_items
27892792 WHERE thread_id = ${threadId}
27902793 AND ${window.anchorItemId ?? null} IS NULL
......@@ -2792,10 +2795,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
27922795 ORDER BY ordinal DESC, turn_item_id DESC
27932796 LIMIT 1
27942797 ) AS latest
2798 ORDER BY ordinal ASC, turn_item_id ASC
27952799 )
2796 SELECT payload_json
2800 -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite
2801 -- skips sorting again once the payloads are attached.
2802 SELECT item.payload_json
27972803 FROM retained
2798 ORDER BY ordinal ASC, turn_item_id ASC
2804 CROSS JOIN orchestration_v2_projection_turn_items AS item
2805 ON item.turn_item_id = retained.turn_item_id
2806 ORDER BY retained.ordinal ASC, retained.turn_item_id ASC
27992807 `;
28002808 // Reuse the decoded items for cohort IDs and the resulting projection.
28012809 // Parsing these rows separately duplicates every retained tool output.
apps/server/src/persistence/Migrations.ts+2
......@@ -73,6 +73,7 @@ import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts"
7373import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";
7474import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
7575import Migration0059 from "./Migrations/059_McpAppModelContext.ts";
76import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts";
7677
7778/**
7879 * Migration loader with all migrations defined inline.
......@@ -146,6 +147,7 @@ export const migrationEntries = [
146147 [57, "ScheduledTaskWebhooks", Migration0057],
147148 [58, "WebhookRelayDeliveries", Migration0058],
148149 [59, "McpAppModelContext", Migration0059],
150 [60, "ThreadSnapshotWindowIndexes", Migration0060],
149151] as const;
150152
151153export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts+3-1
......@@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
1313 Effect.sync(() => {
1414 assert.deepStrictEqual(
1515 migrationEntries.map(([id]) => id),
16 Array.from({ length: 59 }, (_, index) => index + 1),
16 Array.from({ length: 60 }, (_, index) => index + 1),
1717 );
1818 }),
1919 );
......@@ -31,6 +31,7 @@ layer("055_OrchestrationV2", (it) => {
3131 [57, "ScheduledTaskWebhooks"],
3232 [58, "WebhookRelayDeliveries"],
3333 [59, "McpAppModelContext"],
34 [60, "ThreadSnapshotWindowIndexes"],
3435 ]);
3536 assert.deepStrictEqual(yield* runMigrations(), []);
3637
......@@ -56,6 +57,7 @@ layer("055_OrchestrationV2", (it) => {
5657 { migration_id: 57, name: "ScheduledTaskWebhooks" },
5758 { migration_id: 58, name: "WebhookRelayDeliveries" },
5859 { migration_id: 59, name: "McpAppModelContext" },
60 { migration_id: 60, name: "ThreadSnapshotWindowIndexes" },
5961 ]);
6062
6163 const tables = yield* sql<{ readonly name: string }>`
apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts created+23
......@@ -0,0 +1,23 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 // A bounded thread snapshot finds its turn boundary from the newest user
8 // messages. Without this index it reads every turn item in the thread to
9 // find a few dozen of them. ProjectionStore's turn_anchors CTE relies on it.
10 yield* sql`
11 CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_user_message_idx
12 ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id)
13 WHERE type = 'user_message'
14 `;
15 // The same snapshot keeps every unfinished node. A long thread has thousands
16 // of finished ones, and reading them all to find a few live ones was more
17 // than half of the node query's time.
18 yield* sql`
19 CREATE INDEX IF NOT EXISTS orchestration_v2_projection_nodes_live_idx
20 ON orchestration_v2_projection_nodes(thread_id)
21 WHERE status IN ('pending', 'starting', 'running', 'waiting')
22 `;
23});
apps/server/src/persistence/reconcileV2PreviewMigration.test.ts+2
......@@ -40,6 +40,7 @@ describe("V2 preview upgrade", () => {
4040 [57, "ScheduledTaskWebhooks"],
4141 [58, "WebhookRelayDeliveries"],
4242 [59, "McpAppModelContext"],
43 [60, "ThreadSnapshotWindowIndexes"],
4344 ]);
4445 assert.deepStrictEqual(yield* runMigrations(), []);
4546 assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
......@@ -122,6 +123,7 @@ describe("V2 preview upgrade", () => {
122123 [57, "ScheduledTaskWebhooks"],
123124 [58, "WebhookRelayDeliveries"],
124125 [59, "McpAppModelContext"],
126 [60, "ThreadSnapshotWindowIndexes"],
125127 ]);
126128 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
127129 );