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    /// Historical samples of `canonical_live_backlog` anchored to the
34    /// current `transition_epoch`. Only populated when the caller passes
35    /// `?history=true` on `/api/storage`. The 0.6 server returns an empty
36    /// slice because samples are accumulated client-side (no audit table
37    /// — see issue #180); this field is kept additive so future versions
38    /// that introduce server-side accumulation stay wire-compatible.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub history: Option<Vec<BacklogSample>>,
41}
42
43/// One sample of `canonical_live_backlog`, timestamped server-side. The UI
44/// uses these to render an epoch-anchored sparkline showing the full
45/// timeline of the current cutover (rather than a sliding window).
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct BacklogSample {
48    pub at: DateTime<Utc>,
49    pub canonical_live_backlog: i64,
50}
51
52pub async fn status<'e, E>(executor: E) -> Result<StorageStatus, AwaError>
53where
54    E: PgExecutor<'e>,
55{
56    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_status()")
57        .fetch_one(executor)
58        .await
59        .map_err(AwaError::from)
60}
61
62pub async fn prepare<'e, E>(
63    executor: E,
64    engine: &str,
65    details: serde_json::Value,
66) -> Result<StorageStatus, AwaError>
67where
68    E: PgExecutor<'e>,
69{
70    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_prepare($1, $2)")
71        .bind(engine)
72        .bind(details)
73        .fetch_one(executor)
74        .await
75        .map_err(AwaError::from)
76}
77
78pub async fn abort<'e, E>(executor: E) -> Result<StorageStatus, AwaError>
79where
80    E: PgExecutor<'e>,
81{
82    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_abort()")
83        .fetch_one(executor)
84        .await
85        .map_err(AwaError::from)
86}
87
88pub async fn enter_mixed_transition<'e, E>(executor: E) -> Result<StorageStatus, AwaError>
89where
90    E: PgExecutor<'e>,
91{
92    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_enter_mixed_transition()")
93        .fetch_one(executor)
94        .await
95        .map_err(AwaError::from)
96}
97
98pub async fn finalize<'e, E>(executor: E) -> Result<StorageStatus, AwaError>
99where
100    E: PgExecutor<'e>,
101{
102    sqlx::query_as::<_, StorageStatus>("SELECT * FROM awa.storage_finalize()")
103        .fetch_one(executor)
104        .await
105        .map_err(AwaError::from)
106}
107
108fn queue_storage_schema_from_status(status: &StorageStatus) -> Option<String> {
109    let queue_storage_involved = status.prepared_engine.as_deref() == Some("queue_storage")
110        || status.active_engine == "queue_storage";
111    if !queue_storage_involved {
112        return None;
113    }
114    Some(
115        status
116            .details
117            .get("schema")
118            .and_then(serde_json::Value::as_str)
119            .unwrap_or("awa")
120            .to_string(),
121    )
122}
123
124/// Returns true only when every queue-storage substrate object the
125/// runtime depends on is present. Workers and producers should treat
126/// `false` as "schema not installed yet" and either wait or trigger
127/// `QueueStorage::prepare_schema`.
128///
129/// The required objects (tables, the job-id sequence, the claim
130/// function and its exact signature) originate from the v023 SQL helper,
131/// called either by `awa migrate` for the default schema or by
132/// [`QueueStorage::prepare_schema`](crate::QueueStorage::prepare_schema)
133/// for custom schemas. If you change a required substrate object or the
134/// `claim_ready_runtime` signature there, update this check at the same
135/// time.
136pub async fn queue_storage_schema_ready(pool: &PgPool, schema: &str) -> Result<bool, AwaError> {
137    sqlx::query_scalar::<_, bool>(
138        r#"
139        SELECT
140            to_regclass(format('%I.%I', $1, 'job_id_seq')) IS NOT NULL
141            AND to_regclass(format('%I.%I', $1, 'lease_claim_receipt_id_seq')) IS NOT NULL
142            AND to_regclass(format('%I.%I', $1, 'lease_claim_batch_id_seq')) IS NOT NULL
143            AND to_regclass(format('%I.%I', $1, 'queue_ring_state')) IS NOT NULL
144            AND to_regclass(format('%I.%I', $1, 'ready_entries')) IS NOT NULL
145            AND to_regclass(format('%I.%I', $1, 'ready_claim_attempt_batches')) IS NOT NULL
146            AND to_regclass(format('%I.%I', $1, 'ready_tombstones')) IS NOT NULL
147            AND to_regclass(format('%I.%I', $1, 'ready_segments')) IS NOT NULL
148            AND to_regclass(format('%I.%I', $1, 'done_entries')) IS NOT NULL
149            AND to_regclass(format('%I.%I', $1, 'receipt_completion_batches')) IS NOT NULL
150            AND to_regclass(format('%I.%I', $1, 'receipt_completion_tombstones')) IS NOT NULL
151            AND to_regclass(format('%I.%I', $1, 'queue_terminal_count_deltas')) IS NOT NULL
152            AND to_regclass(format('%I.%I', $1, 'leases')) IS NOT NULL
153            AND to_regclass(format('%I.%I', $1, 'deferred_jobs')) IS NOT NULL
154            AND to_regclass(format('%I.%I', $1, 'lease_claims')) IS NOT NULL
155            AND to_regclass(format('%I.%I', $1, 'lease_claim_batches')) IS NOT NULL
156            AND to_regclass(format('%I.%I', $1, 'lease_claim_closures')) IS NOT NULL
157            AND to_regclass(format('%I.%I', $1, 'lease_claim_closure_batches')) IS NOT NULL
158            AND EXISTS (
159                SELECT 1
160                FROM information_schema.columns
161                WHERE table_schema = $1
162                  AND table_name = 'lease_claim_closure_batches'
163                  AND column_name = 'receipt_ranges'
164            )
165            AND (
166                SELECT count(*) = 3
167                FROM information_schema.columns
168                WHERE table_schema = $1
169                  AND table_name = 'queue_claim_heads'
170                  AND column_name = ANY(ARRAY[
171                      'ready_segment_slot',
172                      'ready_segment_generation',
173                      'ready_segment_next_lane_seq'
174                  ])
175            )
176            AND EXISTS (
177                SELECT 1
178                FROM pg_proc AS p
179                JOIN pg_namespace AS n
180                  ON n.oid = p.pronamespace
181                WHERE n.nspname = $1
182                  AND p.oid = to_regprocedure(
183                      format('%I.%I(text,bigint,double precision,double precision)', $1, 'claim_ready_runtime')
184                  )::oid
185                  AND position('receipt_id' IN pg_get_function_result(p.oid)) > 0
186                  AND position('claim_batch_id' IN pg_get_function_result(p.oid)) > 0
187                  AND position('claim_batch_index' IN pg_get_function_result(p.oid)) > 0
188            )
189        "#,
190    )
191    .bind(schema)
192    .fetch_one(pool)
193    .await
194    .map_err(AwaError::from)
195}
196
197async fn canonical_live_backlog(pool: &PgPool) -> Result<i64, AwaError> {
198    sqlx::query_scalar::<_, i64>(
199        r#"
200        SELECT
201            COALESCE((
202                SELECT count(*)::bigint
203                FROM awa.jobs_hot
204                WHERE state NOT IN ('completed', 'failed', 'cancelled')
205            ), 0)
206            +
207            COALESCE((
208                SELECT count(*)::bigint
209                FROM awa.scheduled_jobs
210            ), 0)
211        "#,
212    )
213    .fetch_one(pool)
214    .await
215    .map_err(AwaError::from)
216}
217
218pub async fn status_report(pool: &PgPool) -> Result<StorageStatusReport, AwaError> {
219    let status = status(pool).await?;
220    let canonical_live_backlog = canonical_live_backlog(pool).await?;
221    let prepared_queue_storage_schema = queue_storage_schema_from_status(&status);
222    let prepared_schema_ready = match prepared_queue_storage_schema.as_deref() {
223        Some(schema) => queue_storage_schema_ready(pool, schema).await?,
224        None => false,
225    };
226
227    let instances = admin::list_runtime_instances(pool).await?;
228    let mut live_runtime_capability_counts = BTreeMap::new();
229    let mut live_queue_storage_targets = 0usize;
230    for instance in instances.into_iter().filter(|instance| !instance.stale) {
231        *live_runtime_capability_counts
232            .entry(instance.storage_capability.as_str().to_string())
233            .or_insert(0) += 1;
234        if instance.transition_role == admin::TransitionRole::QueueStorageTarget
235            && instance.storage_capability == admin::StorageCapability::QueueStorage
236        {
237            live_queue_storage_targets += 1;
238        }
239    }
240
241    let live_canonical = *live_runtime_capability_counts
242        .get("canonical")
243        .unwrap_or(&0);
244    let mut enter_mixed_transition_blockers = Vec::new();
245    if status.state != "prepared" {
246        enter_mixed_transition_blockers
247            .push(format!("state is '{}' (expected 'prepared')", status.state));
248    }
249    if status.prepared_engine.as_deref() != Some("queue_storage") {
250        enter_mixed_transition_blockers.push(format!(
251            "prepared_engine is {:?} (expected Some(\"queue_storage\"))",
252            status.prepared_engine
253        ));
254    }
255    if prepared_queue_storage_schema.is_some() && !prepared_schema_ready {
256        enter_mixed_transition_blockers
257            .push("prepared queue-storage schema is missing required tables".to_string());
258    }
259    if live_canonical > 0 {
260        enter_mixed_transition_blockers.push(format!(
261            "{live_canonical} canonical-only runtime(s) are still live"
262        ));
263    }
264    if live_queue_storage_targets == 0 {
265        enter_mixed_transition_blockers.push(
266            "no live runtimes with transition_role=queue_storage_target are reporting; \
267             auto-role runtimes downgrade to drain-only after the routing flip"
268                .to_string(),
269        );
270    }
271
272    let mut finalize_blockers = Vec::new();
273    if status.state != "mixed_transition" {
274        finalize_blockers.push(format!(
275            "state is '{}' (expected 'mixed_transition')",
276            status.state
277        ));
278    }
279    if status.prepared_engine.as_deref() != Some("queue_storage") {
280        finalize_blockers.push(format!(
281            "prepared_engine is {:?} (expected Some(\"queue_storage\"))",
282            status.prepared_engine
283        ));
284    }
285    if canonical_live_backlog > 0 {
286        finalize_blockers.push(format!(
287            "canonical live backlog is {canonical_live_backlog}"
288        ));
289    }
290    if live_canonical > 0 {
291        finalize_blockers.push(format!(
292            "{live_canonical} canonical-only runtime(s) are still live"
293        ));
294    }
295
296    Ok(StorageStatusReport {
297        status,
298        canonical_live_backlog,
299        prepared_queue_storage_schema,
300        prepared_schema_ready,
301        live_runtime_capability_counts,
302        can_enter_mixed_transition: enter_mixed_transition_blockers.is_empty(),
303        enter_mixed_transition_blockers,
304        can_finalize: finalize_blockers.is_empty(),
305        finalize_blockers,
306        history: None,
307    })
308}