1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Explicit migration entry point (ADR 0018).
//!
//! **Never invoked implicitly** — no constructor, `Default`, or `acquire` runs a migration.
//! Reliar's bookkeeping lives in its own schema's `_migrations` table, never the shared,
//! one-per-database `_sqlx_migrations` sqlx would otherwise write to, so this can be added to a
//! database a host already migrates with its own tooling without either side noticing the other.
use fmt;
use Duration;
use Migrator;
use PgConnection;
use ;
/// The first retry delay [`migrate`]'s lock poll waits between failed `pg_try_advisory_lock`
/// attempts (ADR 0040 amendment A).
const LOCK_POLL_FIRST_RETRY: Duration = from_millis;
/// The retry delay [`migrate`]'s lock poll backs off to and caps at.
const LOCK_POLL_MAX_RETRY: Duration = from_secs;
/// The crate's migrations, embedded at compile time from `migrations/` — the single source of
/// truth (ADR 0018): `cargo publish` packages only files under the crate's own directory, and
/// `sqlx::migrate!` resolves relative to `CARGO_MANIFEST_DIR` at compile time, so the SQL must
/// live here rather than at the repository root.
static MIGRATOR: Migrator = migrate!;
/// Where [`migrate`] creates Reliar's schema and its bookkeeping table.
///
/// ```
/// use reliar_store_postgres::MigrateOptions;
///
/// let options = MigrateOptions::default().schema("orders");
/// assert_eq!(options.schema, "orders");
/// ```
/// [`migrate`]'s failure. **Provider-owned**, not a re-export of `sqlx::migrate::MigrateError`:
/// a rejected schema identifier has no variant in `sqlx`'s own type to
/// report it as, since that check happens before any `sqlx::migrate` code runs at all.
///
/// ```no_run
/// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
/// use reliar_store_postgres::{MigrateOptions, migrate};
///
/// let options = MigrateOptions::default().schema("Not-Lowercase");
/// if let Err(err) = migrate(&pool, options).await {
/// eprintln!("migration failed: {err}");
/// }
/// # Ok(())
/// # }
/// ```
/// Reliar's own advisory-lock key for [`migrate`], derived from `schema` alone (ADR 0040
/// amendment A) — **never** sqlx's own `generate_lock_id` (private, and keyed on the database:
/// sharing it would serialize Reliar's migration behind the host's own migrator, dragging any
/// blocked statement of the host's into `0002`'s `CREATE INDEX CONCURRENTLY` wait set). Schema in
/// the key, not the database, because advisory locks are already per-database and a multi-tenant
/// host migrating several schemas should not run their index builds strictly in series
/// (`PROC_IN_SAFE_IC`, PostgreSQL 14+, this crate's floor is 18, lets two `CONCURRENTLY` builds on
/// different tables proceed without waiting on each other's snapshots).
///
/// FNV-1a 64, spelled out rather than reached for from `std::hash::DefaultHasher` (whose output
/// is explicitly not stable across processes or releases, so it cannot key a value two different
/// connections must agree on).
/// Serializes concurrent [`migrate`] callers on the same `schema` **without** sqlx's own
/// `Migrator::set_locking(true)` (ADR 0040 amendment A): that path takes the lock with a
/// *blocking* `SELECT pg_advisory_lock($1)`, and a blocked statement holds an open snapshot for
/// as long as it waits — which deadlocks against `0002`'s `CREATE INDEX CONCURRENTLY`, itself
/// waiting for every older snapshot to end, the moment a second caller's lock attempt overlaps
/// the first caller's index build. `pg_try_advisory_lock` returns at once whether or not it
/// acquired the lock, so between attempts this connection is genuinely idle: no open statement,
/// no snapshot, nothing for a concurrent index build to wait on.
///
/// **Session-level, not transaction-level** (`pg_try_advisory_lock`, not the `_xact_` variant): a
/// lock tied to a transaction would force one open across the whole run, defeating `0002`'s
/// `-- no-transaction` marker outright.
///
/// The wait is **unbounded**, deliberately: `CREATE INDEX CONCURRENTLY` on a large table can
/// legitimately take minutes, and a deadline short enough to matter would fail exactly the
/// deploy this exists to let through cleanly. A caller that wants a bound wraps the call to
/// [`migrate`] in `tokio::time::timeout` — dropping that future while this is polling releases
/// nothing, because nothing is held.
async
/// Releases [`acquire_migration_lock`]'s lock. Best-effort: called on every path out of
/// [`migrate`] once the lock is held, but its own failure is never allowed to shadow the
/// migration run's result — ending the session (`conn.close()`, right after) releases the lock
/// regardless, and sqlx's own `run_direct` does not unlock on its error path either.
async
/// 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.
///
/// ```no_run
/// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
/// use reliar_store_postgres::{MigrateOptions, migrate};
///
/// migrate(&pool, MigrateOptions::default()).await?;
/// # Ok(())
/// # }
/// ```
///
/// # 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` run outside a transaction
///
/// Four migrations issue `CREATE INDEX CONCURRENTLY` (ADR 0040 §2, ADR 0044 §4), 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:
///
/// ```text
/// ERROR: relation "ix_outbox_claimable" already exists
/// ```
///
/// (or `ix_outbox_id` / `ix_outbox_message_id` / `ix_outbox_dead_cursor`) on the next `migrate()`
/// call means exactly that for the named index. Recover with, against the same schema:
///
/// ```sql
/// DROP INDEX CONCURRENTLY ix_outbox_claimable; -- or ix_outbox_id / ix_outbox_message_id / ix_outbox_dead_cursor
/// ```
///
/// then 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.
///
/// # 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` (see above).
pub async
/// Validates a schema name against PostgreSQL's unquoted-identifier grammar, restricted to
/// **lowercase** (`[a-z_][a-z0-9_$]*`, at most 63 bytes — Postgres's own `NAMEDATALEN` limit)
/// **before** it is ever interpolated into `SET search_path`/`dangerous_set_table_name`, both of
/// which build SQL text from this value rather than binding it as data. The only caller left
/// after ADR 0047 (the stores themselves never validate a schema name — they never see one).
///
/// **Lowercase only, not merely case-insensitive (ADR 0040 §5).** PostgreSQL folds an *unquoted*
/// identifier to lowercase, so `schema = "Foo"` would migrate into a schema literally named
/// `"Foo"` (quoted) while every unqualified reference — the claim, `stats()`, the host's own
/// `search_path` — resolves the unquoted, lowercase-folded `foo` instead: a mismatch this crate
/// cannot detect from inside a single connection's `search_path`, since the host's own connection
/// string or `ALTER ROLE` also has to agree, and cannot be fixed here. Rejecting every uppercase
/// character removes the class of mismatch instead of chasing it through four call sites.