Skip to content

Commit eac592c

Browse files
committed
Retry lock-timed-out migration statements instead of failing the deploy
The production deploy of trialkind01 lost its single 8s lock window on the busy trials table three runs in a row. Keep the short lock_timeout (an uncapped ALTER queues all traffic behind it) but retry each contended statement in its own autocommit transaction, and log the lock-holding sessions on the first miss so a failed run names its blocker.
1 parent f822f91 commit eac592c

7 files changed

Lines changed: 320 additions & 83 deletions

oddish/alembic/versions/clearstale01_clear_inflight_analysis_flags.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,30 @@
1818

1919
from alembic import op
2020

21+
from oddish.db.migration_locks import run_with_lock_retry
22+
2123
revision: str = "clearstale01"
2224
down_revision: Union[str, Sequence[str], None] = "retirejobs01"
2325
branch_labels: Union[str, Sequence[str], None] = None
2426
depends_on: Union[str, Sequence[str], None] = None
2527

2628

2729
def upgrade() -> None:
28-
op.execute("SET lock_timeout = '8s'")
29-
op.execute(
30-
"""
31-
UPDATE trials
32-
SET analysis_status = NULL,
33-
analysis_error = NULL,
34-
analysis_started_at = NULL,
35-
analysis_finished_at = NULL
36-
WHERE analysis_status::text IN ('PENDING', 'QUEUED', 'RUNNING')
37-
"""
38-
)
30+
# Row-lock waits on the busy ``trials`` table are bounded by a short
31+
# lock_timeout and retried (see migration_locks).
32+
def _clear_inflight_flags() -> None:
33+
op.execute(
34+
"""
35+
UPDATE trials
36+
SET analysis_status = NULL,
37+
analysis_error = NULL,
38+
analysis_started_at = NULL,
39+
analysis_finished_at = NULL
40+
WHERE analysis_status::text IN ('PENDING', 'QUEUED', 'RUNNING')
41+
"""
42+
)
43+
44+
run_with_lock_retry(_clear_inflight_flags, table_name="trials")
3945

4046

4147
def downgrade() -> None:

oddish/alembic/versions/dropblocks01_drop_analyzer_blocks.py

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,56 +22,75 @@
2222
import sqlalchemy as sa
2323
from alembic import op
2424

25+
from oddish.db.migration_locks import run_with_lock_retry
26+
2527
revision: str = "dropblocks01"
2628
down_revision: Union[str, Sequence[str], None] = "analysisspend01"
2729
branch_labels: Union[str, Sequence[str], None] = None
2830
depends_on: Union[str, Sequence[str], None] = None
2931

3032

3133
def upgrade() -> None:
32-
op.execute("SET lock_timeout = '8s'")
34+
# Every statement here contends with live traffic (row locks on
35+
# ``worker_jobs`` and ``trials``, the DROP's ACCESS EXCLUSIVE on
36+
# ``analyzer_blocks``), so each is bounded by a short lock_timeout and
37+
# retried (see migration_locks). All three are idempotent, so a retried
38+
# or re-run statement redoes no work.
39+
#
3340
# Between the cutover deploy (whose retirejobs01 cancelled the retired
3441
# kinds then in flight) and THIS deploy, the public summary route kept
3542
# enqueueing ANALYZER rows on every share-page summary miss. No handler
3643
# claims the kind, but a QUEUED row keeps its queue "active" to the
3744
# dispatcher's discovery query forever, spawning workers that claim
3845
# nothing and exit. Same terminal write retirejobs01 models.
39-
op.execute(
40-
"""
41-
UPDATE worker_jobs
42-
SET status = 'CANCELLED',
43-
finished_at = NOW(),
44-
error_message = 'block pipeline removed: summaries are read from trials.trajectory_summary',
45-
current_worker_id = NULL,
46-
current_queue_slot = NULL,
47-
modal_function_call_id = NULL
48-
WHERE kind::text = 'ANALYZER'
49-
AND status::text IN ('QUEUED', 'RETRYING', 'RUNNING', 'BLOCKED')
50-
"""
51-
)
52-
result = op.get_bind().execute(
53-
sa.text("SELECT to_regclass('analyzer_blocks') IS NOT NULL")
54-
)
55-
if result.scalar():
46+
def _cancel_analyzer_jobs() -> None:
5647
op.execute(
5748
"""
58-
UPDATE trials t
59-
SET trajectory_summary = b.output
60-
FROM (
61-
SELECT DISTINCT ON (analyzer_id) analyzer_id, output
62-
FROM analyzer_blocks
63-
WHERE type = 'trajectory_summary'
64-
AND status::text = 'SUCCESS'
65-
AND output IS NOT NULL
66-
AND output != 'null'::jsonb
67-
AND deleted_at IS NULL
68-
ORDER BY analyzer_id, created_at DESC, id
69-
) b
70-
WHERE t.id = b.analyzer_id
71-
AND t.trajectory_summary IS NULL
49+
UPDATE worker_jobs
50+
SET status = 'CANCELLED',
51+
finished_at = NOW(),
52+
error_message = 'block pipeline removed: summaries are read from trials.trajectory_summary',
53+
current_worker_id = NULL,
54+
current_queue_slot = NULL,
55+
modal_function_call_id = NULL
56+
WHERE kind::text = 'ANALYZER'
57+
AND status::text IN ('QUEUED', 'RETRYING', 'RUNNING', 'BLOCKED')
7258
"""
7359
)
74-
op.execute("DROP TABLE IF EXISTS analyzer_blocks")
60+
61+
run_with_lock_retry(_cancel_analyzer_jobs, table_name="worker_jobs")
62+
63+
result = op.get_bind().execute(
64+
sa.text("SELECT to_regclass('analyzer_blocks') IS NOT NULL")
65+
)
66+
if result.scalar():
67+
68+
def _backfill_summaries() -> None:
69+
op.execute(
70+
"""
71+
UPDATE trials t
72+
SET trajectory_summary = b.output
73+
FROM (
74+
SELECT DISTINCT ON (analyzer_id) analyzer_id, output
75+
FROM analyzer_blocks
76+
WHERE type = 'trajectory_summary'
77+
AND status::text = 'SUCCESS'
78+
AND output IS NOT NULL
79+
AND output != 'null'::jsonb
80+
AND deleted_at IS NULL
81+
ORDER BY analyzer_id, created_at DESC, id
82+
) b
83+
WHERE t.id = b.analyzer_id
84+
AND t.trajectory_summary IS NULL
85+
"""
86+
)
87+
88+
run_with_lock_retry(_backfill_summaries, table_name="trials")
89+
90+
run_with_lock_retry(
91+
lambda: op.execute("DROP TABLE IF EXISTS analyzer_blocks"),
92+
table_name="analyzer_blocks",
93+
)
7594

7695

7796
def downgrade() -> None:

oddish/alembic/versions/retirejobs01_cancel_retired_kind_jobs.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,35 @@
1919

2020
from alembic import op
2121

22+
from oddish.db.migration_locks import run_with_lock_retry
23+
2224
revision: str = "retirejobs01"
2325
down_revision: Union[str, Sequence[str], None] = "shadowexp01"
2426
branch_labels: Union[str, Sequence[str], None] = None
2527
depends_on: Union[str, Sequence[str], None] = None
2628

2729

2830
def upgrade() -> None:
29-
# Bound waiting on row locks a still-draining worker may briefly hold.
30-
op.execute("SET lock_timeout = '8s'")
31-
op.execute(
32-
"""
33-
UPDATE worker_jobs
34-
SET status = 'CANCELLED',
35-
finished_at = NOW(),
36-
error_message = 'pipeline removed: analysis runs as trials now',
37-
current_worker_id = NULL,
38-
current_queue_slot = NULL,
39-
modal_function_call_id = NULL
40-
WHERE kind::text IN
41-
('QA', 'VERDICT', 'ANALYSIS', 'QA_REVIEW',
42-
'ANALYZER', 'ANALYZER_BLOCK')
43-
AND status::text IN ('QUEUED', 'RETRYING', 'RUNNING', 'BLOCKED')
44-
"""
45-
)
31+
# Waiting on row locks a still-draining worker may briefly hold is
32+
# bounded by a short lock_timeout and retried (see migration_locks).
33+
def _cancel_retired_kind_jobs() -> None:
34+
op.execute(
35+
"""
36+
UPDATE worker_jobs
37+
SET status = 'CANCELLED',
38+
finished_at = NOW(),
39+
error_message = 'pipeline removed: analysis runs as trials now',
40+
current_worker_id = NULL,
41+
current_queue_slot = NULL,
42+
modal_function_call_id = NULL
43+
WHERE kind::text IN
44+
('QA', 'VERDICT', 'ANALYSIS', 'QA_REVIEW',
45+
'ANALYZER', 'ANALYZER_BLOCK')
46+
AND status::text IN ('QUEUED', 'RETRYING', 'RUNNING', 'BLOCKED')
47+
"""
48+
)
49+
50+
run_with_lock_retry(_cancel_retired_kind_jobs, table_name="worker_jobs")
4651

4752

4853
def downgrade() -> None:

oddish/alembic/versions/shadowexp01_add_experiment_shadow_of.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import sqlalchemy as sa
2424
from alembic import op
2525

26+
from oddish.db.migration_locks import run_with_lock_retry
27+
2628
revision: str = "shadowexp01"
2729
down_revision: Union[str, Sequence[str], None] = "trialkind01"
2830
branch_labels: Union[str, Sequence[str], None] = None
@@ -58,24 +60,32 @@ def _recover_invalid_index(index_name: str) -> None:
5860

5961

6062
def upgrade() -> None:
61-
# Bound the ALTER's brief ACCESS EXCLUSIVE lock: without a timeout it
62-
# queues behind any long-running query, and all traffic queues behind it.
63-
op.execute("SET lock_timeout = '8s'")
64-
op.add_column(
65-
"experiments",
66-
sa.Column("shadow_of", sa.String(64), nullable=True),
67-
if_not_exists=True,
63+
# The ALTER's ACCESS EXCLUSIVE lock is bounded by a short lock_timeout
64+
# (an uncapped wait queues all traffic on ``experiments`` behind it) and
65+
# retried, since one short window can lose to in-flight queries.
66+
run_with_lock_retry(
67+
lambda: op.add_column(
68+
"experiments",
69+
sa.Column("shadow_of", sa.String(64), nullable=True),
70+
if_not_exists=True,
71+
),
72+
table_name="experiments",
6873
)
74+
6975
# Index name matches the model's ``__table_args__`` declaration so the
70-
# create_all() index and this one are the same object.
71-
with op.get_context().autocommit_block():
76+
# create_all() index and this one are the same object. The invalid-index
77+
# recovery runs inside the retried step: a lock-timed-out CREATE INDEX
78+
# CONCURRENTLY leaves an INVALID index the next attempt must drop first.
79+
def _create_index() -> None:
7280
_recover_invalid_index("uq_experiments_shadow_of_live")
7381
op.execute(
7482
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "
7583
"uq_experiments_shadow_of_live ON experiments (shadow_of) "
7684
"WHERE deleted_at IS NULL"
7785
)
7886

87+
run_with_lock_retry(_create_index, table_name="experiments")
88+
7989

8090
def downgrade() -> None:
8191
with op.get_context().autocommit_block():

oddish/alembic/versions/trialkind01_add_trial_kind.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import sqlalchemy as sa
2525
from alembic import op
2626

27+
from oddish.db.migration_locks import run_with_lock_retry
28+
2729
revision: str = "trialkind01"
2830
down_revision: Union[str, Sequence[str], None] = "dropanalyzers01"
2931
branch_labels: Union[str, Sequence[str], None] = None
@@ -56,28 +58,38 @@ def _recover_invalid_index(index_name: str) -> None:
5658

5759

5860
def upgrade() -> None:
59-
# Bound the ALTER's brief ACCESS EXCLUSIVE lock: without a timeout it
60-
# queues behind any long-running query, and all traffic queues behind it.
61-
op.execute("SET lock_timeout = '8s'")
62-
op.add_column(
63-
"trials",
64-
sa.Column(
65-
"kind",
66-
sa.String(32),
67-
nullable=False,
68-
server_default="agent",
61+
# The ALTER's ACCESS EXCLUSIVE lock is bounded by a short lock_timeout
62+
# (an uncapped wait queues all traffic on ``trials`` behind it) and
63+
# retried: one short window regularly loses to in-flight queries on this
64+
# busy table — the 2026-08-19 production deploy lost its single 8s
65+
# window three runs in a row.
66+
run_with_lock_retry(
67+
lambda: op.add_column(
68+
"trials",
69+
sa.Column(
70+
"kind",
71+
sa.String(32),
72+
nullable=False,
73+
server_default="agent",
74+
),
75+
if_not_exists=True,
6976
),
70-
if_not_exists=True,
77+
table_name="trials",
7178
)
79+
7280
# Index name matches the model's ``__table_args__`` declaration so the
73-
# create_all() index and this one are the same object.
74-
with op.get_context().autocommit_block():
81+
# create_all() index and this one are the same object. The invalid-index
82+
# recovery runs inside the retried step: a lock-timed-out CREATE INDEX
83+
# CONCURRENTLY leaves an INVALID index the next attempt must drop first.
84+
def _create_index() -> None:
7585
_recover_invalid_index("ix_trials_kind_non_agent")
7686
op.execute(
7787
"CREATE INDEX CONCURRENTLY IF NOT EXISTS "
7888
"ix_trials_kind_non_agent ON trials (kind) WHERE kind != 'agent'"
7989
)
8090

91+
run_with_lock_retry(_create_index, table_name="trials")
92+
8193

8294
def downgrade() -> None:
8395
with op.get_context().autocommit_block():

0 commit comments

Comments
 (0)