Skip to content

Commit d7e6666

Browse files
fix(tables): repair only true duplicate order_keys, re-key by order_key not position
The repair script detected mis-keying by walking position order and flagging any table whose order_key disagreed. Under TABLES_FRACTIONAL_ORDERING that disagreement is normal — position is an append counter, order_key is authoritative — so every flag-on middle-insert false-positived, and re-keying by position would scramble the real order. Now it flags only tables with actual duplicate keys and re-keys in (order_key, id) display order. Verified against staging: drops the false positive, keeps the two genuinely-duplicated tables.
1 parent 27fc6dd commit d7e6666

1 file changed

Lines changed: 36 additions & 43 deletions

File tree

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

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
11
#!/usr/bin/env bun
22

33
/**
4-
* One-off repair for `user_table_rows.order_key` rows mis-ordered by the
5-
* collation bug fixed in migration 0228.
4+
* One-off repair for DUPLICATE `user_table_rows.order_key` values.
65
*
7-
* Fractional `order_key`s are base-62 strings the fractional-indexing library
8-
* compares BYTEWISE (ASCII: `0-9 < A-Z < a-z`). Before migration 0228 the column
9-
* compared under the database's `en_US.UTF-8` locale, where lowercase interleaves
10-
* with/precedes uppercase ("a0" < "Zz", the opposite of bytewise). Keys minted in
11-
* that window were anchored to the wrong neighbors, so a table's keys can be
12-
* out of order — or duplicated — under bytewise comparison. That makes inserts
13-
* throw `generateKeyBetween`'s `a >= b` assertion and rows display out of order.
6+
* Fractional `order_key`s must be unique within a table: they're the authoritative
7+
* row order under `TABLES_FRACTIONAL_ORDERING`, and `generateKeyBetween` throws
8+
* `a >= b` if a neighbor lookup ever returns a key equal to the anchor. Some tables
9+
* accumulated duplicate keys — e.g. a batch insert that minted the same key for many
10+
* rows, or keys written before the collation fix in migration 0228. This script
11+
* finds every table with duplicate keys and re-keys it, minting a fresh DISTINCT run
12+
* with `nKeysBetween` while preserving the current display order.
1413
*
15-
* This script finds every table whose `order_key`s are mis-assigned: walking rows
16-
* in their authoritative `position, id` order, a row's key is `>=` the next row's
17-
* under bytewise (`COLLATE "C"`) comparison. That covers swapped keys (a low
18-
* position holding a bytewise-larger key than a higher one) and duplicates. Each
19-
* flagged table is re-keyed from `position` order — the legacy authoritative order
20-
* the original backfill also used — minting a fresh, evenly-spaced, distinct run
21-
* with `nKeysBetween`.
14+
* Ordering matters. Rows are re-keyed in `(order_key, id)` order — exactly how the
15+
* app sorts them under the flag (`order_key` authoritative, `id` as the tiebreak
16+
* duplicates currently fall back to). We deliberately do NOT re-key by `position`:
17+
* with the fractional flag on, `position` is only an append counter (a row inserted
18+
* in the middle gets a mid-range key but the largest `position`), so re-keying by
19+
* `position` would scramble the real order. Run AFTER migration 0228 so `order_key`
20+
* sorts bytewise (`COLLATE "C"`), matching the library and the app.
2221
*
23-
* Distinct from `backfill-table-order-keys.ts`, which keys tables with NULL keys;
24-
* this one repairs tables that are fully keyed but bytewise-disordered. Run it
25-
* AFTER migration 0228 so the re-key writes and sorts under `COLLATE "C"`.
22+
* Distinct from `backfill-table-order-keys.ts`, which keys rows with NULL keys. This
23+
* one only touches tables that have actual duplicates — a table whose keys merely
24+
* disagree with `position` (normal for flag-on middle-inserts) is left alone.
2625
*
27-
* Per-table-atomic: each table is re-keyed inside one transaction holding the
28-
* same per-table advisory lock the app uses for inserts, so a concurrent insert
29-
* can't interleave. Idempotent: a table whose keys are already distinct and
30-
* ordered is never selected, so a re-run after a partial failure is safe.
26+
* Per-table-atomic: each table is re-keyed inside one transaction holding the same
27+
* per-table advisory lock the app uses for inserts, so a concurrent insert can't
28+
* interleave. Idempotent: a table with no duplicate keys is never selected, so a
29+
* re-run after a partial failure is safe. `--dry-run` sizes without locking or
30+
* writing.
3131
*
3232
* Usage:
3333
* DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts
@@ -64,39 +64,29 @@ export async function runRepair(): Promise<void> {
6464
const stats = { tables: 0, tablesKeyed: 0, rowsKeyed: 0, failed: 0 }
6565

6666
try {
67-
// Tables with a bytewise (`COLLATE "C"`) inversion or duplicate among their
68-
// non-null keys. Walking rows in their authoritative `position, id` order (the
69-
// order the re-key writes), a healthy table has strictly INCREASING keys; flag
70-
// any table where a row's key is `>=` the next row's. Ordering by `position`
71-
// (not by `order_key`) is what makes this detect actual mis-assignment — e.g.
72-
// pos 0 holding "a0" while pos 1 holds "Zz" (bytewise "Zz" < "a0") — and not
73-
// just adjacent duplicates. The explicit `COLLATE "C"` keeps the comparison
74-
// bytewise whether or not migration 0228 has been applied yet.
67+
// Tables that have at least one duplicate `order_key`. This is the only genuine
68+
// corruption: distinct keys that merely disagree with `position` are normal
69+
// under the flag and must NOT be touched.
7570
const pending = await db.execute<{ table_id: string }>(sql`
7671
SELECT DISTINCT table_id FROM (
77-
SELECT
78-
table_id,
79-
order_key,
80-
LEAD(order_key) OVER (
81-
PARTITION BY table_id ORDER BY position, id
82-
) AS next_key
72+
SELECT table_id
8373
FROM user_table_rows
8474
WHERE order_key IS NOT NULL
85-
) t
86-
WHERE next_key IS NOT NULL AND order_key COLLATE "C" >= next_key COLLATE "C"
75+
GROUP BY table_id, order_key
76+
HAVING count(*) > 1
77+
) d
8778
`)
8879

8980
console.log(
90-
`Repair starting — ${pending.length} table(s) with mis-ordered keys${dryRun ? ' [DRY RUN]' : ''}`
81+
`Repair starting — ${pending.length} table(s) with duplicate keys${dryRun ? ' [DRY RUN]' : ''}`
9182
)
9283

9384
for (const { table_id: tableId } of pending) {
9485
stats.tables += 1
9586
try {
9687
if (dryRun) {
9788
// Sizing only — count outside any transaction/lock so we never serialize
98-
// live inserts on the table (taking the advisory lock just to count would
99-
// make the dry run the opposite of safe).
89+
// live inserts on the table.
10090
const [row] = await db
10191
.select({ rowCount: sql<number>`count(*)`.mapWith(Number) })
10292
.from(userTableRows)
@@ -112,11 +102,14 @@ export async function runRepair(): Promise<void> {
112102
await trx.execute(
113103
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))`
114104
)
105+
// Read in the app's display order — `order_key` (bytewise via COLLATE "C"
106+
// after migration 0228), `id` as the duplicate tiebreak — so the fresh run
107+
// preserves exactly what users currently see, minus the duplication.
115108
const rows = await trx
116109
.select({ id: userTableRows.id })
117110
.from(userTableRows)
118111
.where(eq(userTableRows.tableId, tableId))
119-
.orderBy(asc(userTableRows.position), asc(userTableRows.id))
112+
.orderBy(asc(userTableRows.orderKey), asc(userTableRows.id))
120113

121114
if (rows.length === 0) return 0
122115
const keys = nKeysBetween(null, null, rows.length)

0 commit comments

Comments
 (0)