From f8b758dc4951b15ce95cbc2ad09b82c11601919c Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:16:24 +0100 Subject: [PATCH 1/3] perf(server): shell reads stop counting every turn item Shell snapshots and getThreadShell counted each thread's items by walking the (thread_id, run_id) index, one entry per item. Migration 076 keeps one count per (thread, run) with triggers on turn items, and the shell count queries read it. --- .../src/orchestration-v2/ProjectionStore.ts | 22 +++---- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/076_TurnItemCounts.test.ts | 60 +++++++++++++++++++ .../Migrations/076_TurnItemCounts.ts | 60 +++++++++++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 + docs/internals/performance-regressions.md | 6 +- 6 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/076_TurnItemCounts.test.ts create mode 100644 apps/server/src/persistence/Migrations/076_TurnItemCounts.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 84c650f75fe3..36e52f35d8e4 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -6079,27 +6079,21 @@ export const layer: Layer.Layer = const selectShellRunItemCounts = (threadIds: ReadonlyArray) => sql` - SELECT thread_id, run_id, COUNT(*) AS item_count - FROM orchestration_v2_projection_turn_items - WHERE run_id IS NOT NULL AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) - GROUP BY thread_id, run_id + SELECT thread_id, run_key AS run_id, item_count + FROM orchestration_v2_projection_turn_item_counts + WHERE run_key <> '' AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; - // Count all selected threads in one index walk; fork sources still need per-run counts. const selectShellItemCounts = (threadIds: ReadonlyArray) => sql` SELECT counts.thread_id, - SUM(CASE WHEN counts.run_id IS NULL OR r.status <> 'rolled_back' + SUM(CASE WHEN counts.run_key = '' OR r.status <> 'rolled_back' THEN counts.item_count ELSE 0 END) AS item_count, - SUM(CASE WHEN counts.run_id IS NULL THEN counts.item_count ELSE 0 END) + SUM(CASE WHEN counts.run_key = '' THEN counts.item_count ELSE 0 END) AS runless_item_count - FROM ( - SELECT thread_id, run_id, COUNT(*) AS item_count - FROM orchestration_v2_projection_turn_items - WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) - GROUP BY thread_id, run_id - ) counts - LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_id + FROM orchestration_v2_projection_turn_item_counts counts + LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_key + WHERE counts.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY counts.thread_id `; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1f3fb4bc6b8b..b7820505c75f 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -78,6 +78,7 @@ import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; +import Migration0076 from "./Migrations/076_TurnItemCounts.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -156,6 +157,7 @@ const migrationEntries = [ [73, "ActiveTurnItemIndexes", Migration0073], [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], [75, "SubagentDeliveryState", Migration0075], + [76, "TurnItemCounts", Migration0076], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/076_TurnItemCounts.test.ts b/apps/server/src/persistence/Migrations/076_TurnItemCounts.test.ts new file mode 100644 index 000000000000..94a460a196c5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/076_TurnItemCounts.test.ts @@ -0,0 +1,60 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; + +const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" })); + +layer("076_TurnItemCounts", (it) => { + it.effect("backfills item counts, then follows upserts, moves, and deletes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 75 }); + const upsert = (id: string, threadId: string, runId: string | null) => sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json + ) + VALUES ( + ${id}, ${threadId}, ${runId}, 1, 'assistant_message', 'completed', + '2026-10-29T00:00:00.000Z', '{}' + ) + ON CONFLICT(turn_item_id) DO UPDATE SET + thread_id = excluded.thread_id, + run_id = excluded.run_id, + status = excluded.status + `; + yield* upsert("runless-1", "thread-1", null); + yield* upsert("runless-2", "thread-1", null); + yield* upsert("run-1", "thread-1", "run-1"); + + yield* runMigrations(); + + const counts = () => + sql<{ readonly thread_id: string; readonly run_key: string; readonly item_count: number }>` + SELECT thread_id, run_key, item_count + FROM orchestration_v2_projection_turn_item_counts + ORDER BY thread_id, run_key + `; + assert.deepEqual(yield* counts(), [ + { thread_id: "thread-1", run_key: "", item_count: 2 }, + { thread_id: "thread-1", run_key: "run-1", item_count: 1 }, + ]); + + yield* upsert("run-1", "thread-1", "run-1"); + yield* upsert("run-2", "thread-2", "run-2"); + yield* upsert("runless-2", "thread-1", "run-1"); + assert.deepEqual(yield* counts(), [ + { thread_id: "thread-1", run_key: "", item_count: 1 }, + { thread_id: "thread-1", run_key: "run-1", item_count: 2 }, + { thread_id: "thread-2", run_key: "run-2", item_count: 1 }, + ]); + + yield* sql`DELETE FROM orchestration_v2_projection_turn_items WHERE thread_id = 'thread-1'`; + assert.deepEqual(yield* counts(), [ + { thread_id: "thread-2", run_key: "run-2", item_count: 1 }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/076_TurnItemCounts.ts b/apps/server/src/persistence/Migrations/076_TurnItemCounts.ts new file mode 100644 index 000000000000..a432046e6361 --- /dev/null +++ b/apps/server/src/persistence/Migrations/076_TurnItemCounts.ts @@ -0,0 +1,60 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +/** + * Shell reads count items per (thread, run) here instead of walking every turn item. Triggers + * keep it exact for every writer, including projection rebuilds and dev-database pruning. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // run_key is '' for items without a run; NULLs never conflict in a primary key. + yield* sql` + CREATE TABLE orchestration_v2_projection_turn_item_counts ( + thread_id TEXT NOT NULL, + run_key TEXT NOT NULL, + item_count INTEGER NOT NULL, + PRIMARY KEY (thread_id, run_key) + ) WITHOUT ROWID + `; + yield* sql` + INSERT INTO orchestration_v2_projection_turn_item_counts (thread_id, run_key, item_count) + SELECT thread_id, COALESCE(run_id, ''), COUNT(*) + FROM orchestration_v2_projection_turn_items + GROUP BY thread_id, run_id + `; + // An upsert that hits an existing item fires only the update trigger. + yield* sql` + CREATE TRIGGER orchestration_v2_turn_item_counts_insert + AFTER INSERT ON orchestration_v2_projection_turn_items + BEGIN + INSERT INTO orchestration_v2_projection_turn_item_counts (thread_id, run_key, item_count) + VALUES (new.thread_id, COALESCE(new.run_id, ''), 1) + ON CONFLICT (thread_id, run_key) DO UPDATE SET item_count = item_count + 1; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_turn_item_counts_update + AFTER UPDATE OF thread_id, run_id ON orchestration_v2_projection_turn_items + WHEN old.thread_id IS NOT new.thread_id OR old.run_id IS NOT new.run_id + BEGIN + UPDATE orchestration_v2_projection_turn_item_counts SET item_count = item_count - 1 + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, ''); + DELETE FROM orchestration_v2_projection_turn_item_counts + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, '') AND item_count <= 0; + INSERT INTO orchestration_v2_projection_turn_item_counts (thread_id, run_key, item_count) + VALUES (new.thread_id, COALESCE(new.run_id, ''), 1) + ON CONFLICT (thread_id, run_key) DO UPDATE SET item_count = item_count + 1; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_turn_item_counts_delete + AFTER DELETE ON orchestration_v2_projection_turn_items + BEGIN + UPDATE orchestration_v2_projection_turn_item_counts SET item_count = item_count - 1 + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, ''); + DELETE FROM orchestration_v2_projection_turn_item_counts + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, '') AND item_count <= 0; + END + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 4fefa61ec010..ece334f1b148 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -55,6 +55,7 @@ describe("V2 preview upgrade", () => { [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], + [76, "TurnItemCounts"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -153,6 +154,7 @@ describe("V2 preview upgrade", () => { [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], + [76, "TurnItemCounts"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 4458f1af9346..adb344e220ab 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -101,9 +101,9 @@ sessions. The orchestrator still validates the current thread before applying se Read receipts use `ProjectionStore.getThread` to load only canonical thread metadata. A visit advances `lastVisitedAt` monotonically without changing `updatedAt` or decoding history. Shell -queries use a covering thread/run index for item counts and a partial index for nonterminal -background items, including idle items. Latest user-message reads use an index ordered by update -time. +queries read item counts from a per-thread, per-run table that turn-item triggers keep exact, and +use a partial index for nonterminal background items, including idle items. Latest user-message +reads use an index ordered by update time. Command palette results keep live VCS and linked pull-request queries leased to the visible viewport, including a small overscan region. Cached badges remain available while leases are From ca7579374f165135a3816ab0268d4486126c7c88 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:34:34 +0100 Subject: [PATCH 2/3] fix(server): projection rebuilds reset the turn item counts A count the triggers could not see change, such as one inflated by an INSERT OR REPLACE, survived a rebuild because deleting the items only decrements it. Rebuilds now clear the table before re-projecting. --- .../src/orchestration-v2/FoundationPersistence.test.ts | 8 ++++++++ apps/server/src/orchestration-v2/ProjectionMaintenance.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 770da1939848..435ae1c97e48 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -3282,10 +3282,18 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { const broken = yield* maintenance.verify; assert.isFalse(broken.valid); assert.deepEqual(broken.unreadableThreadIds, [threadId]); + yield* sql`UPDATE orchestration_v2_projection_turn_item_counts SET item_count = item_count + 1000`; const rebuilt = yield* maintenance.rebuild; assert.isTrue(rebuilt.valid); assert.lengthOf((yield* projectionStore.getThreadProjection(threadId)).turnItems, 151); + assert.deepEqual( + yield* sql` + SELECT SUM(item_count) AS items FROM orchestration_v2_projection_turn_item_counts + WHERE thread_id = ${threadId} + `, + [{ items: 151 }], + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index aad9d355924b..a09e01dd50f3 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -122,6 +122,7 @@ export const layer: Layer.Layer< yield* sql`DELETE FROM orchestration_v2_projection_checkpoints`; yield* sql`DELETE FROM orchestration_v2_projection_checkpoint_scopes`; yield* sql`DELETE FROM orchestration_v2_projection_turn_items`; + yield* sql`DELETE FROM orchestration_v2_projection_turn_item_counts`; yield* sql`DELETE FROM orchestration_v2_projection_plans`; yield* sql`DELETE FROM orchestration_v2_projection_messages`; yield* sql`DELETE FROM orchestration_v2_projection_runtime_requests`; From 05c11a8e80e189b025bffcae722c903e3467842a Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:56:42 +0100 Subject: [PATCH 3/3] Revert "fix(server): projection rebuilds reset the turn item counts" Migration tests rebuild projections on databases migrated only to 064 or 066, before the counts table exists, and the extra DELETE failed there. The triggers already keep the table exact for every writer. --- .../src/orchestration-v2/FoundationPersistence.test.ts | 8 -------- apps/server/src/orchestration-v2/ProjectionMaintenance.ts | 1 - 2 files changed, 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 435ae1c97e48..770da1939848 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -3282,18 +3282,10 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { const broken = yield* maintenance.verify; assert.isFalse(broken.valid); assert.deepEqual(broken.unreadableThreadIds, [threadId]); - yield* sql`UPDATE orchestration_v2_projection_turn_item_counts SET item_count = item_count + 1000`; const rebuilt = yield* maintenance.rebuild; assert.isTrue(rebuilt.valid); assert.lengthOf((yield* projectionStore.getThreadProjection(threadId)).turnItems, 151); - assert.deepEqual( - yield* sql` - SELECT SUM(item_count) AS items FROM orchestration_v2_projection_turn_item_counts - WHERE thread_id = ${threadId} - `, - [{ items: 151 }], - ); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index a09e01dd50f3..aad9d355924b 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -122,7 +122,6 @@ export const layer: Layer.Layer< yield* sql`DELETE FROM orchestration_v2_projection_checkpoints`; yield* sql`DELETE FROM orchestration_v2_projection_checkpoint_scopes`; yield* sql`DELETE FROM orchestration_v2_projection_turn_items`; - yield* sql`DELETE FROM orchestration_v2_projection_turn_item_counts`; yield* sql`DELETE FROM orchestration_v2_projection_plans`; yield* sql`DELETE FROM orchestration_v2_projection_messages`; yield* sql`DELETE FROM orchestration_v2_projection_runtime_requests`;