use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use parking_lot::RwLock;
use crate::core::{Result, ResumaError};
use super::queue::QueueMessage;
use super::security::validate_schedule_id;
static QUEUE_ROOT: RwLock<Option<PathBuf>> = RwLock::new(None);
pub fn configure(root: impl AsRef<Path>) {
let p = root.as_ref().to_path_buf();
let _ = fs::create_dir_all(&p);
*QUEUE_ROOT.write() = Some(p);
}
pub fn root() -> PathBuf {
QUEUE_ROOT
.read()
.clone()
.unwrap_or_else(|| PathBuf::from(".resuma/queue"))
}
fn queue_dir(queue: &str) -> PathBuf {
root().join(sanitize(queue))
}
fn pending_dir(queue: &str) -> PathBuf {
queue_dir(queue).join("pending")
}
fn processing_dir(queue: &str) -> PathBuf {
queue_dir(queue).join("processing")
}
fn done_dir(queue: &str) -> PathBuf {
queue_dir(queue).join("done")
}
fn failed_dir(queue: &str) -> PathBuf {
queue_dir(queue).join("failed")
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn pending_path(queue: &str, id: &str) -> Result<PathBuf> {
validate_schedule_id(id)?;
Ok(pending_dir(queue).join(format!("{id}.json")))
}
fn processing_path(queue: &str, id: &str) -> Result<PathBuf> {
validate_schedule_id(id)?;
Ok(processing_dir(queue).join(format!("{id}.json")))
}
pub fn persist_pending(queue: &str, msg: &QueueMessage) -> Result<()> {
let dir = pending_dir(queue);
fs::create_dir_all(&dir).map_err(ResumaError::Io)?;
let final_path = pending_path(queue, &msg.id)?;
let tmp_path = dir.join(format!("{}.json.tmp", msg.id));
let data = serde_json::to_string_pretty(msg)?;
{
let mut file = fs::File::create(&tmp_path).map_err(ResumaError::Io)?;
file.write_all(data.as_bytes()).map_err(ResumaError::Io)?;
file.sync_all().map_err(ResumaError::Io)?;
}
fs::rename(&tmp_path, &final_path).map_err(ResumaError::Io)?;
Ok(())
}
pub fn try_claim(queue: &str, id: &str) -> Result<()> {
let src = pending_path(queue, id)?;
if !src.exists() {
return Err(ResumaError::Other("job already claimed or missing".into()));
}
fs::create_dir_all(processing_dir(queue)).map_err(ResumaError::Io)?;
let dst = processing_path(queue, id)?;
fs::rename(&src, &dst).map_err(|e| ResumaError::Other(format!("claim failed: {e}")))
}
pub fn claim_next(queue: &str) -> Option<QueueMessage> {
let dir = pending_dir(queue);
let Ok(entries) = fs::read_dir(&dir) else {
return None;
};
let mut names: Vec<String> = entries
.flatten()
.filter_map(|e| {
let path = e.path();
if path.extension().and_then(|x| x.to_str()) != Some("json") {
return None;
}
path.file_stem()?.to_str().map(str::to_string)
})
.collect();
names.sort();
for id in names {
if validate_schedule_id(&id).is_err() {
continue;
}
if try_claim(queue, &id).is_err() {
continue;
}
let path = processing_path(queue, &id).ok()?;
let data = fs::read_to_string(&path).ok()?;
return serde_json::from_str(&data).ok();
}
None
}
pub fn complete(queue: &str, id: &str, success: bool) -> Result<()> {
let src = processing_path(queue, id)?;
if !src.exists() {
return Ok(());
}
let dest_dir = if success {
done_dir(queue)
} else {
failed_dir(queue)
};
fs::create_dir_all(&dest_dir).map_err(ResumaError::Io)?;
let dest = dest_dir.join(format!("{id}.json"));
fs::rename(&src, &dest).or_else(|_| {
let data = fs::read(&src).map_err(ResumaError::Io)?;
fs::write(&dest, data).map_err(ResumaError::Io)?;
fs::remove_file(&src).map_err(ResumaError::Io)
})
}
#[deprecated(note = "use complete(queue, id, true)")]
pub fn mark_done(queue: &str, id: &str) -> Result<()> {
complete(queue, id, true)
}
#[deprecated(note = "use complete(queue, id, false)")]
pub fn mark_failed(queue: &str, id: &str) -> Result<()> {
complete(queue, id, false)
}
pub fn recover_processing(queue: &str) -> usize {
let proc_dir = processing_dir(queue);
let pending = pending_dir(queue);
let _ = fs::create_dir_all(&pending);
let Ok(entries) = fs::read_dir(&proc_dir) else {
return 0;
};
let mut n = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if validate_schedule_id(id).is_err() {
continue;
}
let Ok(dest) = pending_path(queue, id) else {
continue;
};
if fs::rename(&path, &dest).is_ok() {
n += 1;
}
}
n
}
pub fn list_queues() -> Vec<String> {
let Ok(entries) = fs::read_dir(root()) else {
return Vec::new();
};
entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.collect()
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct QueueStats {
pub queue: String,
pub pending: usize,
pub processing: usize,
pub done: usize,
pub failed: usize,
}
pub fn stats(queue: &str) -> QueueStats {
QueueStats {
queue: queue.to_string(),
pending: count_json_files(&pending_dir(queue)),
processing: count_json_files(&processing_dir(queue)),
done: count_json_files(&done_dir(queue)),
failed: count_json_files(&failed_dir(queue)),
}
}
fn count_json_files(dir: &Path) -> usize {
fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json"))
.count()
})
.unwrap_or(0)
}
#[cfg(test)]
pub(crate) fn exec_test_lock() -> &'static parking_lot::Mutex<()> {
static LOCK: once_cell::sync::Lazy<parking_lot::Mutex<()>> =
once_cell::sync::Lazy::new(|| parking_lot::Mutex::new(()));
&LOCK
}
#[cfg(test)]
pub(crate) fn test_queue_lock() -> &'static parking_lot::Mutex<()> {
static LOCK: once_cell::sync::OnceCell<parking_lot::Mutex<()>> =
once_cell::sync::OnceCell::new();
LOCK.get_or_init(|| parking_lot::Mutex::new(()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn temp_queue() -> PathBuf {
let p = std::env::temp_dir().join(format!("resuma-q-{}", crate::exec::id::next_id()));
configure(&p);
p
}
#[test]
fn claim_is_exclusive() {
let _guard = test_queue_lock().lock();
let _root = temp_queue();
let msg = QueueMessage {
id: "m_test".into(),
worker: "w".into(),
input: json!({}),
};
persist_pending("default", &msg).unwrap();
let first = claim_next("default").unwrap();
assert_eq!(first.id, "m_test");
assert!(claim_next("default").is_none());
complete("default", "m_test", true).unwrap();
assert_eq!(stats("default").done, 1);
}
#[test]
fn claim_rejects_path_traversal_filename() {
let _guard = test_queue_lock().lock();
let _root = temp_queue();
let dir = pending_dir("default");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("..%2f..%2foutside.json"), b"{}").unwrap();
assert!(claim_next("default").is_none());
}
}