Skip to main content

awa_model/
storage.rs

1use crate::admin;
2use crate::error::AwaError;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use sqlx::{FromRow, PgExecutor, PgPool};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
9pub struct StorageStatus {
10    pub current_engine: String,
11    pub active_engine: String,
12    pub prepared_engine: Option<String>,
13    pub state: String,
14    pub transition_epoch: i64,
15    pub details: serde_json::Value,
16    pub entered_at: DateTime<Utc>,
17    pub updated_at: DateTime<Utc>,
18    pub finalized_at: Option<DateTime<Utc>>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct StorageStatusReport {
23    #[serde(flatten)]
24    pub status: StorageStatus,
25    pub canonical_live_backlog: i64,
26    pub prepared_queue_storage_schema: Option<String>,
27    pub prepared_schema_ready: bool,
28    pub live_runtime_capability_counts: BTreeMap<String, usize>,
29    pub can_enter_mixed_transition: bool,
30    pub enter_mixed_transition_blockers: Vec<String>,
31    pub can_finalize: bool,
32    pub finalize_blockers: Vec<String>,
33}
34
35pub async fn status<'e, E>(executor: E) -> Result<StorageStatus, AwaError>
36where
37    E: PgExecutor<'e>,
38{
39    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_status()")
40        .fetch_one(executor)
41        .await
42        .map_err(AwaError::from)
43}
44
45pub async fn prepare<'e, E>(
46    executor: E,
47    engine: &str,
48    details: serde_json::Value,
49) -> Result<StorageStatus, AwaError>
50where
51    E: PgExecutor<'e>,
52{
53    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_prepare($1, $2)")
54        .bind(engine)
55        .bind(details)
56        .fetch_one(executor)
57        .await
58        .map_err(AwaError::from)
59}
60
61pub async fn abort<'e, E>(executor: E) -> Result<StorageStatus, AwaError>
62where
63    E: PgExecutor<'e>,
64{
65    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_abort()")
66        .fetch_one(executor)
67        .await
68        .map_err(AwaError::from)
69}
70
71fn queue_storage_schema_from_status(status: &StorageStatus) -> Option<String> {
72    let queue_storage_involved = status.prepared_engine.as_deref() == Some("queue_storage")
73        || status.active_engine == "queue_storage";
74    if !queue_storage_involved {
75        return None;
76    }
77    Some(
78        status
79            .details
80            .get("schema")
81            .and_then(serde_json::Value::as_str)
82            .unwrap_or("awa_exp")
83            .to_string(),
84    )
85}
86
87async fn prepared_queue_storage_schema_ready(
88    pool: &PgPool,
89    schema: &str,
90) -> Result<bool, AwaError> {
91    sqlx::query_scalar::<_, bool>(
92        r#"
93        SELECT
94            to_regclass(format('%I.%I', $1, 'queue_ring_state')) IS NOT NULL
95            AND to_regclass(format('%I.%I', $1, 'ready_entries')) IS NOT NULL
96            AND to_regclass(format('%I.%I', $1, 'leases')) IS NOT NULL
97        "#,
98    )
99    .bind(schema)
100    .fetch_one(pool)
101    .await
102    .map_err(AwaError::from)
103}
104
105async fn canonical_live_backlog(pool: &PgPool) -> Result<i64, AwaError> {
106    sqlx::query_scalar::<_, i64>(
107        r#"
108        SELECT
109            COALESCE((
110                SELECT count(*)::bigint
111                FROM awa.jobs_hot
112                WHERE state NOT IN ('completed', 'failed', 'cancelled')
113            ), 0)
114            +
115            COALESCE((
116                SELECT count(*)::bigint
117                FROM awa.scheduled_jobs
118            ), 0)
119        "#,
120    )
121    .fetch_one(pool)
122    .await
123    .map_err(AwaError::from)
124}
125
126pub async fn status_report(pool: &PgPool) -> Result<StorageStatusReport, AwaError> {
127    let status = status(pool).await?;
128    let canonical_live_backlog = canonical_live_backlog(pool).await?;
129    let prepared_queue_storage_schema = queue_storage_schema_from_status(&status);
130    let prepared_schema_ready = match prepared_queue_storage_schema.as_deref() {
131        Some(schema) => prepared_queue_storage_schema_ready(pool, schema).await?,
132        None => false,
133    };
134
135    let instances = admin::list_runtime_instances(pool).await?;
136    let mut live_runtime_capability_counts = BTreeMap::new();
137    for instance in instances.into_iter().filter(|instance| !instance.stale) {
138        *live_runtime_capability_counts
139            .entry(instance.storage_capability.as_str().to_string())
140            .or_insert(0) += 1;
141    }
142
143    let live_canonical = *live_runtime_capability_counts
144        .get("canonical")
145        .unwrap_or(&0);
146    let live_queue_storage = *live_runtime_capability_counts
147        .get("queue_storage")
148        .unwrap_or(&0);
149    let live_drain = *live_runtime_capability_counts
150        .get("canonical_drain_only")
151        .unwrap_or(&0);
152
153    let mut enter_mixed_transition_blockers = Vec::new();
154    if status.state != "prepared" {
155        enter_mixed_transition_blockers
156            .push(format!("state is '{}' (expected 'prepared')", status.state));
157    }
158    if status.prepared_engine.as_deref() != Some("queue_storage") {
159        enter_mixed_transition_blockers.push(format!(
160            "prepared_engine is {:?} (expected Some(\"queue_storage\"))",
161            status.prepared_engine
162        ));
163    }
164    if prepared_queue_storage_schema.is_some() && !prepared_schema_ready {
165        enter_mixed_transition_blockers
166            .push("prepared queue-storage schema is missing required tables".to_string());
167    }
168    if live_canonical > 0 {
169        enter_mixed_transition_blockers.push(format!(
170            "{live_canonical} canonical-only runtime(s) are still live"
171        ));
172    }
173    if live_queue_storage == 0 {
174        enter_mixed_transition_blockers
175            .push("no live queue-storage-capable runtimes are reporting".to_string());
176    }
177
178    let mut finalize_blockers = Vec::new();
179    if status.state != "mixed_transition" {
180        finalize_blockers.push(format!(
181            "state is '{}' (expected 'mixed_transition')",
182            status.state
183        ));
184    }
185    if status.prepared_engine.as_deref() != Some("queue_storage") {
186        finalize_blockers.push(format!(
187            "prepared_engine is {:?} (expected Some(\"queue_storage\"))",
188            status.prepared_engine
189        ));
190    }
191    if canonical_live_backlog > 0 {
192        finalize_blockers.push(format!(
193            "canonical live backlog is {canonical_live_backlog}"
194        ));
195    }
196    if live_canonical + live_drain > 0 {
197        finalize_blockers.push(format!(
198            "{} canonical or drain-only runtime(s) are still live",
199            live_canonical + live_drain
200        ));
201    }
202
203    Ok(StorageStatusReport {
204        status,
205        canonical_live_backlog,
206        prepared_queue_storage_schema,
207        prepared_schema_ready,
208        live_runtime_capability_counts,
209        can_enter_mixed_transition: enter_mixed_transition_blockers.is_empty(),
210        enter_mixed_transition_blockers,
211        can_finalize: finalize_blockers.is_empty(),
212        finalize_blockers,
213    })
214}