From 772f48de49e4cfb002a50891b2a697d6615ca80d Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:20:32 -0400 Subject: [PATCH] fix(server): migration 050 no longer stalls startup on large databases Nightly .274/.275 (PR #221) added migration 050, which backfills an event_sequence column with a correlated subquery over a CTE. SQLite plans that as a co-routine that re-scans the entire orchestration_events table for every projected row. On a 3.2 GB database (~487k events, ~222k activities) the first of three backfills did not finish in four minutes, the desktop's 60-second readiness timer killed the backend, the transaction rolled back, and every "Try Again" repeated the same work. Rewrite the three backfills as UPDATE ... FROM so the first-event lookup is materialized once and joined. Same result; both new migrations run in about eight seconds on the same database. Add a guideline line asking for migrations that rewrite rows to be timed against real history. --- AGENTS.md | 1 + .../050_ProjectionTranscriptEventSequence.ts | 50 +++++++++---------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c16f6c737..a1477ba65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,7 @@ Threadlines is dense and flat. When building or changing any user-facing surface - Comments describe how a thing is used: concise notes above functions and classes, not line-by-line narration. When you change code, keep its comments in sync. - Be careful with destructive actions (deleting files, dropping data, rewriting history) that the developer did not explicitly request. - No continuously repainting animations; they peg the GPU on high-refresh displays. +- A migration that rewrites existing rows must be timed against a database with real history (hundreds of thousands of events) before it merges. In-memory tests cannot tell a two-second backfill from one that takes hours on a user's install. - If a rule here fights the task in front of you, say so loudly and get sign-off before breaking it. ## Reference Repos diff --git a/apps/server/src/persistence/Migrations/050_ProjectionTranscriptEventSequence.ts b/apps/server/src/persistence/Migrations/050_ProjectionTranscriptEventSequence.ts index abe0edfd4..9d37eac27 100644 --- a/apps/server/src/persistence/Migrations/050_ProjectionTranscriptEventSequence.ts +++ b/apps/server/src/persistence/Migrations/050_ProjectionTranscriptEventSequence.ts @@ -9,25 +9,29 @@ const decodeActivity = Schema.decodeUnknownOption( Schema.fromJsonString(ThreadActivityAppendedPayload), ); -/** Order transcript entries by their first durable event, even after a clock correction. */ +/** + * Order transcript entries by their first durable event, even after a clock correction. + * + * Each backfill is an UPDATE ... FROM so SQLite materializes the first-event lookup once. + * A correlated subquery over the CTE re-scans the whole event log per projected row, + * which took hours on a multi-gigabyte database and stalled startup. + */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`ALTER TABLE projection_thread_messages ADD COLUMN event_sequence INTEGER`; yield* sql` - WITH first_events AS ( + UPDATE projection_thread_messages + SET event_sequence = first_events.first_sequence + FROM ( SELECT stream_id, json_extract(payload_json, '$.messageId') AS item_id, MIN(sequence) AS first_sequence FROM orchestration_events WHERE event_type IN ('thread.message-sent', 'thread.follow-up-accepted') GROUP BY stream_id, json_extract(payload_json, '$.messageId') - ) - UPDATE projection_thread_messages - SET event_sequence = ( - SELECT first_sequence FROM first_events - WHERE stream_id = projection_thread_messages.thread_id - AND item_id = projection_thread_messages.message_id - ) + ) AS first_events + WHERE first_events.stream_id = projection_thread_messages.thread_id + AND first_events.item_id = projection_thread_messages.message_id `; yield* sql` CREATE INDEX idx_projection_thread_messages_event_order @@ -36,19 +40,17 @@ export default Effect.gen(function* () { yield* sql`ALTER TABLE projection_thread_proposed_plans ADD COLUMN event_sequence INTEGER`; yield* sql` - WITH first_events AS ( + UPDATE projection_thread_proposed_plans + SET event_sequence = first_events.first_sequence + FROM ( SELECT stream_id, json_extract(payload_json, '$.proposedPlan.id') AS item_id, MIN(sequence) AS first_sequence FROM orchestration_events WHERE event_type IN ('thread.proposed-plan-upserted') GROUP BY stream_id, json_extract(payload_json, '$.proposedPlan.id') - ) - UPDATE projection_thread_proposed_plans - SET event_sequence = ( - SELECT first_sequence FROM first_events - WHERE stream_id = projection_thread_proposed_plans.thread_id - AND item_id = projection_thread_proposed_plans.plan_id - ) + ) AS first_events + WHERE first_events.stream_id = projection_thread_proposed_plans.thread_id + AND first_events.item_id = projection_thread_proposed_plans.plan_id `; yield* sql` CREATE INDEX idx_projection_thread_proposed_plans_event_order @@ -57,19 +59,17 @@ export default Effect.gen(function* () { yield* sql`ALTER TABLE projection_thread_activities ADD COLUMN event_sequence INTEGER`; yield* sql` - WITH first_events AS ( + UPDATE projection_thread_activities + SET event_sequence = first_events.first_sequence + FROM ( SELECT stream_id, json_extract(payload_json, '$.activity.id') AS item_id, MIN(sequence) AS first_sequence FROM orchestration_events WHERE event_type IN ('thread.activity-appended') GROUP BY stream_id, json_extract(payload_json, '$.activity.id') - ) - UPDATE projection_thread_activities - SET event_sequence = ( - SELECT first_sequence FROM first_events - WHERE stream_id = projection_thread_activities.thread_id - AND item_id = projection_thread_activities.activity_id - ) + ) AS first_events + WHERE first_events.stream_id = projection_thread_activities.thread_id + AND first_events.item_id = projection_thread_activities.activity_id `; yield* sql` CREATE INDEX idx_projection_thread_activities_event_order