Skip to content

Commit a06000a

Browse files
committed
Recover invalid concurrent indexes on migration retry
An interrupted CREATE INDEX CONCURRENTLY leaves a same-name INVALID index behind; IF NOT EXISTS then sees the relation and skips creation, so a retried migration completes with an index that serves no queries. For uq_experiments_shadow_of_live that silently breaks the shadow creator's INSERT .. ON CONFLICT arbiter inference, which only considers valid indexes. Before each concurrent CREATE, drop a same-name index whose pg_index.indisvalid is false so the CREATE rebuilds it. Drilled against Postgres 16: corrupting both indexes and rerunning the unfixed migrations left indisvalid=f and the ON CONFLICT insert failing with "no unique or exclusion constraint matching"; with this change the rerun rebuilds both (indisvalid=t) and the conflict path dedupes again. Claude-Session: https://claude.ai/code/session_01ACF6SUXdbLFarpj3qwF1Ki
1 parent a2912d6 commit a06000a

2 files changed

Lines changed: 55 additions & 0 deletions

File tree

oddish/alembic/versions/shadowexp01_add_experiment_shadow_of.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,34 @@
2929
depends_on: Union[str, Sequence[str], None] = None
3030

3131

32+
def _recover_invalid_index(index_name: str) -> None:
33+
"""An interrupted ``CREATE INDEX CONCURRENTLY`` leaves a same-name INVALID
34+
index behind (``pg_index.indisvalid = false``). ``IF NOT EXISTS`` sees that
35+
relation and skips the CREATE, so a retried migration would complete with
36+
an index that enforces nothing -- for this unique index that silently
37+
breaks the shadow creator's ``INSERT .. ON CONFLICT`` arbiter inference,
38+
which only considers valid indexes. Drop the invalid leftover
39+
(concurrently, we are in the autocommit block) so the CREATE below
40+
rebuilds it."""
41+
result = op.get_bind().execute(
42+
sa.text(
43+
"""
44+
SELECT 1
45+
FROM pg_index i
46+
JOIN pg_class c ON c.oid = i.indexrelid
47+
JOIN pg_namespace n ON n.oid = c.relnamespace
48+
WHERE c.relname = :index_name
49+
AND n.nspname = current_schema()
50+
AND NOT i.indisvalid
51+
"""
52+
),
53+
{"index_name": index_name},
54+
)
55+
invalid = result.first()
56+
if invalid is not None:
57+
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {index_name}")
58+
59+
3260
def upgrade() -> None:
3361
# Bound the ALTER's brief ACCESS EXCLUSIVE lock: without a timeout it
3462
# queues behind any long-running query, and all traffic queues behind it.
@@ -41,6 +69,7 @@ def upgrade() -> None:
4169
# Index name matches the model's ``__table_args__`` declaration so the
4270
# create_all() index and this one are the same object.
4371
with op.get_context().autocommit_block():
72+
_recover_invalid_index("uq_experiments_shadow_of_live")
4473
op.execute(
4574
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "
4675
"uq_experiments_shadow_of_live ON experiments (shadow_of) "

oddish/alembic/versions/trialkind01_add_trial_kind.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,31 @@
3030
depends_on: Union[str, Sequence[str], None] = None
3131

3232

33+
def _recover_invalid_index(index_name: str) -> None:
34+
"""An interrupted ``CREATE INDEX CONCURRENTLY`` leaves a same-name INVALID
35+
index behind (``pg_index.indisvalid = false``). ``IF NOT EXISTS`` sees that
36+
relation and skips the CREATE, so a retried migration would complete with
37+
an index that serves no queries. Drop the invalid leftover (concurrently,
38+
we are in the autocommit block) so the CREATE below rebuilds it."""
39+
result = op.get_bind().execute(
40+
sa.text(
41+
"""
42+
SELECT 1
43+
FROM pg_index i
44+
JOIN pg_class c ON c.oid = i.indexrelid
45+
JOIN pg_namespace n ON n.oid = c.relnamespace
46+
WHERE c.relname = :index_name
47+
AND n.nspname = current_schema()
48+
AND NOT i.indisvalid
49+
"""
50+
),
51+
{"index_name": index_name},
52+
)
53+
invalid = result.first()
54+
if invalid is not None:
55+
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {index_name}")
56+
57+
3358
def upgrade() -> None:
3459
# Bound the ALTER's brief ACCESS EXCLUSIVE lock: without a timeout it
3560
# queues behind any long-running query, and all traffic queues behind it.
@@ -47,6 +72,7 @@ def upgrade() -> None:
4772
# Index name matches the model's ``__table_args__`` declaration so the
4873
# create_all() index and this one are the same object.
4974
with op.get_context().autocommit_block():
75+
_recover_invalid_index("ix_trials_kind_non_agent")
5076
op.execute(
5177
"CREATE INDEX CONCURRENTLY IF NOT EXISTS "
5278
"ix_trials_kind_non_agent ON trials (kind) WHERE kind != 'agent'"

0 commit comments

Comments
 (0)