1use crate::error::AwaError;
2use sqlx::postgres::PgConnection;
3use sqlx::PgPool;
4use tracing::info;
5
6pub const CURRENT_VERSION: i32 = 39;
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
184const V1_UP: &str = include_str!("../migrations/v001_canonical_schema.sql");
185const V2_UP: &str = include_str!("../migrations/v002_runtime_instances.sql");
186const V3_UP: &str = include_str!("../migrations/v003_maintenance_health.sql");
187const V4_UP: &str = include_str!("../migrations/v004_admin_metadata.sql");
188const V5_UP: &str = include_str!("../migrations/v005_admin_metadata_stmt_triggers.sql");
189const V6_UP: &str = include_str!("../migrations/v006_remove_hot_table_triggers.sql");
190const V7_UP: &str = include_str!("../migrations/v007_backoff_interval_fix.sql");
191const V9_UP: &str = include_str!("../migrations/v009_descriptors.sql");
192const V10_UP: &str = include_str!("../migrations/v010_storage_transition_prep.sql");
193const V11_UP: &str = include_str!("../migrations/v011_storage_transition_self_heal.sql");
194const V12_UP: &str = include_str!("../migrations/v012_queue_storage_compat.sql");
195const V13_UP: &str = include_str!("../migrations/v013_storage_auto_finalize.sql");
196const V14_UP: &str = include_str!("../migrations/v014_storage_transition_role.sql");
197const V15_UP: &str = include_str!("../migrations/v015_cron_missed_fire_policy.sql");
198const V16_UP: &str = include_str!("../migrations/v016_drop_queue_lanes_available_count.sql");
199const V17_UP: &str = include_str!("../migrations/v017_shard_queue_enqueue_heads.sql");
200const V18_UP: &str = include_str!("../migrations/v018_insert_job_compat_ordering_key.sql");
201const V19_UP: &str = include_str!("../migrations/v019_queue_storage_jobs_compat_shard_joins.sql");
202const V20_UP: &str = include_str!("../migrations/v020_active_queue_storage_schema_fallback.sql");
203const V21_UP: &str = include_str!("../migrations/v021_shard_aware_lane_indexes.sql");
204const V22_UP: &str = include_str!("../migrations/v022_delete_compat_terminal_counter.sql");
205const V23_UP: &str = include_str!("../migrations/v023_install_queue_storage_substrate.sql");
206const V24_UP: &str = include_str!("../migrations/v024_receipt_plane_fillfactor.sql");
207const V25_UP: &str = include_str!("../migrations/v025_drop_leases_state_hb_index.sql");
208const V26_UP: &str = include_str!("../migrations/v026_cron_jobs_pause.sql");
209const V27_UP: &str = include_str!("../migrations/v027_sequence_lane_cursors.sql");
210const V28_UP: &str = include_str!("../migrations/v028_ready_tombstones.sql");
211const V29_UP: &str = include_str!("../migrations/v029_batch_operations.sql");
212const V30_UP: &str = include_str!("../migrations/v030_terminal_count_deltas.sql");
213const V31_UP: &str = include_str!("../migrations/v031_queue_storage_failed_done_indexes.sql");
214const V32_UP: &str = include_str!("../migrations/v032_failed_terminal_retention.sql");
215const V33_UP: &str = include_str!("../migrations/v033_receipt_rescue_cursors.sql");
216const V34_UP: &str = include_str!("../migrations/v034_receipt_terminal_delete_closures.sql");
217const V35_UP: &str = include_str!("../migrations/v035_receipt_deadline_rescue_cursors.sql");
218const V36_UP: &str = include_str!("../migrations/v036_compact_receipt_completions.sql");
219const V37_UP: &str = include_str!("../migrations/v037_ready_segments.sql");
220const V38_UP: &str = include_str!("../migrations/v038_compact_claim_batches.sql");
221const V39_UP: &str = include_str!("../migrations/v039_claim_head_cold_routing.sql");
222
223fn normalize_legacy_version(old_version: i32) -> i32 {
227 match old_version {
228 v if v >= 6 => 4, 5 => 3, 4 => 2, 3 => 1, _ => 0, }
234}
235
236pub async fn run(pool: &PgPool) -> Result<(), AwaError> {
246 let lock_key: i64 = 0x4157_415f_4d49_4752; let mut conn = pool.acquire().await?;
248 sqlx::query("SELECT pg_advisory_lock($1)")
249 .bind(lock_key)
250 .execute(&mut *conn)
251 .await?;
252
253 let result = run_inner(&mut conn).await;
254
255 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
256 .bind(lock_key)
257 .execute(&mut *conn)
258 .await;
259
260 result
261}
262
263async fn run_inner(conn: &mut PgConnection) -> Result<(), AwaError> {
264 let has_schema: bool =
265 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
266 .fetch_one(&mut *conn)
267 .await?;
268
269 let current = if has_schema {
270 current_version_conn(conn).await?
271 } else {
272 0
273 };
274
275 if !(has_schema && current == CURRENT_VERSION) {
276 for &(version, description, steps) in MIGRATIONS {
277 if version <= current {
278 continue;
279 }
280 info!(version, description, "Applying migration");
281 for step in steps {
282 sqlx::raw_sql(step).execute(&mut *conn).await?;
283 }
284 info!(version, "Migration applied");
285 }
286 } else {
287 info!(version = current, "Schema is up to date");
288 }
289
290 let has_refresh: bool = sqlx::query_scalar(
295 "SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'refresh_admin_metadata' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'awa'))",
296 )
297 .fetch_one(&mut *conn)
298 .await?;
299 if has_refresh {
300 let _ = sqlx::raw_sql(
306 "BEGIN; SET LOCAL statement_timeout = '5s'; SELECT awa.refresh_admin_metadata(); COMMIT;",
307 )
308 .execute(&mut *conn)
309 .await;
310 }
311
312 Ok(())
313}
314
315pub async fn current_version(pool: &PgPool) -> Result<i32, AwaError> {
317 let mut conn = pool.acquire().await?;
318 current_version_conn(&mut conn).await
319}
320
321async fn current_version_conn(conn: &mut PgConnection) -> Result<i32, AwaError> {
322 let has_schema: bool =
323 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
324 .fetch_one(&mut *conn)
325 .await?;
326
327 if !has_schema {
328 return Ok(0);
329 }
330
331 let has_table: bool = sqlx::query_scalar(
332 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'schema_version')",
333 )
334 .fetch_one(&mut *conn)
335 .await?;
336
337 if !has_table {
338 return Ok(0);
339 }
340
341 let version: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM awa.schema_version")
342 .fetch_one(&mut *conn)
343 .await?;
344
345 let raw_version = version.unwrap_or(0);
346
347 if (1..=CURRENT_VERSION).contains(&raw_version) {
350 let has_admin_tables: bool = sqlx::query_scalar(
353 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
354 )
355 .fetch_one(&mut *conn)
356 .await
357 .unwrap_or(false);
358
359 if raw_version >= 4 && has_admin_tables {
362 return Ok(raw_version);
363 }
364 if raw_version <= 3 {
366 let has_runtime: bool = sqlx::query_scalar(
367 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
368 )
369 .fetch_one(&mut *conn)
370 .await
371 .unwrap_or(false);
372 if (raw_version >= 2 && has_runtime) || raw_version == 1 {
374 return Ok(raw_version);
375 }
376 }
377 }
378
379 let has_legacy_high: bool =
383 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM awa.schema_version WHERE version >= 6)")
384 .fetch_one(&mut *conn)
385 .await
386 .unwrap_or(false);
387
388 let has_admin_metadata: bool = sqlx::query_scalar(
389 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
390 )
391 .fetch_one(&mut *conn)
392 .await
393 .unwrap_or(false);
394
395 let is_legacy_v5_only = raw_version == 5 && !has_legacy_high && !has_admin_metadata;
396 let is_legacy_v4_only = raw_version == 4 && !has_legacy_high && !has_admin_metadata;
397
398 let is_legacy_v3_only = raw_version == 3
401 && !has_legacy_high
402 && {
403 let has_runtime: bool = sqlx::query_scalar(
404 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
405 )
406 .fetch_one(&mut *conn)
407 .await
408 .unwrap_or(false);
409 !has_runtime
410 };
411
412 if has_legacy_high || is_legacy_v5_only || is_legacy_v4_only || is_legacy_v3_only {
413 let normalized = normalize_legacy_version(raw_version);
414 info!(
415 old_version = raw_version,
416 new_version = normalized,
417 "Normalizing legacy version numbering"
418 );
419 sqlx::query("DELETE FROM awa.schema_version WHERE version >= 3")
421 .execute(&mut *conn)
422 .await?;
423 for &(v, desc, _) in MIGRATIONS {
424 if v <= normalized {
425 sqlx::query(
426 "INSERT INTO awa.schema_version (version, description) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
427 )
428 .bind(v)
429 .bind(desc)
430 .execute(&mut *conn)
431 .await?;
432 }
433 }
434 return Ok(normalized);
435 }
436
437 Ok(raw_version)
438}
439
440pub fn migration_sql() -> Vec<(i32, &'static str, String)> {
442 MIGRATIONS
443 .iter()
444 .map(|&(v, d, steps)| (v, d, steps.join("\n")))
445 .collect()
446}
447
448pub fn migration_sql_range(from: i32, to: i32) -> Vec<(i32, &'static str, String)> {
451 MIGRATIONS
452 .iter()
453 .filter(|&&(v, _, _)| v > from && v <= to)
454 .map(|&(v, d, steps)| (v, d, steps.join("\n")))
455 .collect()
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn migration_sql_range_all() {
464 let all = migration_sql_range(0, CURRENT_VERSION);
465 assert_eq!(all.len(), MIGRATIONS.len());
466 assert_eq!(all.first().unwrap().0, 1);
467 assert_eq!(all.last().unwrap().0, CURRENT_VERSION);
468 }
469
470 #[test]
471 fn migration_sql_range_subset() {
472 let subset = migration_sql_range(2, CURRENT_VERSION);
473 assert!(subset.iter().all(|(v, _, _)| *v > 2));
474 let expected = MIGRATIONS.iter().filter(|&&(v, _, _)| v > 2).count();
475 assert_eq!(subset.len(), expected);
476 }
477
478 #[test]
479 fn migration_sql_range_single() {
480 let single = migration_sql_range(2, 3);
481 assert_eq!(single.len(), 1);
482 assert_eq!(single[0].0, 3);
483 assert!(!single[0].2.is_empty());
484 }
485
486 #[test]
487 fn migration_sql_range_empty_when_equal() {
488 let empty = migration_sql_range(CURRENT_VERSION, CURRENT_VERSION);
489 assert!(empty.is_empty());
490 }
491
492 #[test]
493 fn migration_sql_range_empty_when_inverted() {
494 let empty = migration_sql_range(3, 1);
495 assert!(empty.is_empty());
496 }
497
498 #[test]
499 fn migration_sql_range_matches_full() {
500 let full = migration_sql();
501 let ranged = migration_sql_range(0, CURRENT_VERSION);
502 assert_eq!(full.len(), ranged.len());
503 for (f, r) in full.iter().zip(ranged.iter()) {
504 assert_eq!(f.0, r.0);
505 assert_eq!(f.1, r.1);
506 assert_eq!(f.2, r.2);
507 }
508 }
509}