pub async fn migrate(
pool: &PgPool,
options: MigrateOptions<'_>,
) -> Result<(), MigrateError>Expand description
Applies Reliar’s migrations. Never invoked implicitly. pool must reach a PostgreSQL 18
or later server — a hard requirement, with no older-version fallback. This is a stated
requirement, not a checked one: migrate() issues no version probe, and a server below the
floor fails later, at whichever migration file or query first needs a PostgreSQL 18 feature
(uuidv7(), in practice).
Creates options.schema if it does not exist, keeps bookkeeping in
<schema>._migrations — never _sqlx_migrations — and serializes concurrent callers with
Reliar’s own advisory lock, acquired by polling (ADR 0040 amendment A; not
sqlx::migrate’s built-in blocking one), so every caller after the first observes Ok(()).
Idempotent.
Self-contained: does not depend on the caller’s search_path (ADR 0018) — create_schema
plus the qualified bookkeeping table name make it work over a pool whose URL never set one.
use reliar_store_postgres::{MigrateOptions, migrate};
migrate(&pool, MigrateOptions::default()).await?;§The lock wait is unbounded, the connection must be a real session, and timing matters
A second concurrent caller can wait for the first for as long as that first run takes —
legitimately minutes for CREATE INDEX CONCURRENTLY on a large table — and this function
never times that wait out on its own; wrap the call in tokio::time::timeout if a bound is
needed. Dropping that future while it is still polling for the lock (before migrator.run
starts) is exactly as clean as it sounds — nothing is held between poll attempts, as noted
below. Dropping it after the lock is acquired, while migrator.run itself is executing
(e.g. mid-CREATE INDEX CONCURRENTLY), is different: the explicit unlock query never runs, so
the advisory lock is released only when the dropped connection’s own teardown ends the
session, not by this function’s normal path — and whatever DDL was in flight is left exactly
as any other interrupted CONCURRENTLY build would be (see the recovery step below).
pool’s connection URL must not point at a transaction-mode pooler: migrate()
needs one real session for the run’s whole duration, both for SET search_path and for the
session-level advisory lock, and a pooler that hands out a different backend per statement
would silently break both (the outbox_pgdog test in this crate’s suite migrates over a
direct connection for exactly this reason, before ever pooling). Finally, CREATE INDEX CONCURRENTLY (in 0002_outbox_claimable_index.sql) must wait for every transaction that was
already open when it started to finish, regardless of what table that transaction touches —
run migrate() when the database has no other long-running transaction in flight.
§Upgrading from 0.3.0
A host that only ever calls this function has nothing to do — migrate() applies
0002/0003/0004 the same way it always applied 0001. 0004_inbox.sql (inbox contract
§3.2, ADR 0042) adds the inbox table used by crate::PostgresInboxStore — a brand-new,
empty table, so it runs in an ordinary transaction like 0001 and needs none of 0002’s
CREATE INDEX CONCURRENTLY caveats below. A host that instead applies the published
.sql artifact through its own DBA pipeline (Flyway, Liquibase, sqitch, golang-migrate, a raw
psql invocation, …) may not be interchangeable with this function for 0002: see
docs/guides/postgres.md’s “migrate() vs. the release SQL artifact” section for the
per-tool equivalent of “run this one file outside a transaction” that 0002’s CREATE INDEX CONCURRENTLY requires (sqlx’s own -- no-transaction marker means nothing to another
tool), and the same section’s note on 0003’s SET LOCAL lock_timeout, which needs an active
transaction to have any effect.
§Upgrading to 0.7.0 (outbox gains its own row identity)
0005–0010 give the outbox row a database-assigned id (pk_outbox) separate from the
client-minted message_id it used to share one column with (ADR 0044). Run migrate()
before starting 0.7.0 application code: 0.7.0 reads/writes message_id, which does not exist
until 0005 applies. A 0.6.0 binary still running against the migrated schema keeps working for
every row it already leased or that predates the migration (id == message_id for those rows,
by construction — see 0006’s backfill), but its own enqueue fails loudly on every new row,
since its INSERT no longer names every NOT NULL column — the caller’s transaction rolls
back rather than writing a row nobody could later identify correctly. See
docs/guides/postgres.md for the full rolling-upgrade table and the recommended
stop-dispatchers-then-migrate procedure. 0006’s backfill is the one step whose cost scales
with table size; its own doc comment carries the batched, restartable escape hatch for a
statement_timeout too short to let it complete in one statement.
§0002_outbox_claimable_index.sql, 0007–0009, 0012–0013, 0015 run outside a
transaction
Seven migrations issue CREATE INDEX CONCURRENTLY (ADR 0040 §2, ADR 0044 §4, ADR 0049
Amendment A, ADR 0050 §6), which PostgreSQL refuses inside a transaction block; sqlx’s
-- no-transaction marker keeps each of them (and only them) out of one. CONCURRENTLY
cannot roll back on failure, so a connection drop or cancellation mid-build leaves an
invalid index rather than undoing itself:
ERROR: relation "ix_outbox_claimable" already exists(or ix_outbox_id / ix_outbox_message_id / ix_outbox_dead_cursor / ix_outbox_claimable_id
/ ix_outbox_ordering_key_id / ix_outbox_claimable_v2) on the next migrate() call means
exactly that for the named index. Recover with, against the same schema:
DROP INDEX CONCURRENTLY ix_outbox_claimable; -- or ix_outbox_id / ix_outbox_message_id /
-- ix_outbox_dead_cursor / ix_outbox_claimable_id /
-- ix_outbox_ordering_key_id / ix_outbox_claimable_v2then re-run migrate() from the start — it is idempotent and will rebuild the index and
continue: 0003_drop_ix_outbox_pending.sql refuses to drop ix_outbox_pending unless
ix_outbox_claimable exists and is valid, and 0010_outbox_primary_key_swap.sql refuses to
promote ix_outbox_id to pk_outbox (or drop the two indexes ix_outbox_dead_cursor
supersedes) unless all three of ix_outbox_id, ix_outbox_message_id and
ix_outbox_dead_cursor exist and are valid.
0014_drop_outbox_sequence.sql guards the same way for 0012/0013’s pair: it refuses to drop
the sequence column (and the indexes that name it) unless both ix_outbox_claimable_id and
ix_outbox_ordering_key_id exist and are valid, and names both in its error when they are not:
ix_outbox_claimable_id / ix_outbox_ordering_key_id are missing or invalid; rebuild them
concurrently, then re-run migrate()Recover the same way as above — DROP INDEX CONCURRENTLY ix_outbox_claimable_id; and/or
ix_outbox_ordering_key_id, whichever the message names, then re-run migrate(): 0012/0013
rebuild the transient index and 0014 proceeds to drop sequence and promote the permanent
names.
0016_drop_outbox_locked_until.sql guards the same way, beside 0014’s, for 0015’s build: it
refuses to drop locked_until/ck_outbox_lease (and rename the transient index to its
permanent name) unless ix_outbox_claimable_v2 exists and is valid, and names it in its error
when it is not:
ix_outbox_claimable_v2 is missing or invalid; rebuild it concurrently, then re-run migrate()Recover the same way as above — DROP INDEX CONCURRENTLY ix_outbox_claimable_v2; — then re-run
migrate(): 0015 rebuilds the transient index and 0016 proceeds to drop
locked_until/ck_outbox_lease and rename the transient index to its permanent name.
§Errors
Returns MigrateError::InvalidSchema when options.schema is not a valid PostgreSQL
identifier, or MigrateError::Sqlx for a connection failure, a checksum mismatch against an
already applied file, a server too old for a migration file’s own SQL (uuidv7(), PostgreSQL
18+), or any other failure sqlx::migrate::Migrator::run reports — including a 0003 run
against a missing/invalid ix_outbox_claimable, a 0014 run against a missing/invalid
ix_outbox_claimable_id/ix_outbox_ordering_key_id, or a 0016 run against a missing/invalid
ix_outbox_claimable_v2 (see above).