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 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub history: Option<Vec<BacklogSample>>,
41}
42
43#[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
124pub 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 live_drain = *live_runtime_capability_counts
245 .get("canonical_drain_only")
246 .unwrap_or(&0);
247
248 let mut enter_mixed_transition_blockers = Vec::new();
249 if status.state != "prepared" {
250 enter_mixed_transition_blockers
251 .push(format!("state is '{}' (expected 'prepared')", status.state));
252 }
253 if status.prepared_engine.as_deref() != Some("queue_storage") {
254 enter_mixed_transition_blockers.push(format!(
255 "prepared_engine is {:?} (expected Some(\"queue_storage\"))",
256 status.prepared_engine
257 ));
258 }
259 if prepared_queue_storage_schema.is_some() && !prepared_schema_ready {
260 enter_mixed_transition_blockers
261 .push("prepared queue-storage schema is missing required tables".to_string());
262 }
263 if live_canonical > 0 {
264 enter_mixed_transition_blockers.push(format!(
265 "{live_canonical} canonical-only runtime(s) are still live"
266 ));
267 }
268 if live_queue_storage_targets == 0 {
269 enter_mixed_transition_blockers.push(
270 "no live runtimes with transition_role=queue_storage_target are reporting; \
271 auto-role runtimes downgrade to drain-only after the routing flip"
272 .to_string(),
273 );
274 }
275
276 let mut finalize_blockers = Vec::new();
277 if status.state != "mixed_transition" {
278 finalize_blockers.push(format!(
279 "state is '{}' (expected 'mixed_transition')",
280 status.state
281 ));
282 }
283 if status.prepared_engine.as_deref() != Some("queue_storage") {
284 finalize_blockers.push(format!(
285 "prepared_engine is {:?} (expected Some(\"queue_storage\"))",
286 status.prepared_engine
287 ));
288 }
289 if canonical_live_backlog > 0 {
290 finalize_blockers.push(format!(
291 "canonical live backlog is {canonical_live_backlog}"
292 ));
293 }
294 if live_canonical + live_drain > 0 {
295 finalize_blockers.push(format!(
296 "{} canonical or drain-only runtime(s) are still live",
297 live_canonical + live_drain
298 ));
299 }
300
301 Ok(StorageStatusReport {
302 status,
303 canonical_live_backlog,
304 prepared_queue_storage_schema,
305 prepared_schema_ready,
306 live_runtime_capability_counts,
307 can_enter_mixed_transition: enter_mixed_transition_blockers.is_empty(),
308 enter_mixed_transition_blockers,
309 can_finalize: finalize_blockers.is_empty(),
310 finalize_blockers,
311 history: None,
312 })
313}