Skip to content

Commit f14bde6

Browse files
fix(tables): compare order_key bytewise (COLLATE "C") to stop insert collation errors
1 parent c90a1eb commit f14bde6

7 files changed

Lines changed: 16728 additions & 26 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Locks in the BYTEWISE ordering the rest of the stack depends on: `order_key` is
5+
* stored `COLLATE "C"` (migration 0228) so Postgres compares keys the same way this
6+
* library does. If the library's order ever diverged from ASCII byte order — e.g.
7+
* across the `Z` (0x5A) < `a` (0x61) integer-head boundary, exactly where the
8+
* `en_US.UTF-8` locale disagrees — inserts would mint keys that fail the `a >= b`
9+
* assertion and rows would display out of order. These tests would catch that.
10+
*/
11+
import { describe, expect, it } from 'vitest'
12+
import {
13+
BASE_62_DIGITS,
14+
generateKeyBetween,
15+
generateNKeysBetween,
16+
} from '@/lib/fractional-indexing/fractional-indexing'
17+
18+
/** Bytewise (ASCII / UTF-16 code-unit) comparator — what `COLLATE "C"` and the library use. */
19+
const byteCompare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
20+
21+
describe('fractional-indexing bytewise ordering', () => {
22+
it('uses an alphabet in ascending char-code order with uppercase before lowercase', () => {
23+
for (let i = 1; i < BASE_62_DIGITS.length; i++) {
24+
expect(BASE_62_DIGITS.charCodeAt(i)).toBeGreaterThan(BASE_62_DIGITS.charCodeAt(i - 1))
25+
}
26+
// The boundary en_US.UTF-8 inverts: bytewise 'Z' < 'a', locale 'a' < 'Z'.
27+
expect('Z' < 'a').toBe(true)
28+
expect(BASE_62_DIGITS.indexOf('Z')).toBeLessThan(BASE_62_DIGITS.indexOf('a'))
29+
})
30+
31+
it('produces strictly bytewise-increasing keys on repeated append', () => {
32+
let prev: string | null = null
33+
let last = ''
34+
for (let i = 0; i < 200; i++) {
35+
const key: string = generateKeyBetween(prev, null)
36+
if (last) expect(byteCompare(last, key)).toBe(-1)
37+
last = key
38+
prev = key
39+
}
40+
})
41+
42+
it('stays bytewise-ordered when prepends cross the Z/a integer-head boundary', () => {
43+
// Repeated prepend walks the integer head down out of 'a' into 'Z','Y',… —
44+
// the exact uppercase region en_US.UTF-8 sorts wrong. The first prepend before
45+
// "a0" already yields a 'Z'-headed key ("Zz").
46+
const keys: string[] = []
47+
let next: string | null = null
48+
for (let i = 0; i < 60; i++) {
49+
const key: string = generateKeyBetween(null, next)
50+
keys.push(key)
51+
next = key
52+
}
53+
const ascending = [...keys].reverse() // prepends are emitted largest → smallest
54+
expect([...ascending].sort(byteCompare)).toEqual(ascending)
55+
expect(new Set(keys).size).toBe(keys.length) // all distinct
56+
// An uppercase-headed key sorts before the lowercase-headed "a0" at the tail.
57+
expect(ascending.some((k) => k[0] >= 'A' && k[0] <= 'Z')).toBe(true)
58+
expect(ascending[ascending.length - 1][0]).toBe('a')
59+
})
60+
61+
it('mints a contiguous run that is bytewise-sorted and distinct', () => {
62+
const keys = generateNKeysBetween(null, null, 500)
63+
expect([...keys].sort(byteCompare)).toEqual(keys)
64+
expect(new Set(keys).size).toBe(keys.length)
65+
})
66+
67+
it('throws when bounds are out of order — the contract COLLATE "C" must satisfy', () => {
68+
expect(() => generateKeyBetween('a1', 'a0')).toThrow()
69+
expect(() => generateKeyBetween('a0', 'a0')).toThrow()
70+
})
71+
})

apps/sim/lib/table/service.ts

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
gte,
2828
inArray,
2929
isNull,
30+
lt,
3031
ne,
3132
or,
3233
type SQL,
@@ -1268,38 +1269,42 @@ async function resolveInsertByNeighbor(
12681269
}
12691270

12701271
if (afterRowId) {
1271-
// hi = the smallest key strictly after the anchor.
1272-
const [next] = await trx
1273-
.select({ orderKey: userTableRows.orderKey })
1274-
.from(userTableRows)
1275-
.where(
1276-
and(
1277-
eq(userTableRows.tableId, tableId),
1278-
sql`(${userTableRows.orderKey}, ${userTableRows.id}) > (${anchorKey}, ${afterRowId})`
1279-
)
1280-
)
1281-
.orderBy(asc(userTableRows.orderKey), asc(userTableRows.id))
1282-
.limit(1)
1272+
// hi = the smallest key strictly GREATER than the anchor key. Comparing keys
1273+
// (not the `(order_key, id)` row tuple) skips past any sibling that shares the
1274+
// anchor's key, so `keyBetween` always gets strictly-ordered bounds and can't
1275+
// throw on a stray duplicate. Identical to the row tuple when keys are distinct.
1276+
// A null anchorKey (flag off, un-backfilled) has no key to compare — leave the
1277+
// upper bound open, matching the prior best-effort behavior.
1278+
let nextKey: string | null = null
1279+
if (anchorKey !== null) {
1280+
const [next] = await trx
1281+
.select({ orderKey: userTableRows.orderKey })
1282+
.from(userTableRows)
1283+
.where(and(eq(userTableRows.tableId, tableId), gt(userTableRows.orderKey, anchorKey)))
1284+
.orderBy(asc(userTableRows.orderKey))
1285+
.limit(1)
1286+
nextKey = next?.orderKey ?? null
1287+
}
12831288
return {
1284-
orderKey: keyBetween(anchorKey, next?.orderKey ?? null),
1289+
orderKey: keyBetween(anchorKey, nextKey),
12851290
position: anchor.position + 1,
12861291
}
12871292
}
12881293

1289-
// beforeRowId: lo = the largest key strictly before the anchor.
1290-
const [prev] = await trx
1291-
.select({ orderKey: userTableRows.orderKey })
1292-
.from(userTableRows)
1293-
.where(
1294-
and(
1295-
eq(userTableRows.tableId, tableId),
1296-
sql`(${userTableRows.orderKey}, ${userTableRows.id}) < (${anchorKey}, ${beforeRowId})`
1297-
)
1298-
)
1299-
.orderBy(desc(userTableRows.orderKey), desc(userTableRows.id))
1300-
.limit(1)
1294+
// beforeRowId: lo = the largest key strictly LESS than the anchor key (distinct,
1295+
// same rationale as the afterRowId branch above).
1296+
let prevKey: string | null = null
1297+
if (anchorKey !== null) {
1298+
const [prev] = await trx
1299+
.select({ orderKey: userTableRows.orderKey })
1300+
.from(userTableRows)
1301+
.where(and(eq(userTableRows.tableId, tableId), lt(userTableRows.orderKey, anchorKey)))
1302+
.orderBy(desc(userTableRows.orderKey))
1303+
.limit(1)
1304+
prevKey = prev?.orderKey ?? null
1305+
}
13011306
return {
1302-
orderKey: keyBetween(prev?.orderKey ?? null, anchorKey),
1307+
orderKey: keyBetween(prevKey, anchorKey),
13031308
position: anchor.position,
13041309
}
13051310
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* One-off repair for `user_table_rows.order_key` rows mis-ordered by the
5+
* collation bug fixed in migration 0228.
6+
*
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.
14+
*
15+
* This script finds every table whose keys are mis-ordered under `COLLATE "C"`
16+
* (some row's key `>=` the next row's key — covers both inversions and
17+
* duplicates) and re-keys it from `position` order — the legacy authoritative
18+
* order the original backfill also used — minting a fresh, evenly-spaced,
19+
* distinct run with `nKeysBetween`.
20+
*
21+
* Distinct from `backfill-table-order-keys.ts`, which keys tables with NULL keys;
22+
* this one repairs tables that are fully keyed but bytewise-disordered. Run it
23+
* AFTER migration 0228 so the re-key writes and sorts under `COLLATE "C"`.
24+
*
25+
* Per-table-atomic: each table is re-keyed inside one transaction holding the
26+
* same per-table advisory lock the app uses for inserts, so a concurrent insert
27+
* can't interleave. Idempotent: a table whose keys are already distinct and
28+
* ordered is never selected, so a re-run after a partial failure is safe.
29+
*
30+
* Usage:
31+
* DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts
32+
* DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts --dry-run
33+
*/
34+
35+
import { userTableRows } from '@sim/db/schema'
36+
import { getErrorMessage } from '@sim/utils/errors'
37+
import { asc, eq, sql } from 'drizzle-orm'
38+
import { drizzle } from 'drizzle-orm/postgres-js'
39+
import postgres from 'postgres'
40+
import { nKeysBetween } from '@/lib/table/order-key'
41+
42+
/** See backfill-table-order-keys.ts — keeps each VALUES list well under the param/stack ceilings. */
43+
const WRITE_CHUNK_SIZE = 5000
44+
45+
export async function runRepair(): Promise<void> {
46+
const dryRun = process.argv.includes('--dry-run')
47+
const connectionString = process.env.DATABASE_URL ?? process.env.POSTGRES_URL
48+
if (!connectionString) {
49+
console.error('Missing DATABASE_URL or POSTGRES_URL')
50+
process.exit(1)
51+
}
52+
53+
const client = postgres(connectionString, {
54+
prepare: false,
55+
idle_timeout: 20,
56+
connect_timeout: 30,
57+
max: 5,
58+
onnotice: () => {},
59+
})
60+
const db = drizzle(client)
61+
62+
const stats = { tables: 0, tablesKeyed: 0, rowsKeyed: 0, failed: 0 }
63+
64+
try {
65+
// Tables with a bytewise (`COLLATE "C"`) inversion or duplicate among their
66+
// non-null keys. The explicit `COLLATE "C"` makes detection correct whether or
67+
// not migration 0228 has been applied yet.
68+
const pending = await db.execute<{ table_id: string }>(sql`
69+
SELECT DISTINCT table_id FROM (
70+
SELECT
71+
table_id,
72+
order_key,
73+
LEAD(order_key) OVER (
74+
PARTITION BY table_id ORDER BY order_key COLLATE "C", id
75+
) AS next_key
76+
FROM user_table_rows
77+
WHERE order_key IS NOT NULL
78+
) t
79+
WHERE next_key IS NOT NULL AND order_key COLLATE "C" >= next_key COLLATE "C"
80+
`)
81+
82+
console.log(
83+
`Repair starting — ${pending.length} table(s) with mis-ordered keys${dryRun ? ' [DRY RUN]' : ''}`
84+
)
85+
86+
for (const { table_id: tableId } of pending) {
87+
stats.tables += 1
88+
try {
89+
const keyed = await db.transaction(async (trx) => {
90+
// Serialize with concurrent inserts on this table (same lock the app uses).
91+
await trx.execute(
92+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))`
93+
)
94+
const rows = await trx
95+
.select({ id: userTableRows.id })
96+
.from(userTableRows)
97+
.where(eq(userTableRows.tableId, tableId))
98+
.orderBy(asc(userTableRows.position), asc(userTableRows.id))
99+
100+
if (rows.length === 0) return 0
101+
const keys = nKeysBetween(null, null, rows.length)
102+
if (dryRun) return rows.length
103+
104+
// Chunked UPDATE … FROM (VALUES …) mapping id → key (see WRITE_CHUNK_SIZE).
105+
for (let start = 0; start < rows.length; start += WRITE_CHUNK_SIZE) {
106+
const chunk = rows.slice(start, start + WRITE_CHUNK_SIZE)
107+
const values = sql.join(
108+
chunk.map((r, i) => sql`(${r.id}, ${keys[start + i]})`),
109+
sql`, `
110+
)
111+
await trx.execute(sql`
112+
UPDATE user_table_rows AS t
113+
SET order_key = v.order_key
114+
FROM (VALUES ${values}) AS v(id, order_key)
115+
WHERE t.id = v.id AND t.table_id = ${tableId}
116+
`)
117+
}
118+
return rows.length
119+
})
120+
stats.tablesKeyed += 1
121+
stats.rowsKeyed += keyed
122+
console.log(` ${tableId}: re-keyed ${keyed} rows`)
123+
} catch (error) {
124+
stats.failed += 1
125+
console.error(` ${tableId}: FAILED — ${getErrorMessage(error)}`)
126+
}
127+
}
128+
129+
console.log('Repair complete.')
130+
console.log(` tables scanned: ${stats.tables}`)
131+
console.log(` tables re-keyed: ${stats.tablesKeyed}`)
132+
console.log(` rows re-keyed: ${stats.rowsKeyed}`)
133+
console.log(` failed: ${stats.failed}`)
134+
if (stats.failed > 0) process.exitCode = 1
135+
} finally {
136+
await client.end({ timeout: 5 }).catch(() => {})
137+
}
138+
}
139+
140+
if ((import.meta as { main?: boolean }).main) {
141+
try {
142+
await runRepair()
143+
} catch (error) {
144+
console.error('Repair aborted:', getErrorMessage(error))
145+
process.exitCode = 1
146+
}
147+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Compare `user_table_rows.order_key` bytewise (COLLATE "C") instead of under the
2+
-- database's default locale (en_US.UTF-8).
3+
--
4+
-- order_key holds fractional-index strings from the base-62 alphabet
5+
-- 0-9 A-Z a-z, generated by apps/sim/lib/fractional-indexing. That algorithm
6+
-- compares keys BYTEWISE (JS string `>=`) and its integer-length scheme relies on
7+
-- the ASCII boundary `Z` (0x5A) < `a` (0x61). Under en_US.UTF-8 lowercase
8+
-- interleaves with/precedes uppercase ("a0" < "Zz"), the opposite of bytewise — so
9+
-- `max(order_key)`, neighbor lookups, and `ORDER BY order_key` disagreed with the
10+
-- library, making inserts mint keys that fail `generateKeyBetween`'s `a >= b`
11+
-- assertion and making rows display out of order.
12+
--
13+
-- Setting the column to COLLATE "C" makes every comparison, aggregate, and the
14+
-- dependent index `user_table_rows_table_order_key_idx` use byte order, matching
15+
-- the library. Postgres rebuilds that index under the new collation as part of the
16+
-- ALTER. Takes a brief ACCESS EXCLUSIVE lock; user_table rows are low-volume per
17+
-- the fractional-ordering rollout.
18+
ALTER TABLE "user_table_rows" ALTER COLUMN "order_key" SET DATA TYPE text COLLATE "C";

0 commit comments

Comments
 (0)