Skip to content

Commit bb5b414

Browse files
committed
fix(db): avoid repeated dev index builds and search backfills
1 parent 03377cd commit bb5b414

5 files changed

Lines changed: 179 additions & 42 deletions

File tree

‎.github/workflows/migrations.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ jobs:
7878
# terminal" but still exits 0, so the job goes green without applying the
7979
# change. tee keeps the output live in the log; we then fail on drizzle's
8080
# own TTY error. pipefail also preserves a non-zero db:push exit through tee.
81-
bun run db:push --force < /dev/null 2>&1 | tee /tmp/db-push.log
81+
SIM_DEV_DB_PUSH=1 bun run db:push --force < /dev/null 2>&1 | tee /tmp/db-push.log
8282
if grep -q "Interactive prompts require a TTY terminal" /tmp/db-push.log; then
8383
echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2
8484
exit 1

‎packages/db/dev-push.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as schema from '@sim/db/schema'
2+
import { generateDrizzleJson, generateMigration } from 'drizzle-kit/api'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
5+
interface SnapshotTable {
6+
indexes: Record<string, { method: string; with?: Record<string, unknown> }>
7+
}
8+
9+
afterEach(() => vi.unstubAllEnvs())
10+
11+
describe('dev push index options', () => {
12+
it('matches introspected options without changing ordinary migration snapshots', async () => {
13+
vi.stubEnv('SIM_DEV_DB_PUSH', undefined)
14+
const ordinary = generateDrizzleJson(schema)
15+
const introspected = structuredClone(ordinary)
16+
const hnswIndexes = Object.values<SnapshotTable>(introspected.tables).flatMap((table) =>
17+
Object.values(table.indexes).filter((index) => index.method === 'hnsw')
18+
)
19+
expect(hnswIndexes).toHaveLength(22)
20+
for (const index of hnswIndexes) {
21+
expect(index.with).toEqual({ m: 16, ef_construction: 64 })
22+
index.with = { m: '16', ef_construction: '64' }
23+
}
24+
25+
vi.stubEnv('SIM_DEV_DB_PUSH', '1')
26+
const dev = generateDrizzleJson(schema)
27+
expect(await generateMigration(introspected, dev)).toEqual([])
28+
expect({ ...dev, id: ordinary.id }).toEqual(introspected)
29+
30+
vi.stubEnv('SIM_DEV_DB_PUSH', undefined)
31+
const restored = generateDrizzleJson(schema)
32+
expect({ ...restored, id: ordinary.id }).toEqual(ordinary)
33+
})
34+
35+
it('still detects a real index-option change in dev', async () => {
36+
vi.stubEnv('SIM_DEV_DB_PUSH', '1')
37+
const current = generateDrizzleJson(schema)
38+
const previous = structuredClone(current)
39+
previous.tables['public.embedding'].indexes.embedding_vector_hnsw_idx.with = {
40+
m: '8',
41+
ef_construction: '64',
42+
}
43+
const statements = await generateMigration(previous, current)
44+
expect(statements).toHaveLength(2)
45+
expect(statements[0]).toContain('DROP INDEX "embedding_vector_hnsw_idx"')
46+
expect(statements[1]).toContain('WITH (m=16,ef_construction=64)')
47+
})
48+
})

‎packages/db/schema.ts‎

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ import {
2828
} from 'drizzle-orm/pg-core'
2929
import { DEFAULT_FREE_CREDITS, TAG_SLOTS } from './constants'
3030

31+
/**
32+
* Drizzle push compares index options as JSON, while Postgres introspection
33+
* returns strings. Normalize only dev pushes so unchanged HNSW indexes survive
34+
* the diff; versioned migration snapshots retain their original numeric values.
35+
*/
36+
function hnswIndexOptions() {
37+
return process.env.SIM_DEV_DB_PUSH === '1'
38+
? { m: '16', ef_construction: '64' }
39+
: { m: 16, ef_construction: 64 }
40+
}
41+
3142
// Custom tsvector type for full-text search
3243
export const tsvector = customType<{
3344
data: string
@@ -3236,28 +3247,16 @@ export const embedding = pgTable(
32363247
// Vector similarity search indexes (HNSW), one per stored width
32373248
embeddingVectorHnswIdx: index('embedding_vector_hnsw_idx')
32383249
.using('hnsw', table.embedding.op('vector_cosine_ops'))
3239-
.with({
3240-
m: 16,
3241-
ef_construction: 64,
3242-
}),
3250+
.with(hnswIndexOptions()),
32433251
embedding384VectorHnswIdx: index('embedding_384_vector_hnsw_idx')
32443252
.using('hnsw', table.embedding384.op('vector_cosine_ops'))
3245-
.with({
3246-
m: 16,
3247-
ef_construction: 64,
3248-
}),
3253+
.with(hnswIndexOptions()),
32493254
embedding768VectorHnswIdx: index('embedding_768_vector_hnsw_idx')
32503255
.using('hnsw', table.embedding768.op('vector_cosine_ops'))
3251-
.with({
3252-
m: 16,
3253-
ef_construction: 64,
3254-
}),
3256+
.with(hnswIndexOptions()),
32553257
embedding1024VectorHnswIdx: index('embedding_1024_vector_hnsw_idx')
32563258
.using('hnsw', table.embedding1024.op('vector_cosine_ops'))
3257-
.with({
3258-
m: 16,
3259-
ef_construction: 64,
3260-
}),
3259+
.with(hnswIndexOptions()),
32613260
/**
32623261
* pgvector indexes `vector` only up to 2,000 dimensions and `halfvec` up to
32633262
* 4,000, so the 3,072 column is indexed through a `halfvec` cast — the
@@ -3278,27 +3277,24 @@ export const embedding = pgTable(
32783277
*/
32793278
embedding3072VectorHnswIdx: index('embedding_3072_vector_hnsw_idx')
32803279
.using('hnsw', sql`(${table.embedding3072}::halfvec(3072)) halfvec_cosine_ops`)
3281-
.with({
3282-
m: 16,
3283-
ef_construction: 64,
3284-
}),
3280+
.with(hnswIndexOptions()),
32853281

32863282
/** contract-pending(after stored candidate retrieval is fully deployed): remove these expression indexes; old app versions still query them during rollout. */
32873283
embeddingBinaryHnswIdx: index('embedding_binary_hnsw_idx')
32883284
.using('hnsw', sql`(binary_quantize(${table.embedding})::bit(1536)) bit_hamming_ops`)
3289-
.with({ m: 16, ef_construction: 64 }),
3285+
.with(hnswIndexOptions()),
32903286
embedding384BinaryHnswIdx: index('embedding_384_binary_hnsw_idx')
32913287
.using('hnsw', sql`(binary_quantize(${table.embedding384})::bit(384)) bit_hamming_ops`)
3292-
.with({ m: 16, ef_construction: 64 }),
3288+
.with(hnswIndexOptions()),
32933289
embedding768BinaryHnswIdx: index('embedding_768_binary_hnsw_idx')
32943290
.using('hnsw', sql`(binary_quantize(${table.embedding768})::bit(768)) bit_hamming_ops`)
3295-
.with({ m: 16, ef_construction: 64 }),
3291+
.with(hnswIndexOptions()),
32963292
embedding1024BinaryHnswIdx: index('embedding_1024_binary_hnsw_idx')
32973293
.using('hnsw', sql`(binary_quantize(${table.embedding1024})::bit(1024)) bit_hamming_ops`)
3298-
.with({ m: 16, ef_construction: 64 }),
3294+
.with(hnswIndexOptions()),
32993295
embedding3072BinaryHnswIdx: index('embedding_3072_binary_hnsw_idx')
33003296
.using('hnsw', sql`(binary_quantize(${table.embedding3072})::bit(3072)) bit_hamming_ops`)
3301-
.with({ m: 16, ef_construction: 64 }),
3297+
.with(hnswIndexOptions()),
33023298

33033299
// Text tag indexes
33043300
tag1Idx: index('emb_kb_tag1_lower_idx').on(table.knowledgeBaseId, sql`lower(${table.tag1})`),
@@ -3387,37 +3383,37 @@ export const embeddingSearch = pgTable(
33873383
knowledgeBaseIdx: index('embedding_search_kb_idx').on(table.knowledgeBaseId),
33883384
binaryIdx: index('embedding_search_binary_hnsw_idx')
33893385
.using('hnsw', table.binary.op('bit_hamming_ops'))
3390-
.with({ m: 16, ef_construction: 64 }),
3386+
.with(hnswIndexOptions()),
33913387
binary384Idx: index('embedding_search_384_binary_hnsw_idx')
33923388
.using('hnsw', table.binary384.op('bit_hamming_ops'))
3393-
.with({ m: 16, ef_construction: 64 }),
3389+
.with(hnswIndexOptions()),
33943390
binary768Idx: index('embedding_search_768_binary_hnsw_idx')
33953391
.using('hnsw', table.binary768.op('bit_hamming_ops'))
3396-
.with({ m: 16, ef_construction: 64 }),
3392+
.with(hnswIndexOptions()),
33973393
binary1024Idx: index('embedding_search_1024_binary_hnsw_idx')
33983394
.using('hnsw', table.binary1024.op('bit_hamming_ops'))
3399-
.with({ m: 16, ef_construction: 64 }),
3395+
.with(hnswIndexOptions()),
34003396
binary3072Idx: index('embedding_search_3072_binary_hnsw_idx')
34013397
.using('hnsw', table.binary3072.op('bit_hamming_ops'))
3402-
.with({ m: 16, ef_construction: 64 }),
3398+
.with(hnswIndexOptions()),
34033399
vectorIdx: index('embedding_search_cosine_hnsw_idx')
34043400
.using('hnsw', table.vector.op('halfvec_cosine_ops'))
3405-
.with({ m: 16, ef_construction: 64 }),
3401+
.with(hnswIndexOptions()),
34063402
vector512Idx: index('embedding_search_512_cosine_hnsw_idx')
34073403
.using('hnsw', table.vector512.op('halfvec_cosine_ops'))
3408-
.with({ m: 16, ef_construction: 64 }),
3404+
.with(hnswIndexOptions()),
34093405
vector384Idx: index('embedding_search_384_cosine_hnsw_idx')
34103406
.using('hnsw', table.vector384.op('halfvec_cosine_ops'))
3411-
.with({ m: 16, ef_construction: 64 }),
3407+
.with(hnswIndexOptions()),
34123408
vector768Idx: index('embedding_search_768_cosine_hnsw_idx')
34133409
.using('hnsw', table.vector768.op('halfvec_cosine_ops'))
3414-
.with({ m: 16, ef_construction: 64 }),
3410+
.with(hnswIndexOptions()),
34153411
vector1024Idx: index('embedding_search_1024_cosine_hnsw_idx')
34163412
.using('hnsw', table.vector1024.op('halfvec_cosine_ops'))
3417-
.with({ m: 16, ef_construction: 64 }),
3413+
.with(hnswIndexOptions()),
34183414
vector3072Idx: index('embedding_search_3072_cosine_hnsw_idx')
34193415
.using('hnsw', table.vector3072.op('halfvec_cosine_ops'))
3420-
.with({ m: 16, ef_construction: 64 }),
3416+
.with(hnswIndexOptions()),
34213417
widthCheck: check(
34223418
'embedding_search_width_check',
34233419
sql`num_nonnulls("binary", "binary_384", "binary_768", "binary_1024", "binary_3072") = 1`
@@ -3494,10 +3490,7 @@ export const docsEmbeddings = pgTable(
34943490
// Vector similarity search indexes (HNSW) - optimized for documentation embeddings
34953491
embeddingVectorHnswIdx: index('docs_embedding_vector_hnsw_idx')
34963492
.using('hnsw', table.embedding.op('vector_cosine_ops'))
3497-
.with({
3498-
m: 16,
3499-
ef_construction: 64,
3500-
}),
3493+
.with(hnswIndexOptions()),
35013494

35023495
// GIN index for JSONB metadata queries
35033496
metadataGinIdx: index('docs_emb_metadata_gin_idx').using('gin', table.metadata),
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
backfillSearchVectorsMigration,
3+
runDevSearchBackfill,
4+
} from '@sim/db/script-migrations/0016_backfill_search_vectors'
5+
import type { Sql } from 'postgres'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
7+
8+
/** Models the ledger while keeping the expensive projection work mocked. */
9+
function createLedger(appliedNames: string[] = []) {
10+
const names = new Set(appliedNames)
11+
const query = vi.fn(async (parts: TemplateStringsArray, name?: string) => {
12+
const statement = parts.join('?').trim()
13+
if (statement.startsWith('SELECT name')) {
14+
return name && names.has(name) ? [{ name }] : []
15+
}
16+
if (statement.startsWith('INSERT INTO script_migrations') && name) names.add(name)
17+
return []
18+
})
19+
const begin = vi.fn(async (callback: (tx: typeof query) => Promise<void>) => callback(query))
20+
return { names, begin, sql: Object.assign(query, { begin }) as unknown as Sql }
21+
}
22+
23+
afterEach(() => vi.restoreAllMocks())
24+
25+
describe('dev search backfill tracking', () => {
26+
it('records success and skips projection work on the next deploy', async () => {
27+
const up = vi.spyOn(backfillSearchVectorsMigration, 'up').mockResolvedValue(undefined)
28+
const { sql, names } = createLedger()
29+
await runDevSearchBackfill(sql)
30+
expect(names).toEqual(
31+
new Set(['0015_backfill_embedding_search', '0016_backfill_search_vectors'])
32+
)
33+
await runDevSearchBackfill(sql)
34+
expect(up).toHaveBeenCalledTimes(1)
35+
})
36+
37+
it('retries failed work without recording completion', async () => {
38+
const up = vi
39+
.spyOn(backfillSearchVectorsMigration, 'up')
40+
.mockRejectedValueOnce(new Error('backfill interrupted'))
41+
.mockResolvedValue(undefined)
42+
const { sql, names, begin } = createLedger()
43+
await expect(runDevSearchBackfill(sql)).rejects.toThrow('backfill interrupted')
44+
expect(names.size).toBe(0)
45+
expect(begin).not.toHaveBeenCalled()
46+
await runDevSearchBackfill(sql)
47+
expect(names.has(backfillSearchVectorsMigration.name)).toBe(true)
48+
expect(up).toHaveBeenCalledTimes(2)
49+
})
50+
51+
it('runs the vector upgrade even when the older binary backfill is recorded', async () => {
52+
const up = vi.spyOn(backfillSearchVectorsMigration, 'up').mockResolvedValue(undefined)
53+
const { sql } = createLedger(['0015_backfill_embedding_search'])
54+
await runDevSearchBackfill(sql)
55+
expect(up).toHaveBeenCalledOnce()
56+
})
57+
58+
it('skips a backfill already recorded by the tracked migration runner', async () => {
59+
const up = vi.spyOn(backfillSearchVectorsMigration, 'up').mockResolvedValue(undefined)
60+
const { sql, begin } = createLedger([backfillSearchVectorsMigration.name])
61+
await runDevSearchBackfill(sql)
62+
expect(up).not.toHaveBeenCalled()
63+
expect(begin).not.toHaveBeenCalled()
64+
})
65+
})

‎packages/db/script-migrations/0016_backfill_search_vectors.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,43 @@ export const backfillSearchVectorsMigration: ScriptMigration = {
241241
},
242242
}
243243

244+
/** Records the dev push backfill only after success, so later deploys skip both full scans. */
245+
export async function runDevSearchBackfill(sql: Sql): Promise<void> {
246+
await sql`
247+
CREATE TABLE IF NOT EXISTS script_migrations (
248+
name text PRIMARY KEY,
249+
applied_at timestamptz NOT NULL DEFAULT now()
250+
)
251+
`
252+
const [applied] = await sql`
253+
SELECT name FROM script_migrations WHERE name = ${backfillSearchVectorsMigration.name}
254+
`
255+
if (applied) {
256+
logger.info('Dev search backfill already applied; skipping')
257+
return
258+
}
259+
260+
await backfillSearchVectorsMigration.up(sql)
261+
await sql.begin(async (tx) => {
262+
for (const name of [
263+
backfillSearchVectorsMigration.name,
264+
...(backfillSearchVectorsMigration.supersedes ?? []),
265+
]) {
266+
await tx`INSERT INTO script_migrations (name) VALUES (${name}) ON CONFLICT (name) DO NOTHING`
267+
}
268+
})
269+
}
270+
244271
if (import.meta.main) {
245272
const url = resolveMigrationDatabaseUrl()
246273
if (!url) throw new Error('DATABASE_URL is required to initialize search vectors')
247274
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => undefined })
248275
try {
249-
await backfillSearchVectorsMigration.up(sql)
276+
if (process.env.SIM_DEV_DB_PUSH === '1') {
277+
await runDevSearchBackfill(sql)
278+
} else {
279+
await backfillSearchVectorsMigration.up(sql)
280+
}
250281
} finally {
251282
await sql.end()
252283
}

0 commit comments

Comments
 (0)