Skip to main content

resuma/exec/
queue.rs

1//! Job queue — disk-backed, multi-process safe (atomic file claim).
2
3use 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
47/// Configure file-backed queue storage root.
48pub fn configure_disk(root: impl AsRef<std::path::Path>) {
49    queue_disk::configure(root);
50}
51
52/// Start disk pollers for all queues (multi-process workers).
53pub 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
69/// Register a named queue and spawn its disk consumer loop.
70pub 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
84/// Enqueue a worker job (persisted to disk; any Resuma process may claim it).
85pub 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
101/// Queue depth stats (`pending`, `processing`, …).
102pub 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    // The std mutex is held across `.await` only to serialize the shared
205    // on-disk queue between tests; there is no contention or deadlock risk here.
206    #[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}