Files
doctate/server/src/analyze/recovery.rs
T
Brummel 9072d7a833 Refactor analyze recovery and worker busy status
The analyze recovery scan has been refactored to iterate through user
directories and enqueue pending analysis jobs more efficiently. The
`WorkerBusy` status has been introduced as an `Arc<AtomicBool>` to track
whether the analyze worker is currently processing a job.

This `WorkerBusy` flag is used in the `analyze::worker::run` function
and managed by a `BusyGuard` RAII struct, ensuring the flag is correctly
set and unset even in case of panics.

The `handle_my_cases` and `handle_case_detail` handlers in `user_web.rs`
now utilize the `WorkerBusy` flag to accurately display the "analyzing"
status and to trigger a self-heal of orphaned analysis inputs when the
worker is idle.

Additionally, the `WorkerBusy` type is now exported from
`server/src/lib.rs` to be accessible by other modules. The test cases
have been updated to include the `WorkerBusy` parameter when spawning
the analyze worker.
2026-04-16 00:19:08 +02:00

96 lines
3.3 KiB
Rust

use std::path::Path;
use tracing::{info, warn};
use super::{AnalyzeJob, AnalyzeSender};
/// Walk `data_path/*/` and enqueue every pending analysis for every user.
/// Intended to run once at startup; the same primitive backs the per-user
/// self-heal triggered by web handlers.
pub async fn scan_and_enqueue(data_path: &Path, tx: &AnalyzeSender) {
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
return;
};
let mut total = 0;
while let Ok(Some(user_entry)) = users.next_entry().await {
total += enqueue_pending_for_user(&user_entry.path(), tx).await;
}
info!(count = total, "Analyze recovery scan finished");
}
/// Scan a single `<user_root>/<case>/analysis_input_v*.json` set and enqueue
/// every input whose `document_v{N}.md` is missing. Returns the number sent.
/// Channel send is synchronous on the unbounded channel — only fails if the
/// worker is gone, which is a programming error.
pub async fn enqueue_pending_for_user(user_root: &Path, tx: &AnalyzeSender) -> usize {
let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
return 0;
};
let mut sent = 0;
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await {
continue;
}
let Ok(mut files) = tokio::fs::read_dir(&case_dir).await else {
continue;
};
while let Ok(Some(file)) = files.next_entry().await {
let path = file.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(version) = parse_input_version(name) else {
continue;
};
if case_dir.join(format!("document_v{version}.md")).exists() {
continue;
}
if tx
.send(AnalyzeJob {
case_dir: case_dir.clone(),
version,
})
.is_err()
{
warn!(case = %case_dir.display(), "recovery send failed (worker gone)");
return sent;
}
sent += 1;
}
}
sent
}
/// Parse `analysis_input_v{N}.json` and return `N` (as u32) if the pattern
/// matches exactly. Rejects malformed names, leading zeros, and overflow.
fn parse_input_version(filename: &str) -> Option<u32> {
let rest = filename.strip_prefix("analysis_input_v")?;
let num = rest.strip_suffix(".json")?;
if num.is_empty() || (num.starts_with('0') && num.len() > 1) {
return None;
}
num.parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_names() {
assert_eq!(parse_input_version("analysis_input_v1.json"), Some(1));
assert_eq!(parse_input_version("analysis_input_v42.json"), Some(42));
}
#[test]
fn rejects_invalid_names() {
assert_eq!(parse_input_version("analysis_input_v.json"), None);
assert_eq!(parse_input_version("analysis_input_v01.json"), None);
assert_eq!(parse_input_version("analysis_input_vx.json"), None);
assert_eq!(parse_input_version("document_v1.md"), None);
assert_eq!(parse_input_version("analysis_input_v1.txt"), None);
assert_eq!(parse_input_version(""), None);
}
}