1use std::collections::HashMap;
2use std::fmt;
3
4use sqlx::migrate::{AppliedMigration, Migrate, MigrateError, Migrator};
5
6use crate::DbPool;
7
8pub 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
106pub 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 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 reject_legacy_idempotency_rows(&mut conn).await?;
152 validate_idempotency_cutover_constraints(&mut conn).await
153 }
154 }
155}
156
157#[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
170pub 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#[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 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 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}