1use std::collections::HashMap;
4use std::time::Duration;
5
6use once_cell::sync::Lazy;
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use tokio::sync::{mpsc, Mutex};
11use tokio::task;
12use tracing::{error, info};
13
14use crate::core::Result;
15
16use super::engine::FlowEngine;
17use super::id;
18use super::queue_disk;
19use super::types::{GraphId, GraphStatus};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct QueueMessage {
23 pub id: String,
24 pub worker: String,
25 #[serde(default)]
26 pub input: Value,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct EnqueueResponse {
31 pub message_id: String,
32 pub queue: String,
33}
34
35static WAKES: Lazy<RwLock<HashMap<String, mpsc::Sender<()>>>> =
36 Lazy::new(|| RwLock::new(HashMap::new()));
37
38static STARTED: Lazy<Mutex<bool>> = Lazy::new(|| Mutex::new(false));
39
40fn poll_interval_ms() -> u64 {
41 std::env::var("RESUMA_QUEUE_POLL_MS")
42 .ok()
43 .and_then(|s| s.parse().ok())
44 .unwrap_or(200)
45}
46
47pub fn configure_disk(root: impl AsRef<std::path::Path>) {
49 queue_disk::configure(root);
50}
51
52pub async fn start_consumers() {
54 let mut started = STARTED.lock().await;
55 if *started {
56 return;
57 }
58 *started = true;
59
60 register_queue("default");
61
62 for name in queue_disk::list_queues() {
63 if name != "default" {
64 register_queue(&name);
65 }
66 }
67}
68
69pub fn register_queue(name: &str) {
71 let mut wakes = WAKES.write();
72 if wakes.contains_key(name) {
73 return;
74 }
75 let (tx, rx) = mpsc::channel(512);
76 wakes.insert(name.to_string(), tx);
77 drop(wakes);
78 let qname = name.to_string();
79 task::spawn(async move {
80 disk_consumer_loop(&qname, rx).await;
81 });
82}
83
84pub async fn enqueue(queue: &str, worker: &str, input: Value) -> Result<EnqueueResponse> {
86 register_queue(queue);
87 let msg = QueueMessage {
88 id: format!("m_{}", id::next_id()),
89 worker: worker.to_string(),
90 input,
91 };
92 let mid = msg.id.clone();
93 queue_disk::persist_pending(queue, &msg)?;
94 wake(queue);
95 Ok(EnqueueResponse {
96 message_id: mid,
97 queue: queue.to_string(),
98 })
99}
100
101pub fn queue_stats(queue: &str) -> queue_disk::QueueStats {
103 queue_disk::stats(queue)
104}
105
106fn wake(queue: &str) {
107 if let Some(tx) = WAKES.read().get(queue) {
108 let _ = tx.try_send(());
109 }
110}
111
112async fn disk_consumer_loop(queue: &str, mut wake_rx: mpsc::Receiver<()>) {
113 let recovered = queue_disk::recover_processing(queue);
114 if recovered > 0 {
115 info!(queue = %queue, recovered, "re-queued stuck processing jobs");
116 }
117 info!(queue = %queue, "resuma disk queue consumer started (multi-process)");
118
119 let poll = Duration::from_millis(poll_interval_ms());
120 loop {
121 drain_claimed(queue).await;
122 tokio::select! {
123 _ = wake_rx.recv() => {}
124 _ = tokio::time::sleep(poll) => {}
125 }
126 }
127}
128
129async fn drain_claimed(queue: &str) {
130 while let Some(msg) = queue_disk::claim_next(queue) {
131 process_message(queue, msg).await;
132 }
133}
134
135async fn process_message(queue: &str, msg: QueueMessage) {
136 info!(
137 queue = %queue,
138 worker = %msg.worker,
139 message_id = %msg.id,
140 "claimed queued job from disk"
141 );
142 match FlowEngine::start(&msg.worker, msg.input.clone()).await {
143 Ok(started) => {
144 info!(
145 graph_id = %started.graph_id.0,
146 worker = %msg.worker,
147 "queued worker started"
148 );
149 let queue = queue.to_string();
150 let msg_id = msg.id.clone();
151 let graph_id = started.graph_id;
152 tokio::spawn(async move {
153 let success = wait_graph_terminal(&graph_id).await;
154 if let Err(e) = queue_disk::complete(&queue, &msg_id, success) {
155 tracing::error!(
156 error = %e,
157 queue = %queue,
158 message_id = %msg_id,
159 "failed to complete queued job"
160 );
161 }
162 });
163 }
164 Err(e) => {
165 error!(error = %e, worker = %msg.worker, "queued worker failed to start");
166 let _ = queue_disk::complete(queue, &msg.id, false);
167 }
168 }
169}
170
171async fn wait_graph_terminal(graph_id: &GraphId) -> bool {
172 let poll = Duration::from_millis(100);
173 let timeout_secs = std::env::var("RESUMA_QUEUE_GRAPH_TIMEOUT_SECS")
174 .ok()
175 .and_then(|v| v.parse().ok())
176 .unwrap_or(3600);
177 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
178 loop {
179 match FlowEngine::snapshot(graph_id) {
180 Some(snap) => match snap.status {
181 GraphStatus::Done => return true,
182 GraphStatus::Failed => return false,
183 GraphStatus::Running | GraphStatus::Paused => {}
184 },
185 None => return false,
186 }
187 if tokio::time::Instant::now() >= deadline {
188 tracing::warn!(
189 graph_id = %graph_id.0,
190 timeout_secs,
191 "queued job graph wait timed out"
192 );
193 return false;
194 }
195 tokio::time::sleep(poll).await;
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use serde_json::json;
203
204 #[allow(clippy::await_holding_lock)]
207 #[tokio::test]
208 async fn enqueue_persists_for_disk_consumer() {
209 let _guard = super::queue_disk::test_queue_lock().lock();
210 let root = std::env::temp_dir().join(format!("resuma-qe-{}", id::next_id()));
211 configure_disk(&root);
212 register_queue("test");
213 let resp = enqueue("test", "missing_worker", json!({}))
214 .await
215 .expect("enqueue");
216 let s = queue_stats("test");
217 assert!(s.pending >= 1 || s.processing >= 1);
218 assert!(!resp.message_id.is_empty());
219 }
220}