Skip to main content

runledger_postgres/
migrations.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use sqlx::migrate::{AppliedMigration, Migrate, MigrateError, Migrator};
5
6use crate::DbPool;
7
8/// Raw SQLx migrator for inspecting the migrations bundled with this crate
9/// version.
10///
11/// Iterating this value to inspect bundled versions and checksums is supported.
12/// Calling [`Migrator::run`] or [`Migrator::undo`] on it with a shared
13/// application pool is not. SQLx rejects applied versions absent from the exact
14/// bundle, and PostgreSQL migration locks are session-scoped; SQLx can return
15/// from a validation error before unlocking and put the still-locked session
16/// back into the pool.
17///
18/// Use [`migrate_after_idempotency_cutover`] to apply Runledger migrations, or
19/// [`ensure_schema_compatible_after_idempotency_cutover`] when DDL is managed
20/// externally. If a compatibility diagnostic intentionally executes a raw
21/// migrator that may mismatch history, give it a disposable connection or
22/// single-use pool and close that connection or pool on every error path.
23///
24/// During an additive compatibility window, an exact older binary that cannot
25/// use the filtered API must be patched before startup or explicitly accept the
26/// data-loss boundary of reverting newer migrations. Reverting the 0.8
27/// migrations erases workflow-recovery lineage/idempotency, active claims,
28/// execution-resource keys and claims, retry audit fields, and workflow-step
29/// continuation opt-ins. Reverting the post-v0.6 successful-replay migration
30/// also erases relational replay lineage and replay-request idempotency state
31/// while retaining the underlying replay-created queue rows.
32pub static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
33
34type PgPoolConnection = sqlx::pool::PoolConnection<sqlx::Postgres>;
35type RunledgerMigrationMap = HashMap<i64, &'static sqlx::migrate::Migration>;
36
37#[derive(Debug)]
38#[non_exhaustive]
39pub enum SchemaCompatibilityError {
40    Query(sqlx::Error),
41    MissingMigrationHistory {
42        required_first_migration_version: i64,
43    },
44    LegacyIdempotencySnapshotsMissing {
45        job_count: i64,
46        workflow_count: i64,
47    },
48    Incompatible(MigrateError),
49    MigrationUnlock(MigrateError),
50}
51
52impl fmt::Display for SchemaCompatibilityError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::Query(error) => write!(
56                f,
57                "Runledger schema compatibility check could not query PostgreSQL state: {error}"
58            ),
59            Self::MissingMigrationHistory {
60                required_first_migration_version,
61            } => write!(
62                f,
63                "Runledger schema compatibility check requires the _sqlx_migrations table; apply or record Runledger migrations first (expected migration history starting at version {required_first_migration_version})"
64            ),
65            Self::LegacyIdempotencySnapshotsMissing {
66                job_count,
67                workflow_count,
68            } => write!(
69                f,
70                "Runledger idempotency cutover requires enqueue_request snapshots for all keyed rows; found {job_count} legacy job rows and {workflow_count} legacy workflow rows"
71            ),
72            Self::Incompatible(error) => write!(f, "{error}"),
73            Self::MigrationUnlock(error) => {
74                write!(
75                    f,
76                    "Runledger schema migration lock could not be released: {error}"
77                )
78            }
79        }
80    }
81}
82
83impl std::error::Error for SchemaCompatibilityError {
84    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85        match self {
86            Self::Query(error) => Some(error),
87            Self::MissingMigrationHistory { .. } => None,
88            Self::LegacyIdempotencySnapshotsMissing { .. } => None,
89            Self::Incompatible(error) | Self::MigrationUnlock(error) => Some(error),
90        }
91    }
92}
93
94impl From<MigrateError> for SchemaCompatibilityError {
95    fn from(error: MigrateError) -> Self {
96        Self::Incompatible(error)
97    }
98}
99
100impl From<sqlx::Error> for SchemaCompatibilityError {
101    fn from(error: sqlx::Error) -> Self {
102        Self::Query(error)
103    }
104}
105
106/// Apply the bundled Runledger schema migrations to a PostgreSQL pool, then
107/// enforce the idempotency snapshot cutover.
108///
109/// This is intentionally named as a hard-cutover API. Downstream applications
110/// upgrading from older Runledger versions must update their startup code and
111/// verify no keyed legacy rows remain without `enqueue_request` snapshots.
112/// Unlike raw [`MIGRATOR`] execution, this filters shared SQLx history through
113/// Runledger's migration compatibility fence so declared additive migrations
114/// can coexist with older compatible startup code.
115pub async fn migrate_after_idempotency_cutover(
116    pool: &DbPool,
117) -> Result<(), SchemaCompatibilityError> {
118    let mut conn = pool.acquire().await?;
119
120    if MIGRATOR.locking {
121        // PostgreSQL advisory migration locks are session-scoped; never return
122        // a possibly locked session to the pool if this future is cancelled.
123        conn.close_on_drop();
124        (*conn)
125            .lock()
126            .await
127            .map_err(SchemaCompatibilityError::Incompatible)?;
128    }
129
130    let result = run_migrations_with_filtered_history(&mut conn).await;
131    let unlock_result = if MIGRATOR.locking {
132        (*conn).unlock().await
133    } else {
134        Ok(())
135    };
136
137    match (result, unlock_result) {
138        (Err(migration_error), Err(unlock_error)) => {
139            tracing::error!(
140                error = %unlock_error,
141                "failed to unlock migration lock after migration failure"
142            );
143            Err(SchemaCompatibilityError::Incompatible(migration_error))
144        }
145        (Err(error), Ok(())) => Err(SchemaCompatibilityError::Incompatible(error)),
146        (Ok(()), Err(error)) => Err(SchemaCompatibilityError::MigrationUnlock(error)),
147        (Ok(()), Ok(())) => {
148            // The DDL migration lock is no longer needed here: the NOT VALID
149            // cutover constraints already block new violating rows, and
150            // validation is idempotent if another startup validates first.
151            reject_legacy_idempotency_rows(&mut conn).await?;
152            validate_idempotency_cutover_constraints(&mut conn).await
153        }
154    }
155}
156
157/// Apply the bundled Runledger schema migrations to a PostgreSQL pool.
158///
159/// Deprecated compatibility alias for [`migrate_after_idempotency_cutover`].
160/// The current migration set enforces the enqueue request snapshot cutover, so
161/// this function has the same strict behavior as the new explicit API.
162#[deprecated(
163    since = "0.1.2",
164    note = "use migrate_after_idempotency_cutover to make the enqueue request snapshot cutover explicit"
165)]
166pub async fn migrate(pool: &DbPool) -> Result<(), SchemaCompatibilityError> {
167    migrate_after_idempotency_cutover(pool).await
168}
169
170/// Validate that the target database's SQLx migration history matches the
171/// bundled Runledger migrations.
172///
173/// Unlike [`migrate_after_idempotency_cutover`], this does not apply pending
174/// migrations. It is intended
175/// for deployments that manage DDL outside the application process but still
176/// want a startup guardrail. This check is read-only, but it relies on the
177/// `_sqlx_migrations` history table being present and up to date. When present,
178/// it also uses Runledger's own `runledger_migration_history` compatibility
179/// fence to detect newer releases whose schema is not declared backward
180/// compatible. Additive migrations may deliberately rely only on SQLx history
181/// so older guards can coexist during expand-first rollout.
182/// This differs from invoking raw [`MIGRATOR`] execution, which rejects any
183/// applied migration version absent from that exact binary's bundle.
184///
185/// This read-only path does not validate `NOT VALID` cutover constraints after
186/// legacy rows are remediated. Deployments that apply DDL externally can run
187/// PostgreSQL `VALIDATE CONSTRAINT` for the idempotency cutover constraints
188/// after this check passes, or use [`migrate_after_idempotency_cutover`] to let
189/// Runledger do that promotion.
190pub async fn ensure_schema_compatible_after_idempotency_cutover(
191    pool: &DbPool,
192) -> Result<(), SchemaCompatibilityError> {
193    let mut conn = pool.acquire().await?;
194
195    if !has_migrations_table(&mut conn).await? {
196        return Err(SchemaCompatibilityError::MissingMigrationHistory {
197            required_first_migration_version: first_up_migration_version(),
198        });
199    }
200
201    let expected_migrations = expected_runledger_migrations();
202    let history = list_migration_history(&mut conn).await?;
203
204    if let Some(version) = first_conflicting_runledger_version(&history, &expected_migrations) {
205        return Err(SchemaCompatibilityError::Incompatible(
206            MigrateError::VersionMismatch(version),
207        ));
208    }
209
210    if let Some(version) = first_dirty_runledger_version(&history, &expected_migrations) {
211        return Err(SchemaCompatibilityError::Incompatible(MigrateError::Dirty(
212            version,
213        )));
214    }
215
216    if has_runledger_migration_history_table(&mut conn).await? {
217        let recorded_versions = list_recorded_runledger_migrations(&mut conn).await?;
218        if let Some(version) =
219            first_missing_runledger_version(&recorded_versions, &expected_migrations)
220        {
221            return Err(SchemaCompatibilityError::Incompatible(
222                MigrateError::VersionMissing(version),
223            ));
224        }
225    }
226
227    let applied = applied_runledger_migrations(&history, &expected_migrations);
228    let applied_by_version: HashMap<_, _> = applied
229        .iter()
230        .map(|applied_migration| (applied_migration.version, applied_migration))
231        .collect();
232    let latest_applied_version = applied.iter().map(|migration| migration.version).max();
233
234    for migration in MIGRATOR
235        .iter()
236        .filter(|migration| migration.migration_type.is_up_migration())
237    {
238        match applied_by_version.get(&migration.version) {
239            Some(applied_migration) => {
240                validate_checksum(migration.version, applied_migration, migration)
241                    .map_err(SchemaCompatibilityError::from)?
242            }
243            None => {
244                return Err(SchemaCompatibilityError::Incompatible(
245                    MigrateError::VersionTooNew(
246                        migration.version,
247                        latest_applied_version.unwrap_or_default(),
248                    ),
249                ));
250            }
251        }
252    }
253
254    reject_legacy_idempotency_rows(&mut conn).await
255}
256
257/// Validate that the target database's SQLx migration history matches the
258/// bundled Runledger migrations.
259///
260/// Deprecated compatibility alias for
261/// [`ensure_schema_compatible_after_idempotency_cutover`]. The current schema
262/// compatibility check rejects keyed legacy rows without enqueue request
263/// snapshots, matching the stricter cutover API.
264#[deprecated(
265    since = "0.1.2",
266    note = "use ensure_schema_compatible_after_idempotency_cutover to make the enqueue request snapshot cutover explicit"
267)]
268pub async fn ensure_schema_compatible(pool: &DbPool) -> Result<(), SchemaCompatibilityError> {
269    ensure_schema_compatible_after_idempotency_cutover(pool).await
270}
271
272async fn has_migrations_table(conn: &mut PgPoolConnection) -> Result<bool, sqlx::Error> {
273    sqlx::query_scalar::<_, bool>("SELECT to_regclass('_sqlx_migrations') IS NOT NULL")
274        .fetch_one(&mut **conn)
275        .await
276}
277
278async fn has_runledger_migration_history_table(
279    conn: &mut PgPoolConnection,
280) -> Result<bool, sqlx::Error> {
281    sqlx::query_scalar::<_, bool>("SELECT to_regclass('runledger_migration_history') IS NOT NULL")
282        .fetch_one(&mut **conn)
283        .await
284}
285
286async fn list_migration_history(
287    conn: &mut PgPoolConnection,
288) -> Result<Vec<MigrationHistoryRow>, sqlx::Error> {
289    sqlx::query_as::<_, MigrationHistoryRow>(
290        "SELECT version, checksum, success
291         FROM _sqlx_migrations
292         ORDER BY version",
293    )
294    .fetch_all(&mut **conn)
295    .await
296}
297
298async fn list_recorded_runledger_migrations(
299    conn: &mut PgPoolConnection,
300) -> Result<Vec<i64>, sqlx::Error> {
301    sqlx::query_scalar::<_, i64>(
302        "SELECT version
303         FROM runledger_migration_history
304         ORDER BY version",
305    )
306    .fetch_all(&mut **conn)
307    .await
308}
309
310async fn reject_legacy_idempotency_rows(
311    conn: &mut PgPoolConnection,
312) -> Result<(), SchemaCompatibilityError> {
313    if idempotency_cutover_constraints_valid(conn).await? {
314        return Ok(());
315    }
316
317    let row = sqlx::query!(
318        r#"SELECT
319            (
320                SELECT COUNT(*)::bigint
321                FROM job_queue
322                WHERE idempotency_key IS NOT NULL
323                  AND enqueue_request IS NULL
324            ) AS "job_count!",
325            (
326                SELECT COUNT(*)::bigint
327                FROM workflow_runs
328                WHERE idempotency_key IS NOT NULL
329                  AND enqueue_request IS NULL
330            ) AS "workflow_count!""#,
331    )
332    .fetch_one(&mut **conn)
333    .await?;
334
335    if row.job_count == 0 && row.workflow_count == 0 {
336        return Ok(());
337    }
338
339    Err(
340        SchemaCompatibilityError::LegacyIdempotencySnapshotsMissing {
341            job_count: row.job_count,
342            workflow_count: row.workflow_count,
343        },
344    )
345}
346
347async fn validate_idempotency_cutover_constraints(
348    conn: &mut PgPoolConnection,
349) -> Result<(), SchemaCompatibilityError> {
350    if idempotency_cutover_constraints_valid(conn).await? {
351        return Ok(());
352    }
353
354    // PostgreSQL validates each table constraint independently. If one
355    // validation succeeds and the other fails, the next startup skips the valid
356    // constraint and retries the remaining one.
357    sqlx::query(
358        "ALTER TABLE job_queue
359         VALIDATE CONSTRAINT ck_job_queue_idempotency_enqueue_request",
360    )
361    .execute(&mut **conn)
362    .await
363    .map_err(|error| {
364        tracing::warn!(
365            error = %error,
366            "failed to validate job_queue idempotency cutover constraint"
367        );
368        SchemaCompatibilityError::Query(error)
369    })?;
370
371    sqlx::query(
372        "ALTER TABLE workflow_runs
373         VALIDATE CONSTRAINT ck_workflow_runs_idempotency_enqueue_request",
374    )
375    .execute(&mut **conn)
376    .await
377    .map_err(|error| {
378        tracing::warn!(
379            error = %error,
380            "failed to validate workflow_runs idempotency cutover constraint"
381        );
382        SchemaCompatibilityError::Query(error)
383    })?;
384
385    Ok(())
386}
387
388async fn idempotency_cutover_constraints_valid(
389    conn: &mut PgPoolConnection,
390) -> Result<bool, sqlx::Error> {
391    // A validated cutover constraint is the durable proof that legacy keyed rows
392    // without enqueue_request snapshots cannot exist for that table. If future
393    // migrations replace these constraints, they must preserve that invariant
394    // before this short-circuit remains valid.
395    sqlx::query_scalar::<_, bool>(
396        "SELECT COUNT(*) FILTER (WHERE c.convalidated) = 2
397         FROM pg_constraint c
398         JOIN pg_class t ON t.oid = c.conrelid
399         WHERE (t.relname, c.conname) IN (
400             ('job_queue', 'ck_job_queue_idempotency_enqueue_request'),
401             ('workflow_runs', 'ck_workflow_runs_idempotency_enqueue_request')
402         )",
403    )
404    .fetch_one(&mut **conn)
405    .await
406}
407
408fn first_up_migration_version() -> i64 {
409    MIGRATOR
410        .iter()
411        .find(|migration| migration.migration_type.is_up_migration())
412        .map(|migration| migration.version)
413        .unwrap_or_default()
414}
415
416fn expected_runledger_migrations() -> RunledgerMigrationMap {
417    MIGRATOR
418        .iter()
419        .filter(|migration| migration.migration_type.is_up_migration())
420        .map(|migration| (migration.version, migration))
421        .collect()
422}
423
424fn first_conflicting_runledger_version(
425    history: &[MigrationHistoryRow],
426    expected_migrations: &RunledgerMigrationMap,
427) -> Option<i64> {
428    history.iter().find_map(|row| {
429        expected_migrations
430            .get(&row.version)
431            .filter(|migration| row.checksum.as_slice() != migration.checksum.as_ref())
432            .map(|_| row.version)
433    })
434}
435
436fn first_dirty_runledger_version(
437    history: &[MigrationHistoryRow],
438    expected_migrations: &RunledgerMigrationMap,
439) -> Option<i64> {
440    history.iter().filter(|row| !row.success).find_map(|row| {
441        expected_migrations
442            .get(&row.version)
443            .filter(|migration| row.checksum.as_slice() == migration.checksum.as_ref())
444            .map(|_| row.version)
445    })
446}
447
448fn first_missing_runledger_version(
449    recorded_versions: &[i64],
450    expected_migrations: &RunledgerMigrationMap,
451) -> Option<i64> {
452    recorded_versions
453        .iter()
454        .copied()
455        .find(|version| !expected_migrations.contains_key(version))
456}
457
458fn applied_runledger_migrations(
459    history: &[MigrationHistoryRow],
460    expected_migrations: &RunledgerMigrationMap,
461) -> Vec<AppliedMigration> {
462    history
463        .iter()
464        .filter(|row| row.success)
465        .filter(|row| {
466            expected_migrations
467                .get(&row.version)
468                .is_some_and(|migration| row.checksum.as_slice() == migration.checksum.as_ref())
469        })
470        .map(|row| AppliedMigration {
471            version: row.version,
472            checksum: row.checksum.clone().into(),
473        })
474        .collect()
475}
476
477async fn run_migrations_with_filtered_history(
478    conn: &mut PgPoolConnection,
479) -> Result<(), MigrateError> {
480    (**conn).ensure_migrations_table().await?;
481
482    let expected_migrations = expected_runledger_migrations();
483    let history = list_migration_history(conn).await?;
484
485    if let Some(version) = first_conflicting_runledger_version(&history, &expected_migrations) {
486        return Err(MigrateError::VersionMismatch(version));
487    }
488
489    if let Some(version) = first_dirty_runledger_version(&history, &expected_migrations) {
490        return Err(MigrateError::Dirty(version));
491    }
492
493    if has_runledger_migration_history_table(conn).await? {
494        let recorded_versions = list_recorded_runledger_migrations(conn).await?;
495        if let Some(version) =
496            first_missing_runledger_version(&recorded_versions, &expected_migrations)
497        {
498            return Err(MigrateError::VersionMissing(version));
499        }
500    }
501
502    let applied = applied_runledger_migrations(&history, &expected_migrations);
503    let applied_by_version: HashMap<_, _> = applied
504        .into_iter()
505        .map(|migration| (migration.version, migration))
506        .collect();
507
508    for migration in MIGRATOR
509        .iter()
510        .filter(|migration| migration.migration_type.is_up_migration())
511    {
512        match applied_by_version.get(&migration.version) {
513            Some(applied_migration) => {
514                validate_checksum(migration.version, applied_migration, migration)?
515            }
516            None => {
517                (**conn).apply(migration).await?;
518            }
519        }
520    }
521
522    Ok(())
523}
524
525#[derive(sqlx::FromRow)]
526struct MigrationHistoryRow {
527    version: i64,
528    checksum: Vec<u8>,
529    success: bool,
530}
531
532fn validate_checksum(
533    version: i64,
534    applied_migration: &AppliedMigration,
535    expected_migration: &sqlx::migrate::Migration,
536) -> Result<(), MigrateError> {
537    if applied_migration.checksum != expected_migration.checksum {
538        return Err(MigrateError::VersionMismatch(version));
539    }
540
541    Ok(())
542}