Skip to content

Commit 12fb4a9

Browse files
feat(db): auto-apply tracked script data migrations in db:migrate (#5497)
* feat(db): auto-apply tracked script data migrations in db:migrate * fix(db): reset session lock_timeout before script migrations, guard journal insert * improvement(db): re-verify advisory-lock session before script migrations
1 parent 41fdcdb commit 12fb4a9

10 files changed

Lines changed: 226 additions & 150 deletions

File tree

apps/sim/lib/table/order-key.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@
1111
* repeated same-spot inserts.
1212
*/
1313

14-
import {
15-
generateKeyBetween,
16-
generateNKeysBetween,
17-
} from '@/lib/fractional-indexing/fractional-indexing'
14+
import { generateKeyBetween, generateNKeysBetween } from '@sim/utils/fractional-indexing'
1815

1916
/**
2017
* Returns a key that sorts strictly between `a` and `b`. Pass `null` for an open

apps/sim/scripts/backfill-table-order-keys.ts

Lines changed: 0 additions & 130 deletions
This file was deleted.

apps/sim/scripts/repair-table-order-key-collation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
* the original backfill also used — minting a fresh, evenly-spaced, distinct run
2121
* with `nKeysBetween`.
2222
*
23-
* Distinct from `backfill-table-order-keys.ts`, which keys tables with NULL keys;
23+
* Distinct from the `0001_backfill_table_order_keys` script migration
24+
* (`packages/db/script-migrations/`), which keys tables with NULL keys;
2425
* this one repairs tables that are fully keyed but bytewise-disordered. Run it
2526
* AFTER migration 0228 so the re-key writes and sorts under `COLLATE "C"`.
2627
*
@@ -41,7 +42,7 @@ import { drizzle } from 'drizzle-orm/postgres-js'
4142
import postgres from 'postgres'
4243
import { nKeysBetween } from '@/lib/table/order-key'
4344

44-
/** See backfill-table-order-keys.ts — keeps each VALUES list well under the param/stack ceilings. */
45+
/** Keeps each VALUES list well under Postgres's 65535-bound-param and JS call-stack ceilings. */
4546
const WRITE_CHUNK_SIZE = 5000
4647

4748
export async function runRepair(): Promise<void> {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { getErrorMessage } from '@sim/utils/errors'
2+
import { generateNKeysBetween } from '@sim/utils/fractional-indexing'
3+
import type { ScriptMigration } from './types'
4+
5+
/**
6+
* Rows written per `UPDATE … FROM unnest(...)` statement. The two unnest
7+
* arrays are single bind parameters, so chunking only bounds per-statement
8+
* work and wire-message size, not parameter count. Chunks share the one
9+
* per-table transaction, so the table is still keyed atomically.
10+
*/
11+
const WRITE_CHUNK_SIZE = 5000
12+
13+
/**
14+
* Backfills the `order_key` column on `user_table_rows`.
15+
*
16+
* Row ordering moved from the contiguous integer `position` to a fractional
17+
* string `order_key` (O(1) insert/delete — no reshift/recompact). Each existing
18+
* row gets a key derived from its current `position` order, so the fractional
19+
* ordering matches today's once `TABLES_FRACTIONAL_ORDERING` is on.
20+
*
21+
* Per-table-atomic: each table is keyed inside one transaction holding the same
22+
* per-table advisory lock the app uses for inserts, so a concurrent insert
23+
* can't interleave. Idempotent: tables already fully keyed are skipped; a table
24+
* with any NULL key is fully re-keyed from `position` order (deterministic, so
25+
* a re-run after a partial failure is safe). Per-table failures are isolated —
26+
* remaining tables still run, then the migration throws so it stays pending and
27+
* retries (only still-unkeyed tables) on the next upgrade.
28+
*/
29+
export const backfillTableOrderKeys: ScriptMigration = {
30+
name: '0001_backfill_table_order_keys',
31+
async up(sql) {
32+
const pending = await sql<{ table_id: string }[]>`
33+
SELECT DISTINCT table_id FROM user_table_rows WHERE order_key IS NULL
34+
`
35+
console.log(`order_key backfill — ${pending.length} table(s) with NULL order_key`)
36+
37+
const stats = { tablesKeyed: 0, rowsKeyed: 0, failed: 0 }
38+
39+
for (const { table_id: tableId } of pending) {
40+
try {
41+
const keyed = await sql.begin(async (tx) => {
42+
await tx`
43+
SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))
44+
`
45+
const rows = await tx<{ id: string }[]>`
46+
SELECT id FROM user_table_rows
47+
WHERE table_id = ${tableId}
48+
ORDER BY position ASC, id ASC
49+
`
50+
if (rows.length === 0) return 0
51+
const keys = generateNKeysBetween(null, null, rows.length)
52+
53+
for (let start = 0; start < rows.length; start += WRITE_CHUNK_SIZE) {
54+
const chunk = rows.slice(start, start + WRITE_CHUNK_SIZE)
55+
const ids = chunk.map((r) => r.id)
56+
const chunkKeys = keys.slice(start, start + chunk.length)
57+
await tx`
58+
UPDATE user_table_rows AS t
59+
SET order_key = v.order_key
60+
FROM (
61+
SELECT unnest(${ids}::text[]) AS id, unnest(${chunkKeys}::text[]) AS order_key
62+
) AS v
63+
WHERE t.id = v.id AND t.table_id = ${tableId}
64+
`
65+
}
66+
return rows.length
67+
})
68+
stats.tablesKeyed += 1
69+
stats.rowsKeyed += keyed
70+
console.log(` ${tableId}: keyed ${keyed} rows`)
71+
} catch (error) {
72+
stats.failed += 1
73+
console.error(` ${tableId}: FAILED — ${getErrorMessage(error)}`)
74+
}
75+
}
76+
77+
console.log(
78+
`order_key backfill done — tables keyed: ${stats.tablesKeyed}, ` +
79+
`rows keyed: ${stats.rowsKeyed}, failed: ${stats.failed}`
80+
)
81+
if (stats.failed > 0) {
82+
throw new Error(`order_key backfill failed for ${stats.failed} table(s); will retry next run`)
83+
}
84+
},
85+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Sql } from 'postgres'
2+
import { backfillTableOrderKeys } from './0001_backfill_table_order_keys'
3+
import type { ScriptMigration } from './types'
4+
5+
export type { ScriptMigration } from './types'
6+
7+
/**
8+
* Ordered, append-only registry of script migrations. An entry may be deleted
9+
* once a later SQL migration supersedes it (accepting that deployments which
10+
* never ran it skip the backfill) — never renamed or reordered.
11+
*/
12+
export const scriptMigrations: readonly ScriptMigration[] = [backfillTableOrderKeys]
13+
14+
/**
15+
* Applies pending script migrations in registry order, recording each by name.
16+
* Runs inside the migration advisory-lock session, immediately after drizzle's
17+
* SQL migrations succeed — so a script always sees the fully-migrated schema of
18+
* the release that ships it. The tracking table is runner-managed (like
19+
* drizzle's own `__drizzle_migrations`), not part of the app schema.
20+
*
21+
* Fails fast: a missing required env var or a throwing `up` aborts the run
22+
* before the name is recorded, so the migration retries on the next upgrade.
23+
*/
24+
export async function runScriptMigrations(sql: Sql): Promise<void> {
25+
const names = new Set<string>()
26+
for (const migration of scriptMigrations) {
27+
if (names.has(migration.name)) {
28+
throw new Error(`Duplicate script migration name: ${migration.name}`)
29+
}
30+
names.add(migration.name)
31+
}
32+
33+
/**
34+
* The SQL-migration phase leaves `lock_timeout = '5s'` on this session (DDL
35+
* must fail fast rather than queue a table-wide stall). Script migrations
36+
* want the opposite: they block on app-held row/advisory locks (e.g. the
37+
* per-table insert lock in the order_key backfill) and must wait them out —
38+
* exactly like the standalone scripts they replace, which ran on fresh
39+
* connections with the Postgres defaults. `SET` cannot be parameterized,
40+
* hence `unsafe` with constants.
41+
*/
42+
await sql.unsafe('SET statement_timeout = 0')
43+
await sql.unsafe('SET lock_timeout = 0')
44+
45+
await sql`
46+
CREATE TABLE IF NOT EXISTS script_migrations (
47+
name text PRIMARY KEY,
48+
applied_at timestamptz NOT NULL DEFAULT now()
49+
)
50+
`
51+
const appliedRows = await sql<{ name: string }[]>`SELECT name FROM script_migrations`
52+
const applied = new Set(appliedRows.map((row) => row.name))
53+
54+
const pending = scriptMigrations.filter((migration) => !applied.has(migration.name))
55+
if (pending.length === 0) {
56+
console.log('No pending script migrations.')
57+
return
58+
}
59+
60+
for (const migration of pending) {
61+
for (const key of migration.requiredEnv ?? []) {
62+
if (!process.env[key]) {
63+
throw new Error(
64+
`Script migration ${migration.name} requires env var ${key}; set it on the migrations container.`
65+
)
66+
}
67+
}
68+
console.log(`Applying script migration ${migration.name}...`)
69+
const startedAt = Date.now()
70+
await migration.up(sql)
71+
await sql`
72+
INSERT INTO script_migrations (name) VALUES (${migration.name})
73+
ON CONFLICT (name) DO NOTHING
74+
`
75+
console.log(`Script migration ${migration.name} applied in ${Date.now() - startedAt}ms.`)
76+
}
77+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Sql } from 'postgres'
2+
3+
/**
4+
* A run-once TypeScript data migration, applied by the migration runner
5+
* (`scripts/migrate.ts`) after all pending SQL migrations succeed and recorded
6+
* by name in the `script_migrations` table — the code-migration analogue of
7+
* drizzle's `__drizzle_migrations` journal.
8+
*
9+
* Authoring rules:
10+
* - **Idempotent and resumable.** The name is recorded only after `up`
11+
* resolves; a crash mid-run means the whole migration re-runs from the top
12+
* on the next upgrade. Guard with cheap preconditions (`WHERE x IS NULL`,
13+
* `ON CONFLICT DO NOTHING`) so re-runs are near-free.
14+
* - **db-level imports only** (`postgres`, `drizzle-orm`, `@sim/utils`). The
15+
* migrations docker image ships `packages/db` + `utils` + `logger` and
16+
* nothing from `apps/*`.
17+
* - **Runs at whatever schema HEAD the release ships** — scripts are not
18+
* interleaved with SQL migrations. A SQL migration must never drop or
19+
* repurpose a column that a registered script still reads; delete the
20+
* script from the registry in the same PR instead.
21+
* - **Owns its transactions.** The runner deliberately does not wrap `up` in
22+
* one: backfills commit per batch and cannot be rolled back wholesale.
23+
*/
24+
export interface ScriptMigration {
25+
/** Unique stable identifier recorded in `script_migrations`; never rename after release. */
26+
name: string
27+
/** Env vars the migration needs; the runner throws before `up` if any is unset. */
28+
requiredEnv?: readonly string[]
29+
/**
30+
* Applies the migration using the runner's session. The runner resets
31+
* `statement_timeout` and `lock_timeout` to 0 first, so long backfills and
32+
* blocking lock acquisitions wait instead of aborting with `55P03`.
33+
*/
34+
up(sql: Sql): Promise<void>
35+
}

0 commit comments

Comments
 (0)