Skip to content

Commit 751887f

Browse files
authored
fix(knowledge): retry the Tin projection after a database refuses the extension (#8039)
* fix(knowledge): retry the Tin projection after a database refuses the extension A script migration can now defer: `up` throws `ScriptMigrationDeferred`, the runner logs it, leaves the name unrecorded and runs the remaining migrations. The Tin projection defers when the database offers `tin` but refuses to create it, so the upgrade after the extension is permitted installs it instead of skipping it forever. * fix(db): keep a deferred Tin projection from failing a direct run `db:push` runs the migration file directly, where a deferral has no migration record to leave unwritten, so a refused extension aborted the push. The direct entry point now adopts the projection where the database allows it and logs the refusal otherwise, while the registered runner still sees the deferral. Also covers continuation after a deferral with a synthetic migration list, which the registry cannot express while the only deferring migration is its last entry.
1 parent 7689dcc commit 751887f

6 files changed

Lines changed: 180 additions & 12 deletions

File tree

‎apps/sim/lib/knowledge/search/tin-keyword.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ const indexReadiness = new LRUCache<'index', boolean, SearchBudget | undefined>(
3333
},
3434
})
3535

36-
/** Only organization search indexes are projected; `is_search_index` is fixed at creation. */
36+
/**
37+
* Only organization search indexes are projected. `is_search_index` is only ever turned on, when a
38+
* legacy base is adopted, so a stale answer just keeps that base on the GIN projection for one TTL.
39+
*/
3740
const searchIndexBases = new LRUCache<string, boolean>({
3841
max: 10_000,
3942
ttl: SEARCH_INDEX_TTL_MS,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { Sql } from 'postgres'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import {
7+
adoptTinKeywordProjection,
8+
installTinKeywordProjection,
9+
} from './0019_tin_keyword_projection'
10+
import { ScriptMigrationDeferred } from './types'
11+
12+
/** A session that offers `tin` or not, and answers `CREATE EXTENSION` with `createError`. */
13+
function createSqlHarness(options: { available: boolean; createError?: { code: string } }) {
14+
const statements: string[] = []
15+
const run = (strings: TemplateStringsArray) => {
16+
const text = strings.join('?').replace(/\s+/g, ' ').trim()
17+
statements.push(text)
18+
if (text.includes('pg_available_extensions')) {
19+
return Promise.resolve(options.available ? [{ '?column?': 1 }] : [])
20+
}
21+
return Promise.resolve([])
22+
}
23+
const sql = run as unknown as Sql
24+
sql.unsafe = vi.fn(async (text: string) => {
25+
statements.push(text)
26+
if (text.startsWith('CREATE EXTENSION') && options.createError) throw options.createError
27+
return []
28+
}) as unknown as Sql['unsafe']
29+
return { sql, statements }
30+
}
31+
32+
describe('installTinKeywordProjection', () => {
33+
it('records a no-op where the database does not offer tin', async () => {
34+
const { sql, statements } = createSqlHarness({ available: false })
35+
expect(await installTinKeywordProjection(sql)).toBeUndefined()
36+
expect(statements.some((text) => text.startsWith('CREATE EXTENSION'))).toBe(false)
37+
})
38+
39+
it.each(['42501', '0A000'])(
40+
'defers without installing anything when the database refuses the extension (%s)',
41+
async (code) => {
42+
const { sql, statements } = createSqlHarness({ available: true, createError: { code } })
43+
await expect(installTinKeywordProjection(sql)).rejects.toBeInstanceOf(ScriptMigrationDeferred)
44+
expect(statements.at(-1)).toBe('CREATE EXTENSION IF NOT EXISTS tin')
45+
}
46+
)
47+
48+
it('is a no-op when run directly against a database that refuses the extension', async () => {
49+
const { sql, statements } = createSqlHarness({
50+
available: true,
51+
createError: { code: '42501' },
52+
})
53+
await expect(adoptTinKeywordProjection(sql)).resolves.toBeUndefined()
54+
expect(statements.at(-1)).toBe('CREATE EXTENSION IF NOT EXISTS tin')
55+
})
56+
57+
it('fails a direct run on any other extension error', async () => {
58+
const { sql } = createSqlHarness({ available: true, createError: { code: '53100' } })
59+
await expect(adoptTinKeywordProjection(sql)).rejects.toEqual({ code: '53100' })
60+
})
61+
62+
it('fails the migration on any other extension error', async () => {
63+
const { sql } = createSqlHarness({ available: true, createError: { code: '53100' } })
64+
await expect(installTinKeywordProjection(sql)).rejects.toEqual({ code: '53100' })
65+
})
66+
})

‎packages/db/script-migrations/0019_tin_keyword_projection.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EMBEDDING_KEYWORD_TIN_INDEX } from '@sim/db/schema'
2-
import type { ScriptMigration } from '@sim/db/script-migrations/types'
2+
import { type ScriptMigration, ScriptMigrationDeferred } from '@sim/db/script-migrations/types'
33
import { createLogger } from '@sim/logger'
44
import postgres, { type Sql } from 'postgres'
55

@@ -12,7 +12,8 @@ const EXTENSION_REFUSED_CODES = new Set(['42501', '0A000'])
1212
/**
1313
* Installs the extension, or reports that this database refuses it: listed as available is not
1414
* the same as creatable by the migration role. Tin is an optimization, so a refusal leaves keyword
15-
* search on the GIN projection instead of failing the deploy.
15+
* search on the GIN projection instead of failing the deploy, and defers the migration so the
16+
* upgrade after the extension is allowed installs it.
1617
*/
1718
async function createTinExtension(sql: Sql): Promise<boolean> {
1819
try {
@@ -209,15 +210,18 @@ async function buildProjectionIndex(sql: Sql): Promise<void> {
209210

210211
/**
211212
* Installs and fills the Tin keyword projection where the database offers `tin`, and records a
212-
* no-op elsewhere. A database that gains the extension later runs this file directly (see below)
213-
* to adopt it; the whole migration is idempotent.
213+
* no-op elsewhere. A database that refuses the extension it offers defers instead, so a later
214+
* upgrade retries it. A database that gains the extension after recording the no-op runs this file
215+
* directly (see below) to adopt it; the whole migration is idempotent.
214216
*/
215217
export async function installTinKeywordProjection(sql: Sql): Promise<void> {
216218
if (!(await tinAvailable(sql))) {
217219
logger.info('Tin is unavailable; keyword search keeps the GIN projection')
218220
return
219221
}
220-
if (!(await createTinExtension(sql))) return
222+
if (!(await createTinExtension(sql))) {
223+
throw new ScriptMigrationDeferred('the database refused the tin extension')
224+
}
221225
await installProjection(sql)
222226
const rows = await backfillProjection(sql)
223227
await buildProjectionIndex(sql)
@@ -229,12 +233,28 @@ export const tinKeywordProjectionMigration: ScriptMigration = {
229233
up: installTinKeywordProjection,
230234
}
231235

236+
/**
237+
* Installs the projection where this database allows it, treating a refused extension as a no-op:
238+
* run directly — `db:push`, or adopting Tin after a cluster gains it — there is no migration
239+
* record to leave unwritten, and keyword search keeps the GIN projection either way.
240+
*/
241+
export async function adoptTinKeywordProjection(sql: Sql): Promise<void> {
242+
try {
243+
await installTinKeywordProjection(sql)
244+
} catch (error) {
245+
if (!(error instanceof ScriptMigrationDeferred)) throw error
246+
logger.warn('Tin projection deferred; keyword search keeps the GIN projection', {
247+
reason: error.message,
248+
})
249+
}
250+
}
251+
232252
if (import.meta.main) {
233253
const url = process.env.MIGRATION_DATABASE_URL ?? process.env.DATABASE_URL
234254
if (!url) throw new Error('DATABASE_URL is required to install the Tin keyword projection')
235255
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => undefined })
236256
try {
237-
await installTinKeywordProjection(sql)
257+
await adoptTinKeywordProjection(sql)
238258
} finally {
239259
await sql.end()
240260
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { Sql } from 'postgres'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import { runScriptMigrations, scriptMigrations } from './index'
7+
import { type ScriptMigration, ScriptMigrationDeferred } from './types'
8+
9+
const TIN = '0019_tin_keyword_projection'
10+
11+
/** Every registered migration but Tin's, so Tin is the only pending one. */
12+
const APPLIED_BEFORE_TIN = scriptMigrations
13+
.filter(({ name }) => name !== TIN)
14+
.map(({ name }) => name)
15+
16+
/** A session where `applied` is already recorded and the database refuses `tin`. */
17+
function createSqlHarness(applied: readonly string[]) {
18+
const recorded: string[] = []
19+
const run = (strings: TemplateStringsArray, ...values: unknown[]) => {
20+
const text = strings.join('?').replace(/\s+/g, ' ').trim()
21+
if (text.startsWith('SELECT name FROM script_migrations')) {
22+
return Promise.resolve(applied.map((name) => ({ name })))
23+
}
24+
if (text.includes('pg_available_extensions')) return Promise.resolve([{ '?column?': 1 }])
25+
if (text.startsWith('INSERT INTO script_migrations')) recorded.push(values[0] as string)
26+
return Promise.resolve([])
27+
}
28+
const sql = run as unknown as Sql
29+
sql.unsafe = vi.fn(async (text: string) => {
30+
if (text.startsWith('CREATE EXTENSION')) throw { code: '42501' }
31+
return []
32+
}) as unknown as Sql['unsafe']
33+
sql.begin = vi.fn(async (callback) => (callback as (tx: Sql) => unknown)(sql)) as Sql['begin']
34+
return { sql, recorded }
35+
}
36+
37+
describe('runScriptMigrations', () => {
38+
it('leaves a deferred migration unrecorded without failing the upgrade', async () => {
39+
const { sql, recorded } = createSqlHarness(APPLIED_BEFORE_TIN)
40+
await expect(runScriptMigrations(sql)).resolves.toBeUndefined()
41+
expect(recorded).toEqual([])
42+
})
43+
44+
it('applies and records the migrations that follow a deferred one', async () => {
45+
const deferring: ScriptMigration = {
46+
name: 'test_deferring',
47+
up: async () => {
48+
throw new ScriptMigrationDeferred('the database refused the test migration')
49+
},
50+
}
51+
const following: ScriptMigration = { name: 'test_following', up: async () => {} }
52+
const { sql, recorded } = createSqlHarness([])
53+
await expect(runScriptMigrations(sql, [deferring, following])).resolves.toBeUndefined()
54+
expect(recorded).toEqual(['test_following'])
55+
})
56+
})

‎packages/db/script-migrations/index.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { repairUnknownTableRowProvenanceSecondPass } from './0006_repair_unknown
1515
import { repairUnknownWorkspaceFileProvenance } from './0007_repair_unknown_workspace_file_provenance'
1616
import { backfillCredentialGroupResourcePolicies } from './0010_backfill_credential_group_resource_policies'
1717
import { remapLegacyKnowledgeConnectorCredentialsMigration } from './0011_remap_legacy_knowledge_connector_credentials'
18-
import type { ScriptMigration } from './types'
18+
import { type ScriptMigration, ScriptMigrationDeferred } from './types'
1919

2020
export type { ScriptMigration } from './types'
2121

@@ -54,10 +54,18 @@ export const scriptMigrations: readonly ScriptMigration[] = [
5454
*
5555
* Fails fast: a missing required env var or a throwing `up` aborts the run
5656
* before the name is recorded, so the migration retries on the next upgrade.
57+
* A deferred `up` is not recorded either, but lets the later migrations run.
58+
*
59+
* `migrations` defaults to the registry and exists so a test can apply a
60+
* synthetic list: a deferral followed by a later migration is otherwise
61+
* uncoverable while the only deferring migration is the last registered entry.
5762
*/
58-
export async function runScriptMigrations(sql: Sql): Promise<void> {
63+
export async function runScriptMigrations(
64+
sql: Sql,
65+
migrations: readonly ScriptMigration[] = scriptMigrations
66+
): Promise<void> {
5967
const names = new Set<string>()
60-
for (const migration of scriptMigrations) {
68+
for (const migration of migrations) {
6169
if (names.has(migration.name)) {
6270
throw new Error(`Duplicate script migration name: ${migration.name}`)
6371
}
@@ -85,7 +93,7 @@ export async function runScriptMigrations(sql: Sql): Promise<void> {
8593
const appliedRows = await sql<{ name: string }[]>`SELECT name FROM script_migrations`
8694
const applied = new Set(appliedRows.map((row) => row.name))
8795

88-
const pending = scriptMigrations.filter((migration) => !applied.has(migration.name))
96+
const pending = migrations.filter((migration) => !applied.has(migration.name))
8997
if (pending.length === 0) {
9098
console.log('No pending script migrations.')
9199
return
@@ -101,7 +109,13 @@ export async function runScriptMigrations(sql: Sql): Promise<void> {
101109
}
102110
console.log(`Applying script migration ${migration.name}...`)
103111
const startedAt = Date.now()
104-
await migration.up(sql)
112+
try {
113+
await migration.up(sql)
114+
} catch (error) {
115+
if (!(error instanceof ScriptMigrationDeferred)) throw error
116+
console.log(`Script migration ${migration.name} deferred: ${error.message}`)
117+
continue
118+
}
105119
await sql.begin(async (tx) => {
106120
for (const name of [migration.name, ...(migration.supersedes ?? [])]) {
107121
await tx`INSERT INTO script_migrations (name) VALUES (${name}) ON CONFLICT (name) DO NOTHING`

‎packages/db/script-migrations/types.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,12 @@ export interface ScriptMigration {
3535
*/
3636
up(sql: Sql): Promise<void>
3737
}
38+
39+
/**
40+
* Thrown by `up` to leave the migration unrecorded without failing the upgrade,
41+
* so the next upgrade runs it again: for work this database refuses today but
42+
* may accept later, such as an extension the migration role may not yet create.
43+
*/
44+
export class ScriptMigrationDeferred extends Error {
45+
override name = 'ScriptMigrationDeferred'
46+
}

0 commit comments

Comments
 (0)