Skip to main content

awa_model/
migrations.rs

1use crate::error::AwaError;
2use sqlx::postgres::PgConnection;
3use sqlx::PgPool;
4use tracing::info;
5
6/// Current schema version.
7pub const CURRENT_VERSION: i32 = 40;
8
9/// All migrations in order. SQL lives in `awa-model/migrations/*.sql`
10/// for easy inspection by users who run their own migration tooling.
11///
12/// ## Migration policy
13///
14/// Migrations MUST be **additive only**:
15/// - Add tables, columns (with defaults), indexes, functions
16/// - Never drop columns, change types, or tighten constraints
17///
18/// This ensures running workers are not broken by a schema upgrade.
19/// Representation changes require a release-specific compatibility and
20/// authority-transition procedure.
21const MIGRATIONS: &[(i32, &str, &[&str])] = &[
22    (1, "Canonical schema with UI indexes", &[V1_UP]),
23    (2, "Runtime observability snapshots", &[V2_UP]),
24    (3, "Maintenance loop health in runtime snapshots", &[V3_UP]),
25    (4, "Admin metadata cache tables", &[V4_UP]),
26    (5, "Statement-level admin metadata triggers", &[V5_UP]),
27    (
28        6,
29        "Dirty-key statement triggers for deadlock-free admin metadata",
30        &[V6_UP],
31    ),
32    (
33        7,
34        "Backoff interval creation avoids scientific-notation parse failures",
35        &[V7_UP],
36    ),
37    // v008 is reserved for the dead-letter-queue migration on a parallel
38    // branch; leave the slot open so both PRs can land without renumbering.
39    (9, "Queue and job-kind descriptor catalogs", &[V9_UP]),
40    (
41        10,
42        "Storage transition metadata and canonical compat routing",
43        &[V10_UP],
44    ),
45    (
46        11,
47        "Storage transition self-heal: NULL-safe engine resolution and singleton re-seed",
48        &[V11_UP],
49    ),
50    (
51        12,
52        "Queue storage compatibility layer and active backend selection",
53        &[V12_UP],
54    ),
55    (
56        13,
57        "Storage auto-finalize and queue-storage count maintenance",
58        &[V13_UP],
59    ),
60    (
61        14,
62        "Storage transition role tracking and tightened mixed-transition gate",
63        &[V14_UP],
64    ),
65    (15, "Cron missed-fire policy", &[V15_UP]),
66    (
67        16,
68        "Drop redundant queue_lanes.available_count cache; reader derives from heads",
69        &[V16_UP],
70    ),
71    (
72        17,
73        "Shard queue_enqueue_heads/queue_claim_heads/ready_entries by enqueue_shard",
74        &[V17_UP],
75    ),
76    (
77        18,
78        "Thread ordering_key through insert_job_compat for queue-storage producers",
79        &[V18_UP],
80    ),
81    (
82        19,
83        "Make queue-storage jobs compatibility view shard-aware",
84        &[V19_UP],
85    ),
86    (
87        20,
88        "Derive active queue-storage schema from transition state",
89        &[V20_UP],
90    ),
91    (
92        21,
93        "Shard-aware lane indexes on ready_entries/done_entries/leases",
94        &[V21_UP],
95    ),
96    (
97        22,
98        "delete_job_compat decrements queue_terminal_live_counts for done_entries deletes",
99        &[V22_UP],
100    ),
101    (
102        23,
103        "Install default awa queue-storage substrate via SQL helper",
104        &[V23_UP],
105    ),
106    (
107        24,
108        "Lower fillfactor to 50 on leases and lease_claims partitions",
109        &[V24_UP],
110    ),
111    (
112        25,
113        "Drop idx_<schema>_leases_<slot>_state_hb on all AWA substrates",
114        &[V25_UP],
115    ),
116    (
117        26,
118        "Add paused_at + paused_by to cron_jobs for per-schedule pause",
119        &[V26_UP],
120    ),
121    (
122        27,
123        "Move lane cursors to sequences and stripe terminal counters",
124        &[V23_UP, V22_UP, V27_UP],
125    ),
126    (
127        28,
128        "Add ready_tombstones ledger and compatibility filters (#295)",
129        &[V23_UP, V22_UP, V28_UP],
130    ),
131    (29, "Add durable batch operations control table", &[V29_UP]),
132    (
133        30,
134        "Add terminal-count delta ledger and async rollup",
135        &[V23_UP, V30_UP],
136    ),
137    (
138        31,
139        "Backfill failed done-entry metric indexes for queue storage",
140        &[V31_UP],
141    ),
142    (
143        32,
144        "Add pruned_failed_count to queue_terminal_rollups for the failed terminal retention floor",
145        &[V32_UP],
146    ),
147    (
148        33,
149        "Add per-slot receipt-rescue cursors for queue storage",
150        &[V23_UP, V33_UP],
151    ),
152    (
153        34,
154        "Materialize receipt closures before terminal compatibility deletes",
155        &[V34_UP],
156    ),
157    (
158        35,
159        "Add per-slot receipt deadline-rescue cursors for queue storage",
160        &[V35_UP],
161    ),
162    (
163        36,
164        "Compact successful receipt completions into batch terminal history",
165        &[V18_UP, V23_UP, V36_UP],
166    ),
167    (
168        37,
169        "Add ready_segments claim-routing ledger for queue storage",
170        &[V18_UP, V23_UP, V37_UP],
171    ),
172    (
173        38,
174        "Add compact receipt claim batch ledger for queue storage",
175        &[V23_UP, V38_UP],
176    ),
177    (
178        39,
179        "Refresh claim_ready_runtime to cache-free ready-segment routing",
180        &[V23_UP, V39_UP],
181    ),
182    (
183        40,
184        "Allow queue-storage finalization with live canonical drain-only runtimes",
185        &[V40_UP],
186    ),
187];
188
189const V1_UP: &str = include_str!("../migrations/v001_canonical_schema.sql");
190const V2_UP: &str = include_str!("../migrations/v002_runtime_instances.sql");
191const V3_UP: &str = include_str!("../migrations/v003_maintenance_health.sql");
192const V4_UP: &str = include_str!("../migrations/v004_admin_metadata.sql");
193const V5_UP: &str = include_str!("../migrations/v005_admin_metadata_stmt_triggers.sql");
194const V6_UP: &str = include_str!("../migrations/v006_remove_hot_table_triggers.sql");
195const V7_UP: &str = include_str!("../migrations/v007_backoff_interval_fix.sql");
196const V9_UP: &str = include_str!("../migrations/v009_descriptors.sql");
197const V10_UP: &str = include_str!("../migrations/v010_storage_transition_prep.sql");
198const V11_UP: &str = include_str!("../migrations/v011_storage_transition_self_heal.sql");
199const V12_UP: &str = include_str!("../migrations/v012_queue_storage_compat.sql");
200const V13_UP: &str = include_str!("../migrations/v013_storage_auto_finalize.sql");
201const V14_UP: &str = include_str!("../migrations/v014_storage_transition_role.sql");
202const V15_UP: &str = include_str!("../migrations/v015_cron_missed_fire_policy.sql");
203const V16_UP: &str = include_str!("../migrations/v016_drop_queue_lanes_available_count.sql");
204const V17_UP: &str = include_str!("../migrations/v017_shard_queue_enqueue_heads.sql");
205const V18_UP: &str = include_str!("../migrations/v018_insert_job_compat_ordering_key.sql");
206const V19_UP: &str = include_str!("../migrations/v019_queue_storage_jobs_compat_shard_joins.sql");
207const V20_UP: &str = include_str!("../migrations/v020_active_queue_storage_schema_fallback.sql");
208const V21_UP: &str = include_str!("../migrations/v021_shard_aware_lane_indexes.sql");
209const V22_UP: &str = include_str!("../migrations/v022_delete_compat_terminal_counter.sql");
210const V23_UP: &str = include_str!("../migrations/v023_install_queue_storage_substrate.sql");
211const V24_UP: &str = include_str!("../migrations/v024_receipt_plane_fillfactor.sql");
212const V25_UP: &str = include_str!("../migrations/v025_drop_leases_state_hb_index.sql");
213const V26_UP: &str = include_str!("../migrations/v026_cron_jobs_pause.sql");
214const V27_UP: &str = include_str!("../migrations/v027_sequence_lane_cursors.sql");
215const V28_UP: &str = include_str!("../migrations/v028_ready_tombstones.sql");
216const V29_UP: &str = include_str!("../migrations/v029_batch_operations.sql");
217const V30_UP: &str = include_str!("../migrations/v030_terminal_count_deltas.sql");
218const V31_UP: &str = include_str!("../migrations/v031_queue_storage_failed_done_indexes.sql");
219const V32_UP: &str = include_str!("../migrations/v032_failed_terminal_retention.sql");
220const V33_UP: &str = include_str!("../migrations/v033_receipt_rescue_cursors.sql");
221const V34_UP: &str = include_str!("../migrations/v034_receipt_terminal_delete_closures.sql");
222const V35_UP: &str = include_str!("../migrations/v035_receipt_deadline_rescue_cursors.sql");
223const V36_UP: &str = include_str!("../migrations/v036_compact_receipt_completions.sql");
224const V37_UP: &str = include_str!("../migrations/v037_ready_segments.sql");
225const V38_UP: &str = include_str!("../migrations/v038_compact_claim_batches.sql");
226const V39_UP: &str = include_str!("../migrations/v039_claim_head_cold_routing.sql");
227const V40_UP: &str = include_str!("../migrations/v040_finalize_with_drain_runtimes.sql");
228
229/// Old version numbers from pre-0.4 releases that used V3/V4/V5 numbering.
230/// Also tolerates the unreleased inline-V6 branch numbering used during review.
231/// Maps old max version → equivalent new version.
232fn normalize_legacy_version(old_version: i32) -> i32 {
233    match old_version {
234        v if v >= 6 => 4, // legacy/unreleased V6 admin metadata = V4 (new)
235        5 => 3,           // V5 (0.3.x) = V3 (new)
236        4 => 2,           // V4 = V2 (new)
237        3 => 1,           // V3 = V1 (new)
238        _ => 0,           // Pre-canonical or fresh
239    }
240}
241
242/// Run all pending migrations against the database.
243///
244/// Applies only migrations newer than the current schema version.
245/// V1 bootstraps the canonical schema from scratch; V2+ are incremental
246/// and use `IF NOT EXISTS` guards so they are safe to re-run.
247///
248/// by replacing the legacy `schema_version` rows with the new numbering.
249///
250/// Takes `&PgPool` for ergonomic use from Rust.
251pub async fn run(pool: &PgPool) -> Result<(), AwaError> {
252    let lock_key: i64 = 0x4157_415f_4d49_4752; // "AWA_MIGR"
253    let mut conn = pool.acquire().await?;
254    sqlx::query("SELECT pg_advisory_lock($1)")
255        .bind(lock_key)
256        .execute(&mut *conn)
257        .await?;
258
259    let result = run_inner(&mut conn).await;
260
261    let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
262        .bind(lock_key)
263        .execute(&mut *conn)
264        .await;
265
266    result
267}
268
269async fn run_inner(conn: &mut PgConnection) -> Result<(), AwaError> {
270    let has_schema: bool =
271        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
272            .fetch_one(&mut *conn)
273            .await?;
274
275    let current = if has_schema {
276        current_version_conn(conn).await?
277    } else {
278        0
279    };
280
281    if current > CURRENT_VERSION {
282        info!(
283            schema_version = current,
284            supported_schema_version = CURRENT_VERSION,
285            "Schema is newer but forward-compatible; no migrations to apply"
286        );
287    } else if !(has_schema && current == CURRENT_VERSION) {
288        for &(version, description, steps) in MIGRATIONS {
289            if version <= current {
290                continue;
291            }
292            info!(version, description, "Applying migration");
293            for step in steps {
294                sqlx::raw_sql(step).execute(&mut *conn).await?;
295            }
296            info!(version, "Migration applied");
297        }
298    } else {
299        info!(version = current, "Schema is up to date");
300    }
301
302    // Ensure the admin metadata cache is warm. Since v006 removed the
303    // synchronous triggers on jobs_hot, the cache is only updated by the
304    // maintenance leader. Refreshing here guarantees queue_stats() and
305    // state_counts() return accurate data immediately after migrate().
306    let has_refresh: bool = sqlx::query_scalar(
307        "SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'refresh_admin_metadata' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'awa'))",
308    )
309    .fetch_one(&mut *conn)
310    .await?;
311    if has_refresh {
312        // Best-effort cache warmup. Uses a short statement timeout to avoid
313        // blocking if a previous runtime's maintenance leader is still
314        // holding the cache advisory lock during a slow shutdown.
315        // Wrapped in an explicit transaction because SET LOCAL is only
316        // effective inside a transaction block (not in autocommit mode).
317        let _ = sqlx::raw_sql(
318            "BEGIN; SET LOCAL statement_timeout = '5s'; SELECT awa.refresh_admin_metadata(); COMMIT;",
319        )
320        .execute(&mut *conn)
321        .await;
322    }
323
324    Ok(())
325}
326
327/// Get the current schema version.
328pub async fn current_version(pool: &PgPool) -> Result<i32, AwaError> {
329    let mut conn = pool.acquire().await?;
330    current_version_conn(&mut conn).await
331}
332
333async fn current_version_conn(conn: &mut PgConnection) -> Result<i32, AwaError> {
334    let has_schema: bool =
335        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
336            .fetch_one(&mut *conn)
337            .await?;
338
339    if !has_schema {
340        return Ok(0);
341    }
342
343    let has_table: bool = sqlx::query_scalar(
344        "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'schema_version')",
345    )
346    .fetch_one(&mut *conn)
347    .await?;
348
349    if !has_table {
350        return Ok(0);
351    }
352
353    let version: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM awa.schema_version")
354        .fetch_one(&mut *conn)
355        .await?;
356
357    let raw_version = version.unwrap_or(0);
358
359    if raw_version > CURRENT_VERSION {
360        if forward_compatible_v043_columns(conn, raw_version).await? {
361            return Ok(raw_version);
362        }
363        tracing::error!(
364            schema_version = raw_version,
365            supported_schema_version = CURRENT_VERSION,
366            "This Awa binary is too old for the database schema; upgrade Awa before retrying"
367        );
368        return Err(AwaError::SchemaNotMigrated {
369            expected: CURRENT_VERSION,
370            found: raw_version,
371        });
372    }
373
374    // If max version is within the current MIGRATIONS range and the expected
375    // tables exist, this is a current install — skip legacy detection.
376    if (1..=CURRENT_VERSION).contains(&raw_version) {
377        // Quick check: does the schema match what we expect at this version?
378        // If queue_state_counts exists, we're past v4 in the current numbering.
379        let has_admin_tables: bool = sqlx::query_scalar(
380            "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
381        )
382        .fetch_one(&mut *conn)
383        .await
384        .unwrap_or(false);
385
386        // Current v4+ has queue_state_counts. If we're at v4+ and have
387        // the table, this is definitely a current install.
388        if raw_version >= 4 && has_admin_tables {
389            return Ok(raw_version);
390        }
391        // Current v1-v3 don't have queue_state_counts.
392        if raw_version <= 3 {
393            let has_runtime: bool = sqlx::query_scalar(
394                "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
395            )
396            .fetch_one(&mut *conn)
397            .await
398            .unwrap_or(false);
399            // v2+ has runtime_instances. If present, current install.
400            if (raw_version >= 2 && has_runtime) || raw_version == 1 {
401                return Ok(raw_version);
402            }
403        }
404    }
405
406    // Detect legacy version numbering from pre-0.4 releases.
407    // Legacy installs used a different numbering scheme where v3-v6 mapped
408    // to what is now v1-v4.
409    let has_legacy_high: bool =
410        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM awa.schema_version WHERE version >= 6)")
411            .fetch_one(&mut *conn)
412            .await
413            .unwrap_or(false);
414
415    let has_admin_metadata: bool = sqlx::query_scalar(
416        "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
417    )
418    .fetch_one(&mut *conn)
419    .await
420    .unwrap_or(false);
421
422    let is_legacy_v5_only = raw_version == 5 && !has_legacy_high && !has_admin_metadata;
423    let is_legacy_v4_only = raw_version == 4 && !has_legacy_high && !has_admin_metadata;
424
425    // Also detect a single legacy V3 row (0.3.0 with only canonical schema)
426    // by checking if runtime_instances exists — if not, this is legacy V3.
427    let is_legacy_v3_only = raw_version == 3
428        && !has_legacy_high
429        && {
430            let has_runtime: bool = sqlx::query_scalar(
431            "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
432        )
433        .fetch_one(&mut *conn)
434        .await
435        .unwrap_or(false);
436            !has_runtime
437        };
438
439    if has_legacy_high || is_legacy_v5_only || is_legacy_v4_only || is_legacy_v3_only {
440        let normalized = normalize_legacy_version(raw_version);
441        info!(
442            old_version = raw_version,
443            new_version = normalized,
444            "Normalizing legacy version numbering"
445        );
446        // Replace legacy rows so future calls return the new numbering.
447        sqlx::query("DELETE FROM awa.schema_version WHERE version >= 3")
448            .execute(&mut *conn)
449            .await?;
450        for &(v, desc, _) in MIGRATIONS {
451            if v <= normalized {
452                sqlx::query(
453                    "INSERT INTO awa.schema_version (version, description) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
454                )
455                .bind(v)
456                .bind(desc)
457                .execute(&mut *conn)
458                .await?;
459            }
460        }
461        return Ok(normalized);
462    }
463
464    Ok(raw_version)
465}
466
467/// The 0.6.2 rolling-upgrade stepping-stone recognizes exactly the additive
468/// v043 shape, and only before any queue-storage schema has flipped to ledger
469/// authority. This lets an already-rolled 0.6.2 fleet survive a migration-first
470/// 0.7 rollout. Unknown future schemas and the post-flip boundary fail closed.
471async fn forward_compatible_v043_columns(
472    conn: &mut PgConnection,
473    raw_version: i32,
474) -> Result<bool, AwaError> {
475    if raw_version != 43 {
476        return Ok(false);
477    }
478
479    // quote_ident is computed by PostgreSQL before the identifier is used in
480    // the dynamic query below. Queue-storage schema names are validated when
481    // installed, but retaining quoting here keeps this probe safe for catalogs
482    // created by external migration tooling too.
483    let schemas: Vec<String> = sqlx::query_scalar(
484        "SELECT quote_ident(n.nspname) \
485         FROM pg_namespace AS n \
486         JOIN pg_class AS c ON c.relnamespace = n.oid \
487         WHERE c.relname = 'ring_cursor_authority' \
488           AND c.relkind IN ('r', 'p') \
489         ORDER BY n.nspname",
490    )
491    .fetch_all(&mut *conn)
492    .await?;
493
494    if schemas.is_empty() {
495        return Ok(false);
496    }
497
498    for schema in schemas {
499        let authority: Option<String> = sqlx::query_scalar(&format!(
500            "SELECT authority FROM {schema}.ring_cursor_authority WHERE singleton"
501        ))
502        .fetch_optional(&mut *conn)
503        .await?;
504        if authority.as_deref() != Some("columns") {
505            return Ok(false);
506        }
507    }
508
509    Ok(true)
510}
511
512/// Get the raw SQL for all migrations (for extraction / external tooling).
513pub fn migration_sql() -> Vec<(i32, &'static str, String)> {
514    MIGRATIONS
515        .iter()
516        .map(|&(v, d, steps)| (v, d, steps.join("\n")))
517        .collect()
518}
519
520/// Get migration SQL for a version range `(from, to]` — `from` is exclusive,
521/// `to` is inclusive. Returns only migrations where `from < version <= to`.
522pub fn migration_sql_range(from: i32, to: i32) -> Vec<(i32, &'static str, String)> {
523    MIGRATIONS
524        .iter()
525        .filter(|&&(v, _, _)| v > from && v <= to)
526        .map(|&(v, d, steps)| (v, d, steps.join("\n")))
527        .collect()
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn migration_sql_range_all() {
536        let all = migration_sql_range(0, CURRENT_VERSION);
537        assert_eq!(all.len(), MIGRATIONS.len());
538        assert_eq!(all.first().unwrap().0, 1);
539        assert_eq!(all.last().unwrap().0, CURRENT_VERSION);
540    }
541
542    #[test]
543    fn migration_sql_range_subset() {
544        let subset = migration_sql_range(2, CURRENT_VERSION);
545        assert!(subset.iter().all(|(v, _, _)| *v > 2));
546        let expected = MIGRATIONS.iter().filter(|&&(v, _, _)| v > 2).count();
547        assert_eq!(subset.len(), expected);
548    }
549
550    #[test]
551    fn migration_sql_range_single() {
552        let single = migration_sql_range(2, 3);
553        assert_eq!(single.len(), 1);
554        assert_eq!(single[0].0, 3);
555        assert!(!single[0].2.is_empty());
556    }
557
558    #[test]
559    fn migration_sql_range_empty_when_equal() {
560        let empty = migration_sql_range(CURRENT_VERSION, CURRENT_VERSION);
561        assert!(empty.is_empty());
562    }
563
564    #[test]
565    fn migration_sql_range_empty_when_inverted() {
566        let empty = migration_sql_range(3, 1);
567        assert!(empty.is_empty());
568    }
569
570    #[test]
571    fn migration_sql_range_matches_full() {
572        let full = migration_sql();
573        let ranged = migration_sql_range(0, CURRENT_VERSION);
574        assert_eq!(full.len(), ranged.len());
575        for (f, r) in full.iter().zip(ranged.iter()) {
576            assert_eq!(f.0, r.0);
577            assert_eq!(f.1, r.1);
578            assert_eq!(f.2, r.2);
579        }
580    }
581}