diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index c78a078..403bb71 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -440,7 +440,7 @@ export async function ensureQuoteLifecycleRetentionSchema(pool) { CREATE INDEX IF NOT EXISTS ${QUOTE_LIFECYCLE_STATISTIC_SUBJECTS_TABLE}_bucket_idx ON ${QUOTE_LIFECYCLE_STATISTIC_SUBJECTS_TABLE} (bucket, latest_stage_at DESC NULLS LAST) `); - await pool.query(` + await ensureSchemaIndex(pool, ` CREATE INDEX IF NOT EXISTS ${QUOTE_LIFECYCLE_STATISTIC_SUBJECTS_TABLE}_updated_idx ON ${QUOTE_LIFECYCLE_STATISTIC_SUBJECTS_TABLE} (updated_at DESC) `); @@ -6418,8 +6418,25 @@ function withDepositCreatedAt(payload, effectiveAt) { } async function ensureExpressionIndex(pool, { name, table, expression }) { - await pool.query(` + await ensureSchemaIndex(pool, ` CREATE INDEX IF NOT EXISTS ${name} ON ${table} (${expression}) `); } + +export async function ensureSchemaIndex(pool, sql) { + try { + await pool.query(sql); + } catch (error) { + if (isConcurrentSchemaIndexExistsError(error)) return; + throw error; + } +} + +function isConcurrentSchemaIndexExistsError(error) { + return error?.code === '42P07' + || ( + error?.code === '23505' + && error?.constraint === 'pg_class_relname_nsp_index' + ); +} diff --git a/test/postgres-quote-lifecycle-statistics.test.mjs b/test/postgres-quote-lifecycle-statistics.test.mjs index ff0e799..e5d46f4 100644 --- a/test/postgres-quote-lifecycle-statistics.test.mjs +++ b/test/postgres-quote-lifecycle-statistics.test.mjs @@ -5,6 +5,7 @@ import { buildQuoteLifecycleStatistics, } from '../src/core/quote-lifecycle-statistics.mjs'; import { + ensureSchemaIndex, loadQuoteLifecycleStatistics, refreshQuoteLifecycleStatistics, } from '../src/lib/postgres.mjs'; @@ -192,3 +193,35 @@ test('postgres quote lifecycle statistics loader returns all-time and clamped gr assert.match(issuedSql, /PARTITION BY grain, window_start, window_end/); assert.match(issuedSql, /duplicate_rank = 1/); }); + +test('postgres schema index creation tolerates concurrent already-created index race', async () => { + let queryCount = 0; + const pool = { + async query() { + queryCount += 1; + const error = new Error('duplicate key value violates unique constraint "pg_class_relname_nsp_index"'); + error.code = '23505'; + error.constraint = 'pg_class_relname_nsp_index'; + throw error; + }, + }; + + await ensureSchemaIndex(pool, 'CREATE INDEX IF NOT EXISTS quote_lifecycle_stat_subjects_updated_idx ON quote_lifecycle_stat_subjects (updated_at DESC)'); + assert.equal(queryCount, 1); +}); + +test('postgres schema index creation still rejects unrelated duplicate errors', async () => { + const pool = { + async query() { + const error = new Error('duplicate key value violates unrelated constraint'); + error.code = '23505'; + error.constraint = 'other_unique_constraint'; + throw error; + }, + }; + + await assert.rejects( + () => ensureSchemaIndex(pool, 'CREATE INDEX IF NOT EXISTS unrelated_idx ON some_table (id)'), + /unrelated constraint/, + ); +});