Skip to content

Commit 90adc4e

Browse files
fix(db): recover failed credential group index on migration retry (#7595)
* fix(db): recover failed credential group index on migration retry * fix(test): compare connector identifiers exactly
1 parent 9c544a0 commit 90adc4e

3 files changed

Lines changed: 97 additions & 8 deletions

File tree

apps/sim/lib/knowledge/orchestration/connectors.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1396,9 +1396,10 @@ describe('members-mode connectors', () => {
13961396
expect(mockDispatchMemberSync).not.toHaveBeenCalled()
13971397
expect(mockRecordAudit).not.toHaveBeenCalled()
13981398
expect(mockRevoke).toHaveBeenCalledWith(
1399-
expect.objectContaining({ connectorId: expect.not.stringMatching(MEMBERS_CONNECTOR.id) }),
1399+
expect.objectContaining({ connectorId: expect.any(String) }),
14001400
ACTOR.userId
14011401
)
1402+
expect(mockRevoke.mock.calls[0][0].connectorId).not.toBe(MEMBERS_CONNECTOR.id)
14021403
})
14031404

14041405
it('does not reuse matching settings bound to a different account option', async () => {

packages/db/migrations/0326_enterprise_organization_search.sql

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,31 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_organization_id_idx" O
384384
--> statement-breakpoint
385385
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_organization_unique" ON "credential_group" USING btree ("organization_id");
386386
--> statement-breakpoint
387+
-- A failed concurrent build leaves an INVALID index that IF NOT EXISTS skips.
388+
-- Rename only that failed index so it can be dropped concurrently outside this block.
389+
-- The recovery name also survives interruption between the rename and drop.
390+
DO $$
391+
BEGIN
392+
IF EXISTS (
393+
SELECT 1 FROM pg_index
394+
WHERE indexrelid = to_regclass('"public"."credential_group_workspace_unique_failed_0326"')
395+
AND (indisvalid OR indrelid <> '"public"."credential_group"'::regclass)
396+
) THEN
397+
RAISE EXCEPTION 'Refusing to drop unexpected index credential_group_workspace_unique_failed_0326';
398+
END IF;
399+
IF EXISTS (
400+
SELECT 1 FROM pg_index
401+
WHERE indexrelid = to_regclass('"public"."credential_group_workspace_unique"')
402+
AND indrelid = '"public"."credential_group"'::regclass
403+
AND NOT indisvalid
404+
) THEN
405+
ALTER INDEX "public"."credential_group_workspace_unique" RENAME TO "credential_group_workspace_unique_failed_0326";
406+
END IF;
407+
END $$;
408+
--> statement-breakpoint
409+
-- migration-safe: Only the invalid workspace index left by 0326 is renamed above. Valid indexes and legacy uniqueness remain intact; the following statement rebuilds the failed index without removing rows.
410+
DROP INDEX CONCURRENTLY IF EXISTS "public"."credential_group_workspace_unique_failed_0326";
411+
--> statement-breakpoint
387412
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_workspace_unique" ON "credential_group" USING btree ("workspace_id");
388413
--> statement-breakpoint
389414
CREATE INDEX CONCURRENTLY IF NOT EXISTS "doc_connector_source_lookup_idx" ON "document" USING btree ("connector_id","external_id");

packages/db/organization-search-migration.postgres.test.ts

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async function createMigrationFixture() {
147147
}
148148

149149
describe.skipIf(!databaseUrl)('Organization Search PostgreSQL migration replay', () => {
150-
it('preserves old uniqueness and data when a concurrent replacement fails, then replays after repair', async () => {
150+
it('preserves data on duplicate failures and rebuilds the invalid index after duplicates are removed', async () => {
151151
const fixture = await createMigrationFixture()
152152
const { sql, schema } = fixture
153153
try {
@@ -158,20 +158,20 @@ describe.skipIf(!databaseUrl)('Organization Search PostgreSQL migration replay',
158158
await sql`SELECT indisvalid FROM pg_index
159159
WHERE indexrelid = ${`"${schema}"."credential_group_workspace_unique"`}::regclass`
160160
).toEqual([{ indisvalid: false }])
161-
await expect(fixture.migrate()).rejects.toMatchObject({
162-
code: 'P0001',
163-
message: expect.stringContaining('credential_group_workspace_unique'),
164-
hint: expect.stringContaining('Repair the listed indexes'),
165-
})
161+
await expect(fixture.migrate()).rejects.toMatchObject({ code: '23505' })
166162
expect(await sql`SELECT id FROM credential_group`).toHaveLength(2)
167163
await expect(sql`INSERT INTO credential_group (id, workspace_id, name)
168164
VALUES ('third', 'workspace-a', 'First')`).rejects.toMatchObject({
169165
code: '23505',
170166
constraint_name: 'credential_group_workspace_name_unique',
171167
})
172168
await sql`DELETE FROM credential_group WHERE id = 'duplicate'`
173-
await sql.unsafe('DROP INDEX CONCURRENTLY credential_group_workspace_unique')
174169
await fixture.migrate()
170+
expect(
171+
await sql`SELECT indisvalid, indisready FROM pg_index
172+
WHERE indexrelid = ${`"${schema}"."credential_group_workspace_unique"`}::regclass`
173+
).toEqual([{ indisvalid: true, indisready: true }])
174+
expect(await sql`SELECT id FROM credential_group`).toEqual([{ id: 'first' }])
175175
await expect(sql`INSERT INTO credential_group (id, workspace_id, name)
176176
VALUES ('third', 'workspace-a', 'Different')`).rejects.toMatchObject({
177177
code: '23505',
@@ -182,6 +182,69 @@ describe.skipIf(!databaseUrl)('Organization Search PostgreSQL migration replay',
182182
}
183183
})
184184

185+
it('resumes index recovery after interruption between renaming and dropping the failed index', async () => {
186+
const fixture = await createMigrationFixture()
187+
const { sql, schema } = fixture
188+
try {
189+
await sql`INSERT INTO credential_group (id, workspace_id, name)
190+
VALUES ('first', 'workspace-a', 'First'), ('duplicate', 'workspace-a', 'Second')`
191+
await expect(fixture.migrate()).rejects.toMatchObject({ code: '23505' })
192+
await sql`DELETE FROM credential_group WHERE id = 'duplicate'`
193+
const recovery = fixture.statements.find((statement) =>
194+
statement.includes('RENAME TO "credential_group_workspace_unique_failed_0326"')
195+
)
196+
expect(recovery).toBeDefined()
197+
await sql.unsafe(recovery!)
198+
await fixture.migrate()
199+
expect(
200+
await sql`SELECT indisvalid FROM pg_index
201+
WHERE indexrelid = ${`"${schema}"."credential_group_workspace_unique"`}::regclass`
202+
).toEqual([{ indisvalid: true }])
203+
expect(
204+
await sql`SELECT to_regclass(${`"${schema}"."credential_group_workspace_unique_failed_0326"`}) AS recovery`
205+
).toEqual([{ recovery: null }])
206+
} finally {
207+
await fixture.cleanup()
208+
}
209+
})
210+
211+
it('does not drop a healthy index occupying the recovery name', async () => {
212+
const fixture = await createMigrationFixture()
213+
const { sql, schema } = fixture
214+
try {
215+
await sql.unsafe(
216+
'CREATE INDEX credential_group_workspace_unique_failed_0326 ON credential_group (workspace_id)'
217+
)
218+
await expect(fixture.migrate()).rejects.toMatchObject({
219+
code: 'P0001',
220+
message: expect.stringContaining('Refusing to drop unexpected index'),
221+
})
222+
expect(
223+
await sql`SELECT indisvalid FROM pg_index
224+
WHERE indexrelid = ${`"${schema}"."credential_group_workspace_unique_failed_0326"`}::regclass`
225+
).toEqual([{ indisvalid: true }])
226+
} finally {
227+
await fixture.cleanup()
228+
}
229+
})
230+
231+
it('preserves the healthy workspace index when the migration is replayed', async () => {
232+
const fixture = await createMigrationFixture()
233+
const { sql, schema } = fixture
234+
try {
235+
await fixture.migrate()
236+
const before = await sql`SELECT indexrelid::oid AS oid FROM pg_index
237+
WHERE indexrelid = ${`"${schema}"."credential_group_workspace_unique"`}::regclass`
238+
await fixture.migrate()
239+
expect(
240+
await sql`SELECT indexrelid::oid AS oid FROM pg_index
241+
WHERE indexrelid = ${`"${schema}"."credential_group_workspace_unique"`}::regclass`
242+
).toEqual(before)
243+
} finally {
244+
await fixture.cleanup()
245+
}
246+
})
247+
185248
it.each(['complete migration', 'committed pre-index phase'] as const)(
186249
'replays after a %s without losing owner constraints or workspace behavior',
187250
async (interruption) => {

0 commit comments

Comments
 (0)