Skip to content

Commit 24f3129

Browse files
committed
Serialize migrations across pods with an advisory lock
Both bob pods run Bob.ReleaseTasks.migrate() before starting the app, and the Deployment's maxUnavailable: 1 creates both new pods at once, so two pods race to migrate on every deploy. Ecto's own migration lock cannot serialize them when a migration uses CREATE INDEX CONCURRENTLY, which is why those migrations set @disable_migration_lock true. On 2026-08-19 the deploy of 2c287e9 hit this: both pods ran the same CREATE INDEX CONCURRENTLY statements from 20260815120000 and deadlocked three times, each time leaving the index in progress invalid. Because IF NOT EXISTS treats an invalid index as present, every restart skipped the builds and failed the validity check, so the rollout crash looped. Wrap migrate and rollback in a session level advisory lock held on its own Postgrex connection, polled with pg_try_advisory_lock so the waiting pod holds no snapshot that the holder's CREATE INDEX CONCURRENTLY would wait for. The lock is released explicitly when the function returns and by Postgres when the connection ends, including when a pod dies. Have the migration drop invalid leftovers of its own indexes before creating them, so a retry after an interrupted build can succeed instead of failing the validity check forever.
1 parent 2c287e9 commit 24f3129

3 files changed

Lines changed: 247 additions & 6 deletions

File tree

lib/bob/release_tasks.ex

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,85 @@ defmodule Bob.ReleaseTasks do
44
55
Run on deploy with: `bin/bob eval "Bob.ReleaseTasks.migrate()"`.
66
"""
7+
require Logger
8+
79
@app :bob
10+
@migration_lock_key 4_771_003
11+
@migration_lock_poll_interval 1_000
812

913
def migrate() do
1014
load_app()
1115

1216
for repo <- repos() do
13-
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
17+
{:ok, _, _} =
18+
Ecto.Migrator.with_repo(repo, fn repo ->
19+
with_migration_lock(repo, fn -> Ecto.Migrator.run(repo, :up, all: true) end)
20+
end)
1421
end
1522
end
1623

1724
def rollback(repo, version) do
1825
load_app()
19-
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
26+
27+
{:ok, _, _} =
28+
Ecto.Migrator.with_repo(repo, fn repo ->
29+
with_migration_lock(repo, fn -> Ecto.Migrator.run(repo, :down, to: version) end)
30+
end)
31+
end
32+
33+
@doc false
34+
# Every pod runs migrate() at boot, so they race. Ecto's own migration lock
35+
# cannot serialize them here: it holds a transaction for the duration, and
36+
# CREATE INDEX CONCURRENTLY waits for concurrent transactions to finish, so
37+
# the two deadlock. A session level advisory lock on its own connection holds
38+
# no transaction once acquired, and the lock is released when that connection
39+
# ends, including when the pod dies mid-migration.
40+
#
41+
# Waiting for the lock must not hold a transaction either. A blocking
42+
# pg_advisory_lock() call is a running statement with a snapshot for as long
43+
# as it waits, and the holder's CREATE INDEX CONCURRENTLY waits for every
44+
# older snapshot to go away, so the two would deadlock through the app.
45+
# Polling pg_try_advisory_lock() leaves the connection idle between attempts.
46+
def with_migration_lock(repo, fun) do
47+
opts =
48+
Keyword.take(repo.config(), [
49+
:hostname,
50+
:port,
51+
:username,
52+
:password,
53+
:database,
54+
:socket_options,
55+
:ssl
56+
])
57+
58+
{:ok, conn} = Postgrex.start_link(opts)
59+
60+
try do
61+
Logger.info("Waiting for migration lock")
62+
acquire_migration_lock(conn)
63+
Logger.info("Acquired migration lock")
64+
65+
try do
66+
fun.()
67+
after
68+
Postgrex.query!(conn, "SELECT pg_advisory_unlock($1)", [@migration_lock_key])
69+
end
70+
after
71+
GenServer.stop(conn)
72+
end
73+
end
74+
75+
@doc false
76+
def migration_lock_key(), do: @migration_lock_key
77+
78+
defp acquire_migration_lock(conn) do
79+
%{rows: [[locked?]]} =
80+
Postgrex.query!(conn, "SELECT pg_try_advisory_lock($1)", [@migration_lock_key])
81+
82+
unless locked? do
83+
Process.sleep(@migration_lock_poll_interval)
84+
acquire_migration_lock(conn)
85+
end
2086
end
2187

2288
defp repos() do

priv/repo/migrations/20260815120000_index_docker_tags_by_version.exs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ defmodule Bob.Repo.Migrations.IndexDockerTagsByVersion do
44
@disable_ddl_transaction true
55
@disable_migration_lock true
66

7+
@indexes [
8+
"docker_tags_elixir_version_desc_index",
9+
"docker_tags_erlang_version_desc_index",
10+
"docker_tags_os_version_desc_index",
11+
"docker_tags_os_desc_index"
12+
]
13+
714
def up do
815
execute("""
916
CREATE OR REPLACE FUNCTION docker_tag_natural_sort_key(value text)
@@ -39,6 +46,10 @@ defmodule Bob.Repo.Migrations.IndexDockerTagsByVersion do
3946
$$
4047
""")
4148

49+
for index <- invalid_indexes() do
50+
execute("DROP INDEX CONCURRENTLY IF EXISTS #{index}")
51+
end
52+
4253
execute("""
4354
CREATE INDEX CONCURRENTLY IF NOT EXISTS docker_tags_elixir_version_desc_index
4455
ON docker_tags (
@@ -101,10 +112,29 @@ defmodule Bob.Repo.Migrations.IndexDockerTagsByVersion do
101112
end
102113

103114
def down do
104-
execute("DROP INDEX CONCURRENTLY IF EXISTS docker_tags_os_desc_index")
105-
execute("DROP INDEX CONCURRENTLY IF EXISTS docker_tags_os_version_desc_index")
106-
execute("DROP INDEX CONCURRENTLY IF EXISTS docker_tags_erlang_version_desc_index")
107-
execute("DROP INDEX CONCURRENTLY IF EXISTS docker_tags_elixir_version_desc_index")
115+
for index <- Enum.reverse(@indexes) do
116+
execute("DROP INDEX CONCURRENTLY IF EXISTS #{index}")
117+
end
118+
108119
execute("DROP FUNCTION IF EXISTS docker_tag_natural_sort_key(text)")
109120
end
121+
122+
defp invalid_indexes() do
123+
%{rows: rows} =
124+
repo().query!(
125+
"""
126+
SELECT index_class.relname
127+
FROM pg_index AS index_metadata
128+
JOIN pg_class AS index_class ON index_class.oid = index_metadata.indexrelid
129+
JOIN pg_namespace AS namespace ON namespace.oid = index_class.relnamespace
130+
WHERE namespace.nspname = current_schema()
131+
AND index_class.relname = ANY($1)
132+
AND NOT index_metadata.indisvalid
133+
ORDER BY index_class.relname
134+
""",
135+
[@indexes]
136+
)
137+
138+
List.flatten(rows)
139+
end
110140
end

test/bob/release_tasks_test.exs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
defmodule Bob.ReleaseTasksTest do
2+
use Bob.DataCase, async: false
3+
4+
alias Bob.ReleaseTasks
5+
6+
defp locks_held() do
7+
%{rows: [[count]]} =
8+
Repo.query!(
9+
"SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND classid = 0 AND objid = $1",
10+
[ReleaseTasks.migration_lock_key()]
11+
)
12+
13+
count
14+
end
15+
16+
defp lock_connections() do
17+
Repo.query!("SELECT pg_stat_clear_snapshot()", [])
18+
19+
%{rows: [[count]]} =
20+
Repo.query!(
21+
"""
22+
SELECT count(*) FROM pg_stat_activity
23+
WHERE datname = current_database()
24+
AND pid <> pg_backend_pid()
25+
AND query LIKE '%advisory_lock($1)'
26+
""",
27+
[]
28+
)
29+
30+
count
31+
end
32+
33+
defp wait_until(fun, attempts \\ 100) do
34+
cond do
35+
fun.() ->
36+
:ok
37+
38+
attempts == 0 ->
39+
flunk("condition not met")
40+
41+
true ->
42+
Process.sleep(50)
43+
wait_until(fun, attempts - 1)
44+
end
45+
end
46+
47+
defp create_index_concurrently() do
48+
opts = Keyword.take(Repo.config(), [:hostname, :port, :username, :password, :database])
49+
{:ok, conn} = Postgrex.start_link(opts)
50+
51+
try do
52+
Postgrex.query!(conn, "DROP TABLE IF EXISTS migration_lock_test", [])
53+
Postgrex.query!(conn, "CREATE TABLE migration_lock_test (id integer)", [])
54+
55+
Postgrex.query!(
56+
conn,
57+
"CREATE INDEX CONCURRENTLY migration_lock_test_id_index ON migration_lock_test (id)",
58+
[]
59+
)
60+
61+
Postgrex.query!(conn, "DROP TABLE migration_lock_test", [])
62+
after
63+
GenServer.stop(conn)
64+
end
65+
end
66+
67+
describe "with_migration_lock/2" do
68+
test "holds the lock while the function runs" do
69+
assert locks_held() == 0
70+
71+
ReleaseTasks.with_migration_lock(Repo, fn ->
72+
assert locks_held() == 1
73+
end)
74+
75+
assert locks_held() == 0
76+
end
77+
78+
test "releases the lock when the function raises" do
79+
assert_raise RuntimeError, "boom", fn ->
80+
ReleaseTasks.with_migration_lock(Repo, fn -> raise "boom" end)
81+
end
82+
83+
assert locks_held() == 0
84+
end
85+
86+
test "returns what the function returned" do
87+
assert ReleaseTasks.with_migration_lock(Repo, fn -> :migrated end) == :migrated
88+
end
89+
90+
test "a second caller waits for the first to finish" do
91+
test = self()
92+
93+
holder =
94+
Task.async(fn ->
95+
ReleaseTasks.with_migration_lock(Repo, fn ->
96+
send(test, :holding)
97+
receive do: (:release -> :ok)
98+
end)
99+
end)
100+
101+
assert_receive :holding, 10_000
102+
103+
waiter =
104+
Task.async(fn ->
105+
ReleaseTasks.with_migration_lock(Repo, fn -> send(test, :second_ran) end)
106+
end)
107+
108+
refute_receive :second_ran, 200
109+
110+
send(holder.pid, :release)
111+
Task.await(holder, 10_000)
112+
113+
assert_receive :second_ran, 10_000
114+
Task.await(waiter, 10_000)
115+
assert locks_held() == 0
116+
end
117+
118+
test "a waiting caller does not block CREATE INDEX CONCURRENTLY in the holder" do
119+
test = self()
120+
121+
holder =
122+
Task.async(fn ->
123+
ReleaseTasks.with_migration_lock(Repo, fn ->
124+
send(test, :holding)
125+
receive do: (:create_index -> :ok)
126+
create_index_concurrently()
127+
end)
128+
end)
129+
130+
assert_receive :holding, 10_000
131+
132+
waiter =
133+
Task.async(fn ->
134+
ReleaseTasks.with_migration_lock(Repo, fn -> :ok end)
135+
end)
136+
137+
wait_until(fn -> lock_connections() == 2 end)
138+
139+
send(holder.pid, :create_index)
140+
Task.await(holder, 10_000)
141+
Task.await(waiter, 10_000)
142+
assert locks_held() == 0
143+
end
144+
end
145+
end

0 commit comments

Comments
 (0)