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 = 11;
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/// For breaking schema changes, bump the major version and document
20/// the required stop-the-world upgrade 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
52const V1_UP: &str = include_str!("../migrations/v001_canonical_schema.sql");
53const V2_UP: &str = include_str!("../migrations/v002_runtime_instances.sql");
54const V3_UP: &str = include_str!("../migrations/v003_maintenance_health.sql");
55const V4_UP: &str = include_str!("../migrations/v004_admin_metadata.sql");
56const V5_UP: &str = include_str!("../migrations/v005_admin_metadata_stmt_triggers.sql");
57const V6_UP: &str = include_str!("../migrations/v006_remove_hot_table_triggers.sql");
58const V7_UP: &str = include_str!("../migrations/v007_backoff_interval_fix.sql");
59const V9_UP: &str = include_str!("../migrations/v009_descriptors.sql");
60const V10_UP: &str = include_str!("../migrations/v010_storage_transition_prep.sql");
61const V11_UP: &str = include_str!("../migrations/v011_storage_transition_self_heal.sql");
62
63/// Old version numbers from pre-0.4 releases that used V3/V4/V5 numbering.
64/// Also tolerates the unreleased inline-V6 branch numbering used during review.
65/// Maps old max version → equivalent new version.
66fn normalize_legacy_version(old_version: i32) -> i32 {
67    match old_version {
68        v if v >= 6 => 4, // legacy/unreleased V6 admin metadata = V4 (new)
69        5 => 3,           // V5 (0.3.x) = V3 (new)
70        4 => 2,           // V4 = V2 (new)
71        3 => 1,           // V3 = V1 (new)
72        _ => 0,           // Pre-canonical or fresh
73    }
74}
75
76/// Run all pending migrations against the database.
77///
78/// Applies only migrations newer than the current schema version.
79/// V1 bootstraps the canonical schema from scratch; V2+ are incremental
80/// and use `IF NOT EXISTS` guards so they are safe to re-run.
81///
82/// by replacing the legacy `schema_version` rows with the new numbering.
83///
84/// Takes `&PgPool` for ergonomic use from Rust.
85pub async fn run(pool: &PgPool) -> Result<(), AwaError> {
86    let lock_key: i64 = 0x4157_415f_4d49_4752; // "AWA_MIGR"
87    let mut conn = pool.acquire().await?;
88    sqlx::query("SELECT pg_advisory_lock($1)")
89        .bind(lock_key)
90        .execute(&mut *conn)
91        .await?;
92
93    let result = run_inner(&mut conn).await;
94
95    let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
96        .bind(lock_key)
97        .execute(&mut *conn)
98        .await;
99
100    result
101}
102
103async fn run_inner(conn: &mut PgConnection) -> Result<(), AwaError> {
104    let has_schema: bool =
105        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
106            .fetch_one(&mut *conn)
107            .await?;
108
109    let current = if has_schema {
110        current_version_conn(conn).await?
111    } else {
112        0
113    };
114
115    if !(has_schema && current == CURRENT_VERSION) {
116        for &(version, description, steps) in MIGRATIONS {
117            if version <= current {
118                continue;
119            }
120            info!(version, description, "Applying migration");
121            for step in steps {
122                sqlx::raw_sql(step).execute(&mut *conn).await?;
123            }
124            info!(version, "Migration applied");
125        }
126    } else {
127        info!(version = current, "Schema is up to date");
128    }
129
130    // Ensure the admin metadata cache is warm. Since v006 removed the
131    // synchronous triggers on jobs_hot, the cache is only updated by the
132    // maintenance leader. Refreshing here guarantees queue_stats() and
133    // state_counts() return accurate data immediately after migrate().
134    let has_refresh: bool = sqlx::query_scalar(
135        "SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'refresh_admin_metadata' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'awa'))",
136    )
137    .fetch_one(&mut *conn)
138    .await?;
139    if has_refresh {
140        // Best-effort cache warmup. Uses a short statement timeout to avoid
141        // blocking if a previous runtime's maintenance leader is still
142        // holding the cache advisory lock during a slow shutdown.
143        // Wrapped in an explicit transaction because SET LOCAL is only
144        // effective inside a transaction block (not in autocommit mode).
145        let _ = sqlx::raw_sql(
146            "BEGIN; SET LOCAL statement_timeout = '5s'; SELECT awa.refresh_admin_metadata(); COMMIT;",
147        )
148        .execute(&mut *conn)
149        .await;
150    }
151
152    Ok(())
153}
154
155/// Get the current schema version.
156pub async fn current_version(pool: &PgPool) -> Result<i32, AwaError> {
157    let mut conn = pool.acquire().await?;
158    current_version_conn(&mut conn).await
159}
160
161async fn current_version_conn(conn: &mut PgConnection) -> Result<i32, AwaError> {
162    let has_schema: bool =
163        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
164            .fetch_one(&mut *conn)
165            .await?;
166
167    if !has_schema {
168        return Ok(0);
169    }
170
171    let has_table: bool = sqlx::query_scalar(
172        "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'schema_version')",
173    )
174    .fetch_one(&mut *conn)
175    .await?;
176
177    if !has_table {
178        return Ok(0);
179    }
180
181    let version: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM awa.schema_version")
182        .fetch_one(&mut *conn)
183        .await?;
184
185    let raw_version = version.unwrap_or(0);
186
187    // If max version is within the current MIGRATIONS range and the expected
188    // tables exist, this is a current install — skip legacy detection.
189    if (1..=CURRENT_VERSION).contains(&raw_version) {
190        // Quick check: does the schema match what we expect at this version?
191        // If queue_state_counts exists, we're past v4 in the current numbering.
192        let has_admin_tables: bool = sqlx::query_scalar(
193            "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
194        )
195        .fetch_one(&mut *conn)
196        .await
197        .unwrap_or(false);
198
199        // Current v4+ has queue_state_counts. If we're at v4+ and have
200        // the table, this is definitely a current install.
201        if raw_version >= 4 && has_admin_tables {
202            return Ok(raw_version);
203        }
204        // Current v1-v3 don't have queue_state_counts.
205        if raw_version <= 3 {
206            let has_runtime: bool = sqlx::query_scalar(
207                "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
208            )
209            .fetch_one(&mut *conn)
210            .await
211            .unwrap_or(false);
212            // v2+ has runtime_instances. If present, current install.
213            if (raw_version >= 2 && has_runtime) || raw_version == 1 {
214                return Ok(raw_version);
215            }
216        }
217    }
218
219    // Detect legacy version numbering from pre-0.4 releases.
220    // Legacy installs used a different numbering scheme where v3-v6 mapped
221    // to what is now v1-v4.
222    let has_legacy_high: bool =
223        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM awa.schema_version WHERE version >= 6)")
224            .fetch_one(&mut *conn)
225            .await
226            .unwrap_or(false);
227
228    let has_admin_metadata: bool = sqlx::query_scalar(
229        "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
230    )
231    .fetch_one(&mut *conn)
232    .await
233    .unwrap_or(false);
234
235    let is_legacy_v5_only = raw_version == 5 && !has_legacy_high && !has_admin_metadata;
236    let is_legacy_v4_only = raw_version == 4 && !has_legacy_high && !has_admin_metadata;
237
238    // Also detect a single legacy V3 row (0.3.0 with only canonical schema)
239    // by checking if runtime_instances exists — if not, this is legacy V3.
240    let is_legacy_v3_only = raw_version == 3
241        && !has_legacy_high
242        && {
243            let has_runtime: bool = sqlx::query_scalar(
244            "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
245        )
246        .fetch_one(&mut *conn)
247        .await
248        .unwrap_or(false);
249            !has_runtime
250        };
251
252    if has_legacy_high || is_legacy_v5_only || is_legacy_v4_only || is_legacy_v3_only {
253        let normalized = normalize_legacy_version(raw_version);
254        info!(
255            old_version = raw_version,
256            new_version = normalized,
257            "Normalizing legacy version numbering"
258        );
259        // Replace legacy rows so future calls return the new numbering.
260        sqlx::query("DELETE FROM awa.schema_version WHERE version >= 3")
261            .execute(&mut *conn)
262            .await?;
263        for &(v, desc, _) in MIGRATIONS {
264            if v <= normalized {
265                sqlx::query(
266                    "INSERT INTO awa.schema_version (version, description) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
267                )
268                .bind(v)
269                .bind(desc)
270                .execute(&mut *conn)
271                .await?;
272            }
273        }
274        return Ok(normalized);
275    }
276
277    Ok(raw_version)
278}
279
280/// Get the raw SQL for all migrations (for extraction / external tooling).
281pub fn migration_sql() -> Vec<(i32, &'static str, String)> {
282    MIGRATIONS
283        .iter()
284        .map(|&(v, d, steps)| (v, d, steps.join("\n")))
285        .collect()
286}
287
288/// Get migration SQL for a version range `(from, to]` — `from` is exclusive,
289/// `to` is inclusive. Returns only migrations where `from < version <= to`.
290pub fn migration_sql_range(from: i32, to: i32) -> Vec<(i32, &'static str, String)> {
291    MIGRATIONS
292        .iter()
293        .filter(|&&(v, _, _)| v > from && v <= to)
294        .map(|&(v, d, steps)| (v, d, steps.join("\n")))
295        .collect()
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn migration_sql_range_all() {
304        let all = migration_sql_range(0, CURRENT_VERSION);
305        assert_eq!(all.len(), MIGRATIONS.len());
306        assert_eq!(all.first().unwrap().0, 1);
307        assert_eq!(all.last().unwrap().0, CURRENT_VERSION);
308    }
309
310    #[test]
311    fn migration_sql_range_subset() {
312        let subset = migration_sql_range(2, CURRENT_VERSION);
313        assert!(subset.iter().all(|(v, _, _)| *v > 2));
314        let expected = MIGRATIONS.iter().filter(|&&(v, _, _)| v > 2).count();
315        assert_eq!(subset.len(), expected);
316    }
317
318    #[test]
319    fn migration_sql_range_single() {
320        let single = migration_sql_range(2, 3);
321        assert_eq!(single.len(), 1);
322        assert_eq!(single[0].0, 3);
323        assert!(!single[0].2.is_empty());
324    }
325
326    #[test]
327    fn migration_sql_range_empty_when_equal() {
328        let empty = migration_sql_range(CURRENT_VERSION, CURRENT_VERSION);
329        assert!(empty.is_empty());
330    }
331
332    #[test]
333    fn migration_sql_range_empty_when_inverted() {
334        let empty = migration_sql_range(3, 1);
335        assert!(empty.is_empty());
336    }
337
338    #[test]
339    fn migration_sql_range_matches_full() {
340        let full = migration_sql();
341        let ranged = migration_sql_range(0, CURRENT_VERSION);
342        assert_eq!(full.len(), ranged.len());
343        for (f, r) in full.iter().zip(ranged.iter()) {
344            assert_eq!(f.0, r.0);
345            assert_eq!(f.1, r.1);
346            assert_eq!(f.2, r.2);
347        }
348    }
349}