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. SHALL agree with
40 /// [`crate::PostgresOutboxSettings::schema`] — [`crate::PostgresOutboxStore::connect`]'s
41 /// startup verification fails otherwise, since `outbox` will not resolve where it expects.
42 /// **Lowercase only** (`[a-z_][a-z0-9_$]*`, at most 63 bytes) — an uppercase name is
43 /// rejected with [`MigrateError::InvalidSchema`] rather than silently folded, since
44 /// PostgreSQL itself would fold it inconsistently across an unquoted reference (ADR 0040
45 /// §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 /// The connected server's `server_version_num` is below
108 /// [`crate::MIN_SERVER_VERSION_NUM`] (PostgreSQL 18, ADR 0041) — **no
109 /// older-version fallback**. Checked as the **first statement** on `migrate`'s dedicated
110 /// connection, before `SET search_path`, schema creation, or any migration file runs — a
111 /// refused `migrate()` leaves the database exactly as it found it. `migrate.rs` declares its
112 /// own copy of this variant rather than re-exporting
113 /// [`crate::PostgresOutboxError::UnsupportedServerVersion`] — the same shape as
114 /// [`Self::InvalidSchema`] alongside [`crate::PostgresOutboxError::InvalidSchema`].
115 UnsupportedServerVersion {
116 /// [`crate::MIN_SERVER_VERSION_NUM`], restated on the value.
117 required: u32,
118 /// The `server_version_num` this connection reported.
119 detected: u32,
120 },
121}
122
123impl fmt::Display for MigrateError {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 match self {
126 Self::InvalidSchema { schema } => write!(
127 f,
128 "{schema:?} is not a valid PostgreSQL identifier (expected \
129 [a-z_][a-z0-9_$]*, at most 63 bytes, lowercase only — PostgreSQL folds an \
130 unquoted identifier to lowercase, so an uppercase name would resolve \
131 inconsistently)"
132 ),
133 Self::Sqlx { source } => write!(f, "migration failed: {source}"),
134 Self::UnsupportedServerVersion { required, detected } => write!(
135 f,
136 "PostgreSQL 18 or newer is required (server_version_num >= {required}); \
137 detected {detected} — there is no supported way to run Reliar below the floor"
138 ),
139 }
140 }
141}
142
143impl std::error::Error for MigrateError {
144 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
145 match self {
146 Self::Sqlx { source } => Some(source),
147 Self::InvalidSchema { .. } | Self::UnsupportedServerVersion { .. } => None,
148 }
149 }
150}
151
152impl From<sqlx::migrate::MigrateError> for MigrateError {
153 fn from(source: sqlx::migrate::MigrateError) -> Self {
154 Self::Sqlx { source }
155 }
156}
157
158impl From<sqlx::Error> for MigrateError {
159 fn from(source: sqlx::Error) -> Self {
160 Self::Sqlx {
161 source: sqlx::migrate::MigrateError::Execute(source),
162 }
163 }
164}
165
166/// Reliar's own advisory-lock key for [`migrate`], derived from `schema` alone (ADR 0040
167/// amendment A) — **never** sqlx's own `generate_lock_id` (private, and keyed on the database:
168/// sharing it would serialize Reliar's migration behind the host's own migrator, dragging any
169/// blocked statement of the host's into `0002`'s `CREATE INDEX CONCURRENTLY` wait set). Schema in
170/// the key, not the database, because advisory locks are already per-database and a multi-tenant
171/// host migrating several schemas should not run their index builds strictly in series
172/// (`PROC_IN_SAFE_IC`, PostgreSQL 14+, this crate's floor is 18, lets two `CONCURRENTLY` builds on
173/// different tables proceed without waiting on each other's snapshots).
174///
175/// FNV-1a 64, spelled out rather than reached for from `std::hash::DefaultHasher` (whose output
176/// is explicitly not stable across processes or releases, so it cannot key a value two different
177/// connections must agree on).
178fn migration_lock_id(schema: &str) -> i64 {
179 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
180 const PRIME: u64 = 0x0000_0100_0000_01b3;
181 let mut hash = OFFSET;
182
183 for byte in b"reliar.migrate:v1:".iter().chain(schema.as_bytes()) {
184 hash ^= u64::from(*byte);
185
186 hash = hash.wrapping_mul(PRIME);
187 }
188
189 i64::from_le_bytes(hash.to_le_bytes())
190}
191
192/// Serializes concurrent [`migrate`] callers on the same `schema` **without** sqlx's own
193/// `Migrator::set_locking(true)` (ADR 0040 amendment A): that path takes the lock with a
194/// *blocking* `SELECT pg_advisory_lock($1)`, and a blocked statement holds an open snapshot for
195/// as long as it waits — which deadlocks against `0002`'s `CREATE INDEX CONCURRENTLY`, itself
196/// waiting for every older snapshot to end, the moment a second caller's lock attempt overlaps
197/// the first caller's index build. `pg_try_advisory_lock` returns at once whether or not it
198/// acquired the lock, so between attempts this connection is genuinely idle: no open statement,
199/// no snapshot, nothing for a concurrent index build to wait on.
200///
201/// **Session-level, not transaction-level** (`pg_try_advisory_lock`, not the `_xact_` variant): a
202/// lock tied to a transaction would force one open across the whole run, defeating `0002`'s
203/// `-- no-transaction` marker outright.
204///
205/// The wait is **unbounded**, deliberately: `CREATE INDEX CONCURRENTLY` on a large table can
206/// legitimately take minutes, and a deadline short enough to matter would fail exactly the
207/// deploy this exists to let through cleanly. A caller that wants a bound wraps the call to
208/// [`migrate`] in `tokio::time::timeout` — dropping that future while this is polling releases
209/// nothing, because nothing is held.
210async fn acquire_migration_lock(conn: &mut PgConnection, lock_id: i64) -> Result<(), sqlx::Error> {
211 let mut backoff = LOCK_POLL_FIRST_RETRY;
212
213 loop {
214 let acquired =
215 sqlx::query_scalar!(r#"SELECT pg_try_advisory_lock($1) AS "acquired!""#, lock_id)
216 .fetch_one(&mut *conn)
217 .await?;
218
219 if acquired {
220 return Ok(());
221 }
222
223 if backoff == LOCK_POLL_FIRST_RETRY {
224 tracing::info!(
225 "another migrate() call holds Reliar's migration lock for this schema; waiting"
226 );
227 }
228
229 tokio::time::sleep(backoff).await;
230
231 backoff = (backoff * 2).min(LOCK_POLL_MAX_RETRY);
232 }
233}
234
235/// Releases [`acquire_migration_lock`]'s lock. Best-effort: called on every path out of
236/// [`migrate`] once the lock is held, but its own failure is never allowed to shadow the
237/// migration run's result — ending the session (`conn.close()`, right after) releases the lock
238/// regardless, and sqlx's own `run_direct` does not unlock on its error path either.
239async fn release_migration_lock(conn: &mut PgConnection, lock_id: i64) {
240 let result = sqlx::query_scalar!(r#"SELECT pg_advisory_unlock($1) AS "released!""#, lock_id)
241 .fetch_one(&mut *conn)
242 .await;
243
244 if let Err(err) = result {
245 tracing::warn!(error = %err, "failed to release Reliar's migration lock; ending the session releases it anyway");
246 }
247}
248
249/// Applies Reliar's migrations. **Never invoked implicitly.** `pool` must reach a **PostgreSQL 18
250/// or later** server — a hard requirement, with no older-version fallback, checked here as the
251/// first statement on this function's dedicated connection (ADR 0041):
252/// a server below the floor returns [`MigrateError::UnsupportedServerVersion`] before the schema
253/// is created or any migration file runs.
254///
255/// Creates `options.schema` if it does not exist, keeps bookkeeping in
256/// `<schema>._migrations` — never `_sqlx_migrations` — and serializes concurrent callers with
257/// **Reliar's own** advisory lock, acquired by polling (ADR 0040 amendment A; not
258/// `sqlx::migrate`'s built-in blocking one), so every caller after the first observes `Ok(())`.
259/// **Idempotent.**
260/// Self-contained: does not depend on the caller's `search_path` (ADR 0018) — `create_schema`
261/// plus the qualified bookkeeping table name make it work over a pool whose URL never set one.
262///
263/// ```no_run
264/// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
265/// use reliar_store_postgres::{MigrateOptions, migrate};
266///
267/// migrate(&pool, MigrateOptions::default()).await?;
268/// # Ok(())
269/// # }
270/// ```
271///
272/// # The lock wait is unbounded, the connection must be a real session, and timing matters
273///
274/// A second concurrent caller can wait for the first for as long as that first run takes —
275/// legitimately minutes for `CREATE INDEX CONCURRENTLY` on a large table — and this function
276/// never times that wait out on its own; wrap the call in `tokio::time::timeout` if a bound is
277/// needed. Dropping that future while it is still polling for the lock (before `migrator.run`
278/// starts) is exactly as clean as it sounds — nothing is held between poll attempts, as noted
279/// below. Dropping it **after** the lock is acquired, while `migrator.run` itself is executing
280/// (e.g. mid-`CREATE INDEX CONCURRENTLY`), is different: the explicit unlock query never runs, so
281/// the advisory lock is released only when the dropped connection's own teardown ends the
282/// session, not by this function's normal path — and whatever DDL was in flight is left exactly
283/// as any other interrupted `CONCURRENTLY` build would be (see the recovery step below).
284/// `pool`'s connection URL **must not point at a transaction-mode pooler**: `migrate()`
285/// needs one real session for the run's whole duration, both for `SET search_path` and for the
286/// session-level advisory lock, and a pooler that hands out a different backend per statement
287/// would silently break both (the `outbox_pgdog` test in this crate's suite migrates over a
288/// direct connection for exactly this reason, before ever pooling). Finally, `CREATE INDEX
289/// CONCURRENTLY` (in `0002_outbox_claimable_index.sql`) must wait for every transaction that was
290/// already open when it started to finish, regardless of what table that transaction touches —
291/// run `migrate()` when the database has no other long-running transaction in flight.
292///
293/// # Upgrading from 0.3.0
294///
295/// A host that only ever calls this function has nothing to do — `migrate()` applies
296/// `0002`/`0003`/`0004` the same way it always applied `0001`. `0004_inbox.sql` (inbox contract
297/// §3.2, ADR 0042) adds the `inbox` table used by [`crate::PostgresInboxStore`] — a brand-new,
298/// empty table, so it runs in an ordinary transaction like `0001` and needs none of `0002`'s
299/// `CREATE INDEX CONCURRENTLY` caveats below. A host that instead applies the published
300/// `.sql` artifact through its own DBA pipeline (Flyway, Liquibase, sqitch, golang-migrate, a raw
301/// `psql` invocation, …) **may not be** interchangeable with this function for `0002`: see
302/// `docs/guides/postgres.md`'s "`migrate()` vs. the release SQL artifact" section for the
303/// per-tool equivalent of "run this one file outside a transaction" that `0002`'s `CREATE INDEX
304/// CONCURRENTLY` requires (`sqlx`'s own `-- no-transaction` marker means nothing to another
305/// tool), and the same section's note on `0003`'s `SET LOCAL lock_timeout`, which needs an active
306/// transaction to have any effect.
307///
308/// # Upgrading to 0.7.0 (`outbox` gains its own row identity)
309///
310/// `0005`–`0010` give the `outbox` row a database-assigned `id` (`pk_outbox`) separate from the
311/// client-minted `message_id` it used to share one column with (ADR 0044). **Run `migrate()`
312/// before starting 0.7.0 application code**: 0.7.0 reads/writes `message_id`, which does not exist
313/// until `0005` applies. A 0.6.0 binary still running against the migrated schema keeps working for
314/// every row it already leased or that predates the migration (`id == message_id` for those rows,
315/// by construction — see `0006`'s backfill), but its own `enqueue` fails loudly on every new row,
316/// since its `INSERT` no longer names every `NOT NULL` column — the caller's transaction rolls
317/// back rather than writing a row nobody could later identify correctly. See
318/// `docs/guides/postgres.md` for the full rolling-upgrade table and the recommended
319/// stop-dispatchers-then-migrate procedure. `0006`'s backfill is the one step whose cost scales
320/// with table size; its own doc comment carries the batched, restartable escape hatch for a
321/// `statement_timeout` too short to let it complete in one statement.
322///
323/// # `0002_outbox_claimable_index.sql`, `0007`–`0009` run outside a transaction
324///
325/// Four migrations issue `CREATE INDEX CONCURRENTLY` (ADR 0040 §2, ADR 0044 §4), which PostgreSQL
326/// refuses inside a transaction block; sqlx's `-- no-transaction` marker keeps each of them (and
327/// only them) out of one. `CONCURRENTLY` cannot roll back on failure, so a connection drop or
328/// cancellation mid-build leaves an **invalid** index rather than undoing itself:
329///
330/// ```text
331/// ERROR: relation "ix_outbox_claimable" already exists
332/// ```
333///
334/// (or `ix_outbox_id` / `ix_outbox_message_id` / `ix_outbox_dead_cursor`) on the next `migrate()`
335/// call means exactly that for the named index. Recover with, against the same schema:
336///
337/// ```sql
338/// DROP INDEX CONCURRENTLY ix_outbox_claimable; -- or ix_outbox_id / ix_outbox_message_id / ix_outbox_dead_cursor
339/// ```
340///
341/// then re-run `migrate()` from the start — it is idempotent and will rebuild the index and
342/// continue: `0003_drop_ix_outbox_pending.sql` refuses to drop `ix_outbox_pending` unless
343/// `ix_outbox_claimable` exists and is valid, and `0010_outbox_primary_key_swap.sql` refuses to
344/// promote `ix_outbox_id` to `pk_outbox` (or drop the two indexes `ix_outbox_dead_cursor`
345/// supersedes) unless all three of `ix_outbox_id`, `ix_outbox_message_id` and
346/// `ix_outbox_dead_cursor` exist and are valid.
347///
348/// # Errors
349///
350/// Returns [`MigrateError::InvalidSchema`] when `options.schema` is not a valid PostgreSQL
351/// identifier, [`MigrateError::UnsupportedServerVersion`] when `pool` reaches a server older
352/// than [`crate::MIN_SERVER_VERSION_NUM`] (PostgreSQL 18), or [`MigrateError::Sqlx`] for a
353/// connection failure, a checksum mismatch against an already applied file, or any other failure
354/// `sqlx::migrate::Migrator::run` reports — including a `0003` run against a missing/invalid
355/// `ix_outbox_claimable` (see above).
356pub async fn migrate(pool: &PgPool, options: MigrateOptions<'_>) -> Result<(), MigrateError> {
357 // Validated once, before it is ever interpolated into `dangerous_set_table_name`/`SET
358 // search_path` below, both of which build SQL text from this value rather than binding it
359 // as data.
360 if !crate::connection::schema::is_valid_schema_name(options.schema) {
361 return Err(MigrateError::InvalidSchema {
362 schema: options.schema.to_owned(),
363 });
364 }
365
366 // `Migrator` has no `Clone` impl, but every field is public (`migrate!()` relies on that to
367 // construct the static in a const-promotable context), so a field-by-field copy is the
368 // sanctioned way to get a mutable instance without touching the static (ADR 0018).
369 let mut migrator = Migrator {
370 migrations: MIGRATOR.migrations.clone(),
371 ignore_missing: MIGRATOR.ignore_missing,
372 locking: MIGRATOR.locking,
373 no_tx: MIGRATOR.no_tx,
374 table_name: MIGRATOR.table_name.clone(),
375 create_schemas: MIGRATOR.create_schemas.clone(),
376 };
377 migrator.create_schema(options.schema.to_owned());
378 migrator.dangerous_set_table_name(format!("{}._migrations", options.schema));
379 // Reliar's own poll-based lock, not sqlx's blocking one — see `acquire_migration_lock`
380 // (ADR 0040 amendment A). `set_locking(false)` (the default is `true`) turns off
381 // `Migrator::run`'s built-in mutual exclusion so this is the only lock in play.
382 migrator.set_locking(false);
383
384 // `SET search_path` (unqualified migration SQL needs it, ADR 0018) is session-level, and so
385 // is the migration lock below — both need a dedicated connection, never `pool.acquire()`,
386 // since sqlx never resets a session-level GUC or an advisory lock when a pooled connection
387 // is released. The URL behind `pool` must therefore not point at a transaction-mode pooler:
388 // a session is required for both.
389 let connect_options = pool.connect_options();
390 let mut conn = PgConnection::connect_with(&connect_options).await?;
391
392 // First statement on this connection (ADR 0041) — before `SET
393 // search_path`, before `CREATE SCHEMA`, before any migration file runs, so a refused
394 // `migrate()` leaves the database exactly as it found it.
395 let detected = crate::connection::version::detected_server_version_num(&mut conn).await?;
396
397 if detected < crate::MIN_SERVER_VERSION_NUM {
398 return Err(MigrateError::UnsupportedServerVersion {
399 required: crate::MIN_SERVER_VERSION_NUM,
400 detected,
401 });
402 }
403
404 conn.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
405 "SET search_path = \"{}\", public",
406 options.schema.replace('"', "\"\"")
407 ))))
408 .await?;
409
410 let lock_id = migration_lock_id(options.schema);
411 acquire_migration_lock(&mut conn, lock_id).await?;
412 let run_result = migrator.run(&mut conn).await;
413 release_migration_lock(&mut conn, lock_id).await;
414 run_result?;
415
416 conn.close().await?;
417
418 Ok(())
419}