reliar_store_postgres/migrate.rs
1//! Explicit migration entry point (ADR 0018).
2//!
3//! **Never invoked implicitly** — no constructor, `Default`, or `acquire` runs a migration.
4//! Reliar's bookkeeping lives in its own schema's `_migrations` table, never the shared,
5//! one-per-database `_sqlx_migrations` sqlx would otherwise write to, so this can be added to a
6//! database a host already migrates with its own tooling without either side noticing the other.
7
8use core::fmt;
9use std::time::Duration;
10
11use sqlx::migrate::Migrator;
12use sqlx::postgres::PgConnection;
13use sqlx::{Connection, Executor, PgPool};
14
15/// The first retry delay [`migrate`]'s lock poll waits between failed `pg_try_advisory_lock`
16/// attempts (ADR 0040 amendment A).
17const LOCK_POLL_FIRST_RETRY: Duration = Duration::from_millis(50);
18/// The retry delay [`migrate`]'s lock poll backs off to and caps at.
19const LOCK_POLL_MAX_RETRY: Duration = Duration::from_secs(1);
20
21/// The crate's migrations, embedded at compile time from `migrations/` — the single source of
22/// truth (ADR 0018): `cargo publish` packages only files under the crate's own directory, and
23/// `sqlx::migrate!` resolves relative to `CARGO_MANIFEST_DIR` at compile time, so the SQL must
24/// live here rather than at the repository root.
25static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
26
27/// Where [`migrate`] creates Reliar's schema and its bookkeeping table.
28///
29/// ```
30/// use reliar_store_postgres::MigrateOptions;
31///
32/// let options = MigrateOptions::default().schema("orders");
33/// assert_eq!(options.schema, "orders");
34/// ```
35#[derive(Clone, Copy, Debug)]
36#[non_exhaustive]
37pub struct MigrateOptions<'a> {
38 /// The schema to create (`CREATE SCHEMA IF NOT EXISTS`) and use for both the data tables
39 /// and the `_migrations` bookkeeping table. Put this same value first on the connection's
40 /// `search_path` — in the connection URL (`options=-c search_path=<schema>,public`) or with
41 /// `ALTER ROLE <role> SET search_path = <schema>, public` — so every unqualified reference
42 /// resolves here; Reliar does not verify this for you (ADR 0047). **Lowercase only**
43 /// (`[a-z_][a-z0-9_$]*`, at most 63 bytes) — an uppercase name is rejected with
44 /// [`MigrateError::InvalidSchema`] rather than silently folded, since PostgreSQL itself would
45 /// fold it inconsistently across an unquoted reference (ADR 0040 §5).
46 pub schema: &'a str,
47}
48
49impl Default for MigrateOptions<'_> {
50 fn default() -> Self {
51 Self { schema: "reliar" }
52 }
53}
54
55impl<'a> MigrateOptions<'a> {
56 /// Sets [`Self::schema`]. `#[non_exhaustive]` forbids struct-literal construction outside
57 /// this crate, so this is the only way to migrate into a non-default schema.
58 ///
59 /// ```
60 /// use reliar_store_postgres::MigrateOptions;
61 /// let options = MigrateOptions::default().schema("orders");
62 /// assert_eq!(options.schema, "orders");
63 /// ```
64 #[must_use]
65 pub const fn schema(mut self, schema: &'a str) -> Self {
66 self.schema = schema;
67
68 self
69 }
70}
71
72/// [`migrate`]'s failure. **Provider-owned**, not a re-export of `sqlx::migrate::MigrateError`:
73/// a rejected schema identifier has no variant in `sqlx`'s own type to
74/// report it as, since that check happens before any `sqlx::migrate` code runs at all.
75///
76/// ```no_run
77/// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
78/// use reliar_store_postgres::{MigrateOptions, migrate};
79///
80/// let options = MigrateOptions::default().schema("Not-Lowercase");
81/// if let Err(err) = migrate(&pool, options).await {
82/// eprintln!("migration failed: {err}");
83/// }
84/// # Ok(())
85/// # }
86/// ```
87#[derive(Debug)]
88#[non_exhaustive]
89pub enum MigrateError {
90 /// `options.schema` is not a valid PostgreSQL identifier (`[a-z_][a-z0-9_$]*`, at most 63
91 /// bytes, **lowercase only** — PostgreSQL folds an unquoted identifier to lowercase, so an
92 /// uppercase name would migrate into a schema the rest of the crate, and the host's own
93 /// `search_path`, can never consistently resolve; ADR 0040 §5). Checked **before** the name
94 /// reaches `dangerous_set_table_name`, which is string interpolation into DDL.
95 InvalidSchema {
96 /// The rejected schema name.
97 schema: String,
98 },
99
100 /// Any failure from `sqlx::migrate::Migrator::run` or the dedicated connection's own setup
101 /// (a connection failure, a checksum mismatch against an already-applied file, …).
102 Sqlx {
103 /// The underlying `sqlx` migration error.
104 source: sqlx::migrate::MigrateError,
105 },
106}
107
108impl fmt::Display for MigrateError {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 Self::InvalidSchema { schema } => write!(
112 f,
113 "{schema:?} is not a valid PostgreSQL identifier (expected \
114 [a-z_][a-z0-9_$]*, at most 63 bytes, lowercase only — PostgreSQL folds an \
115 unquoted identifier to lowercase, so an uppercase name would resolve \
116 inconsistently)"
117 ),
118 Self::Sqlx { source } => write!(f, "migration failed: {source}"),
119 }
120 }
121}
122
123impl std::error::Error for MigrateError {
124 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
125 match self {
126 Self::Sqlx { source } => Some(source),
127 Self::InvalidSchema { .. } => None,
128 }
129 }
130}
131
132impl From<sqlx::migrate::MigrateError> for MigrateError {
133 fn from(source: sqlx::migrate::MigrateError) -> Self {
134 Self::Sqlx { source }
135 }
136}
137
138impl From<sqlx::Error> for MigrateError {
139 fn from(source: sqlx::Error) -> Self {
140 Self::Sqlx {
141 source: sqlx::migrate::MigrateError::Execute(source),
142 }
143 }
144}
145
146/// Reliar's own advisory-lock key for [`migrate`], derived from `schema` alone (ADR 0040
147/// amendment A) — **never** sqlx's own `generate_lock_id` (private, and keyed on the database:
148/// sharing it would serialize Reliar's migration behind the host's own migrator, dragging any
149/// blocked statement of the host's into `0002`'s `CREATE INDEX CONCURRENTLY` wait set). Schema in
150/// the key, not the database, because advisory locks are already per-database and a multi-tenant
151/// host migrating several schemas should not run their index builds strictly in series
152/// (`PROC_IN_SAFE_IC`, PostgreSQL 14+, this crate's floor is 18, lets two `CONCURRENTLY` builds on
153/// different tables proceed without waiting on each other's snapshots).
154///
155/// FNV-1a 64, spelled out rather than reached for from `std::hash::DefaultHasher` (whose output
156/// is explicitly not stable across processes or releases, so it cannot key a value two different
157/// connections must agree on).
158fn migration_lock_id(schema: &str) -> i64 {
159 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
160 const PRIME: u64 = 0x0000_0100_0000_01b3;
161 let mut hash = OFFSET;
162
163 for byte in b"reliar.migrate:v1:".iter().chain(schema.as_bytes()) {
164 hash ^= u64::from(*byte);
165
166 hash = hash.wrapping_mul(PRIME);
167 }
168
169 i64::from_le_bytes(hash.to_le_bytes())
170}
171
172/// Serializes concurrent [`migrate`] callers on the same `schema` **without** sqlx's own
173/// `Migrator::set_locking(true)` (ADR 0040 amendment A): that path takes the lock with a
174/// *blocking* `SELECT pg_advisory_lock($1)`, and a blocked statement holds an open snapshot for
175/// as long as it waits — which deadlocks against `0002`'s `CREATE INDEX CONCURRENTLY`, itself
176/// waiting for every older snapshot to end, the moment a second caller's lock attempt overlaps
177/// the first caller's index build. `pg_try_advisory_lock` returns at once whether or not it
178/// acquired the lock, so between attempts this connection is genuinely idle: no open statement,
179/// no snapshot, nothing for a concurrent index build to wait on.
180///
181/// **Session-level, not transaction-level** (`pg_try_advisory_lock`, not the `_xact_` variant): a
182/// lock tied to a transaction would force one open across the whole run, defeating `0002`'s
183/// `-- no-transaction` marker outright.
184///
185/// The wait is **unbounded**, deliberately: `CREATE INDEX CONCURRENTLY` on a large table can
186/// legitimately take minutes, and a deadline short enough to matter would fail exactly the
187/// deploy this exists to let through cleanly. A caller that wants a bound wraps the call to
188/// [`migrate`] in `tokio::time::timeout` — dropping that future while this is polling releases
189/// nothing, because nothing is held.
190async fn acquire_migration_lock(conn: &mut PgConnection, lock_id: i64) -> Result<(), sqlx::Error> {
191 let mut backoff = LOCK_POLL_FIRST_RETRY;
192
193 loop {
194 let acquired =
195 sqlx::query_scalar!(r#"SELECT pg_try_advisory_lock($1) AS "acquired!""#, lock_id)
196 .fetch_one(&mut *conn)
197 .await?;
198
199 if acquired {
200 return Ok(());
201 }
202
203 if backoff == LOCK_POLL_FIRST_RETRY {
204 tracing::info!(
205 "another migrate() call holds Reliar's migration lock for this schema; waiting"
206 );
207 }
208
209 tokio::time::sleep(backoff).await;
210
211 backoff = (backoff * 2).min(LOCK_POLL_MAX_RETRY);
212 }
213}
214
215/// Releases [`acquire_migration_lock`]'s lock. Best-effort: called on every path out of
216/// [`migrate`] once the lock is held, but its own failure is never allowed to shadow the
217/// migration run's result — ending the session (`conn.close()`, right after) releases the lock
218/// regardless, and sqlx's own `run_direct` does not unlock on its error path either.
219async fn release_migration_lock(conn: &mut PgConnection, lock_id: i64) {
220 let result = sqlx::query_scalar!(r#"SELECT pg_advisory_unlock($1) AS "released!""#, lock_id)
221 .fetch_one(&mut *conn)
222 .await;
223
224 if let Err(err) = result {
225 tracing::warn!(error = %err, "failed to release Reliar's migration lock; ending the session releases it anyway");
226 }
227}
228
229/// Applies Reliar's migrations. **Never invoked implicitly.** `pool` must reach a **PostgreSQL 18
230/// or later** server — a hard requirement, with no older-version fallback. This is a stated
231/// requirement, not a checked one: `migrate()` issues no version probe, and a server below the
232/// floor fails later, at whichever migration file or query first needs a PostgreSQL 18 feature
233/// (`uuidv7()`, in practice).
234///
235/// Creates `options.schema` if it does not exist, keeps bookkeeping in
236/// `<schema>._migrations` — never `_sqlx_migrations` — and serializes concurrent callers with
237/// **Reliar's own** advisory lock, acquired by polling (ADR 0040 amendment A; not
238/// `sqlx::migrate`'s built-in blocking one), so every caller after the first observes `Ok(())`.
239/// **Idempotent.**
240/// Self-contained: does not depend on the caller's `search_path` (ADR 0018) — `create_schema`
241/// plus the qualified bookkeeping table name make it work over a pool whose URL never set one.
242///
243/// ```no_run
244/// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
245/// use reliar_store_postgres::{MigrateOptions, migrate};
246///
247/// migrate(&pool, MigrateOptions::default()).await?;
248/// # Ok(())
249/// # }
250/// ```
251///
252/// # The lock wait is unbounded, the connection must be a real session, and timing matters
253///
254/// A second concurrent caller can wait for the first for as long as that first run takes —
255/// legitimately minutes for `CREATE INDEX CONCURRENTLY` on a large table — and this function
256/// never times that wait out on its own; wrap the call in `tokio::time::timeout` if a bound is
257/// needed. Dropping that future while it is still polling for the lock (before `migrator.run`
258/// starts) is exactly as clean as it sounds — nothing is held between poll attempts, as noted
259/// below. Dropping it **after** the lock is acquired, while `migrator.run` itself is executing
260/// (e.g. mid-`CREATE INDEX CONCURRENTLY`), is different: the explicit unlock query never runs, so
261/// the advisory lock is released only when the dropped connection's own teardown ends the
262/// session, not by this function's normal path — and whatever DDL was in flight is left exactly
263/// as any other interrupted `CONCURRENTLY` build would be (see the recovery step below).
264/// `pool`'s connection URL **must not point at a transaction-mode pooler**: `migrate()`
265/// needs one real session for the run's whole duration, both for `SET search_path` and for the
266/// session-level advisory lock, and a pooler that hands out a different backend per statement
267/// would silently break both (the `outbox_pgdog` test in this crate's suite migrates over a
268/// direct connection for exactly this reason, before ever pooling). Finally, `CREATE INDEX
269/// CONCURRENTLY` (in `0002_outbox_claimable_index.sql`) must wait for every transaction that was
270/// already open when it started to finish, regardless of what table that transaction touches —
271/// run `migrate()` when the database has no other long-running transaction in flight.
272///
273/// # Upgrading from 0.3.0
274///
275/// A host that only ever calls this function has nothing to do — `migrate()` applies
276/// `0002`/`0003`/`0004` the same way it always applied `0001`. `0004_inbox.sql` (inbox contract
277/// §3.2, ADR 0042) adds the `inbox` table used by [`crate::PostgresInboxStore`] — a brand-new,
278/// empty table, so it runs in an ordinary transaction like `0001` and needs none of `0002`'s
279/// `CREATE INDEX CONCURRENTLY` caveats below. A host that instead applies the published
280/// `.sql` artifact through its own DBA pipeline (Flyway, Liquibase, sqitch, golang-migrate, a raw
281/// `psql` invocation, …) **may not be** interchangeable with this function for `0002`: see
282/// `docs/guides/postgres.md`'s "`migrate()` vs. the release SQL artifact" section for the
283/// per-tool equivalent of "run this one file outside a transaction" that `0002`'s `CREATE INDEX
284/// CONCURRENTLY` requires (`sqlx`'s own `-- no-transaction` marker means nothing to another
285/// tool), and the same section's note on `0003`'s `SET LOCAL lock_timeout`, which needs an active
286/// transaction to have any effect.
287///
288/// # Upgrading to 0.7.0 (`outbox` gains its own row identity)
289///
290/// `0005`–`0010` give the `outbox` row a database-assigned `id` (`pk_outbox`) separate from the
291/// client-minted `message_id` it used to share one column with (ADR 0044). **Run `migrate()`
292/// before starting 0.7.0 application code**: 0.7.0 reads/writes `message_id`, which does not exist
293/// until `0005` applies. A 0.6.0 binary still running against the migrated schema keeps working for
294/// every row it already leased or that predates the migration (`id == message_id` for those rows,
295/// by construction — see `0006`'s backfill), but its own `enqueue` fails loudly on every new row,
296/// since its `INSERT` no longer names every `NOT NULL` column — the caller's transaction rolls
297/// back rather than writing a row nobody could later identify correctly. See
298/// `docs/guides/postgres.md` for the full rolling-upgrade table and the recommended
299/// stop-dispatchers-then-migrate procedure. `0006`'s backfill is the one step whose cost scales
300/// with table size; its own doc comment carries the batched, restartable escape hatch for a
301/// `statement_timeout` too short to let it complete in one statement.
302///
303/// # `0002_outbox_claimable_index.sql`, `0007`–`0009`, `0012`–`0013`, `0015` run outside a
304/// transaction
305///
306/// Seven migrations issue `CREATE INDEX CONCURRENTLY` (ADR 0040 §2, ADR 0044 §4, ADR 0049
307/// Amendment A, ADR 0050 §6), which PostgreSQL refuses inside a transaction block; sqlx's
308/// `-- no-transaction` marker keeps each of them (and only them) out of one. `CONCURRENTLY`
309/// cannot roll back on failure, so a connection drop or cancellation mid-build leaves an
310/// **invalid** index rather than undoing itself:
311///
312/// ```text
313/// ERROR: relation "ix_outbox_claimable" already exists
314/// ```
315///
316/// (or `ix_outbox_id` / `ix_outbox_message_id` / `ix_outbox_dead_cursor` / `ix_outbox_claimable_id`
317/// / `ix_outbox_ordering_key_id` / `ix_outbox_claimable_v2`) on the next `migrate()` call means
318/// exactly that for the named index. Recover with, against the same schema:
319///
320/// ```sql
321/// DROP INDEX CONCURRENTLY ix_outbox_claimable; -- or ix_outbox_id / ix_outbox_message_id /
322/// -- ix_outbox_dead_cursor / ix_outbox_claimable_id /
323/// -- ix_outbox_ordering_key_id / ix_outbox_claimable_v2
324/// ```
325///
326/// then re-run `migrate()` from the start — it is idempotent and will rebuild the index and
327/// continue: `0003_drop_ix_outbox_pending.sql` refuses to drop `ix_outbox_pending` unless
328/// `ix_outbox_claimable` exists and is valid, and `0010_outbox_primary_key_swap.sql` refuses to
329/// promote `ix_outbox_id` to `pk_outbox` (or drop the two indexes `ix_outbox_dead_cursor`
330/// supersedes) unless all three of `ix_outbox_id`, `ix_outbox_message_id` and
331/// `ix_outbox_dead_cursor` exist and are valid.
332///
333/// `0014_drop_outbox_sequence.sql` guards the same way for `0012`/`0013`'s pair: it refuses to drop
334/// the `sequence` column (and the indexes that name it) unless both `ix_outbox_claimable_id` and
335/// `ix_outbox_ordering_key_id` exist and are valid, and names both in its error when they are not:
336///
337/// ```text
338/// ix_outbox_claimable_id / ix_outbox_ordering_key_id are missing or invalid; rebuild them
339/// concurrently, then re-run migrate()
340/// ```
341///
342/// Recover the same way as above — `DROP INDEX CONCURRENTLY ix_outbox_claimable_id;` and/or
343/// `ix_outbox_ordering_key_id`, whichever the message names, then re-run `migrate()`: `0012`/`0013`
344/// rebuild the transient index and `0014` proceeds to drop `sequence` and promote the permanent
345/// names.
346///
347/// `0016_drop_outbox_locked_until.sql` guards the same way, beside `0014`'s, for `0015`'s build: it
348/// refuses to drop `locked_until`/`ck_outbox_lease` (and rename the transient index to its
349/// permanent name) unless `ix_outbox_claimable_v2` exists and is valid, and names it in its error
350/// when it is not:
351///
352/// ```text
353/// ix_outbox_claimable_v2 is missing or invalid; rebuild it concurrently, then re-run migrate()
354/// ```
355///
356/// Recover the same way as above — `DROP INDEX CONCURRENTLY ix_outbox_claimable_v2;` — then re-run
357/// `migrate()`: `0015` rebuilds the transient index and `0016` proceeds to drop
358/// `locked_until`/`ck_outbox_lease` and rename the transient index to its permanent name.
359///
360/// # Errors
361///
362/// Returns [`MigrateError::InvalidSchema`] when `options.schema` is not a valid PostgreSQL
363/// identifier, or [`MigrateError::Sqlx`] for a connection failure, a checksum mismatch against an
364/// already applied file, a server too old for a migration file's own SQL (`uuidv7()`, PostgreSQL
365/// 18+), or any other failure `sqlx::migrate::Migrator::run` reports — including a `0003` run
366/// against a missing/invalid `ix_outbox_claimable`, a `0014` run against a missing/invalid
367/// `ix_outbox_claimable_id`/`ix_outbox_ordering_key_id`, or a `0016` run against a missing/invalid
368/// `ix_outbox_claimable_v2` (see above).
369pub async fn migrate(pool: &PgPool, options: MigrateOptions<'_>) -> Result<(), MigrateError> {
370 // Validated once, before it is ever interpolated into `dangerous_set_table_name`/`SET
371 // search_path` below, both of which build SQL text from this value rather than binding it
372 // as data.
373 if !is_valid_schema_name(options.schema) {
374 return Err(MigrateError::InvalidSchema {
375 schema: options.schema.to_owned(),
376 });
377 }
378
379 // `Migrator` has no `Clone` impl, but every field is public (`migrate!()` relies on that to
380 // construct the static in a const-promotable context), so a field-by-field copy is the
381 // sanctioned way to get a mutable instance without touching the static (ADR 0018).
382 let mut migrator = Migrator {
383 migrations: MIGRATOR.migrations.clone(),
384 ignore_missing: MIGRATOR.ignore_missing,
385 locking: MIGRATOR.locking,
386 no_tx: MIGRATOR.no_tx,
387 table_name: MIGRATOR.table_name.clone(),
388 create_schemas: MIGRATOR.create_schemas.clone(),
389 };
390 migrator.create_schema(options.schema.to_owned());
391 migrator.dangerous_set_table_name(format!("{}._migrations", options.schema));
392 // Reliar's own poll-based lock, not sqlx's blocking one — see `acquire_migration_lock`
393 // (ADR 0040 amendment A). `set_locking(false)` (the default is `true`) turns off
394 // `Migrator::run`'s built-in mutual exclusion so this is the only lock in play.
395 migrator.set_locking(false);
396
397 // `SET search_path` (unqualified migration SQL needs it, ADR 0018) is session-level, and so
398 // is the migration lock below — both need a dedicated connection, never `pool.acquire()`,
399 // since sqlx never resets a session-level GUC or an advisory lock when a pooled connection
400 // is released. The URL behind `pool` must therefore not point at a transaction-mode pooler:
401 // a session is required for both.
402 let connect_options = pool.connect_options();
403 let mut conn = PgConnection::connect_with(&connect_options).await?;
404
405 conn.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
406 "SET search_path = \"{}\", public",
407 options.schema.replace('"', "\"\"")
408 ))))
409 .await?;
410
411 let lock_id = migration_lock_id(options.schema);
412 acquire_migration_lock(&mut conn, lock_id).await?;
413 let run_result = migrator.run(&mut conn).await;
414 release_migration_lock(&mut conn, lock_id).await;
415 run_result?;
416
417 conn.close().await?;
418
419 Ok(())
420}
421
422/// Validates a schema name against PostgreSQL's unquoted-identifier grammar, restricted to
423/// **lowercase** (`[a-z_][a-z0-9_$]*`, at most 63 bytes — Postgres's own `NAMEDATALEN` limit)
424/// **before** it is ever interpolated into `SET search_path`/`dangerous_set_table_name`, both of
425/// which build SQL text from this value rather than binding it as data. The only caller left
426/// after ADR 0047 (the stores themselves never validate a schema name — they never see one).
427///
428/// **Lowercase only, not merely case-insensitive (ADR 0040 §5).** PostgreSQL folds an *unquoted*
429/// identifier to lowercase, so `schema = "Foo"` would migrate into a schema literally named
430/// `"Foo"` (quoted) while every unqualified reference — the claim, `stats()`, the host's own
431/// `search_path` — resolves the unquoted, lowercase-folded `foo` instead: a mismatch this crate
432/// cannot detect from inside a single connection's `search_path`, since the host's own connection
433/// string or `ALTER ROLE` also has to agree, and cannot be fixed here. Rejecting every uppercase
434/// character removes the class of mismatch instead of chasing it through four call sites.
435fn is_valid_schema_name(schema: &str) -> bool {
436 let mut chars = schema.chars();
437 let Some(first) = chars.next() else {
438 return false;
439 };
440
441 if !(first.is_ascii_lowercase() || first == '_') {
442 return false;
443 }
444
445 schema.len() <= 63
446 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '$')
447}