1use crate::error::AwaError;
2use sqlx::postgres::PgConnection;
3use sqlx::PgPool;
4use tracing::info;
5
6pub const CURRENT_VERSION: i32 = 40;
8
9const 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 (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
229fn normalize_legacy_version(old_version: i32) -> i32 {
233 match old_version {
234 v if v >= 6 => 4, 5 => 3, 4 => 2, 3 => 1, _ => 0, }
240}
241
242pub async fn run(pool: &PgPool) -> Result<(), AwaError> {
252 let lock_key: i64 = 0x4157_415f_4d49_4752; 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 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 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
327pub 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 (1..=CURRENT_VERSION).contains(&raw_version) {
377 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 if raw_version >= 4 && has_admin_tables {
389 return Ok(raw_version);
390 }
391 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 if (raw_version >= 2 && has_runtime) || raw_version == 1 {
401 return Ok(raw_version);
402 }
403 }
404 }
405
406 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 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 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
467async 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 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
512pub 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
520pub 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}