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.
This commit is contained in:
2026-04-16 00:19:08 +02:00
parent 4e145dd167
commit 9072d7a833
7 changed files with 191 additions and 84 deletions
+77 -14
View File
@@ -1,4 +1,5 @@
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use askama::Template;
@@ -6,15 +7,19 @@ use axum::extract::{Path as AxumPath, State};
use axum::response::Html;
use tracing::{info, warn};
use crate::analyze::{recovery as analyze_recovery, AnalyzeSender};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::routes::web::{scan_recordings, RecordingView};
use crate::WorkerBusy;
struct UserCaseView {
case_id: String,
case_id_short: String,
most_recent: String,
/// HH:MM extracted from `most_recent` filename (e.g. "10:32"),
/// empty string if filename does not match the expected timestamp form.
time_hms: String,
oneliner: Option<String>,
recordings_count: usize,
analyzing: bool,
@@ -25,6 +30,19 @@ struct UserCaseView {
can_analyze: bool,
}
/// Extract `HH:MM` from a recording filename of the form
/// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch.
fn extract_hhmm(filename: &str) -> String {
let (Some(h), Some(m)) = (filename.get(11..13), filename.get(14..16)) else {
return String::new();
};
if h.bytes().all(|b| b.is_ascii_digit()) && m.bytes().all(|b| b.is_ascii_digit()) {
format!("{h}:{m}")
} else {
String::new()
}
}
#[derive(Template)]
#[template(path = "my_cases.html")]
struct MyCasesTemplate {
@@ -33,6 +51,8 @@ struct MyCasesTemplate {
/// Number of cases that "Undo last delete" would restore. 0 hides the
/// undo button entirely.
undo_count: usize,
/// True iff the session user is an admin — toggles full case_id display.
is_admin: bool,
}
#[derive(Template)]
@@ -65,12 +85,13 @@ async fn compute_flags(
case_dir: &Path,
recordings: &[RecordingView],
llm_configured: bool,
worker_busy: bool,
) -> CaseFlags {
let has_document = any_document_exists(case_dir).await;
let analyzing = !has_document
&& tokio::fs::try_exists(case_dir.join("analysis_input_v1.json"))
.await
.unwrap_or(false);
// Honest status: an input file alone does not mean a job is in flight.
// The worker may be idle and the file an orphan from a crash. Page-level
// self-heal (see `heal_orphans_if_idle`) re-enqueues those.
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_dir).await;
let non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect();
let all_transcribed =
@@ -86,6 +107,24 @@ async fn compute_flags(
}
}
/// True iff the case directory contains at least one `analysis_input_v*.json`.
/// The worker removes the input after successful document write, so existence
/// implies a pending or in-flight analyze job.
pub(crate) async fn any_analysis_input_exists(case_dir: &Path) -> bool {
let mut entries = match tokio::fs::read_dir(case_dir).await {
Ok(r) => r,
Err(_) => return false,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let Some(name_str) = name.to_str() else { continue };
if name_str.starts_with("analysis_input_v") && name_str.ends_with(".json") {
return true;
}
}
false
}
/// True iff the case directory contains at least one `document_v{N}.md`.
/// We don't care about N here — any version means "Ausgewertet" for the UI.
pub(crate) async fn any_document_exists(case_dir: &Path) -> bool {
@@ -103,20 +142,41 @@ pub(crate) async fn any_document_exists(case_dir: &Path) -> bool {
false
}
/// Self-heal: if the analyze worker is idle but orphan inputs (an input file
/// without its document) exist for this user, re-enqueue them. Page-load is
/// the trigger; no cron, no periodic task.
async fn heal_orphans_if_idle(
user_root: &Path,
worker_busy: &WorkerBusy,
analyze_tx: &AnalyzeSender,
) {
if worker_busy.load(Ordering::Acquire) {
return;
}
analyze_recovery::enqueue_pending_for_user(user_root, analyze_tx).await;
}
pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(worker_busy): State<WorkerBusy>,
State(analyze_tx): State<AnalyzeSender>,
) -> Result<Html<String>, AppError> {
let cases = scan_user_cases(&config, &user.slug).await;
let user_root = config.data_path.join(&user.slug);
heal_orphans_if_idle(&user_root, &worker_busy, &analyze_tx).await;
let busy = worker_busy.load(Ordering::Acquire);
let cases = scan_user_cases(&config, &user.slug, busy).await;
let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root)
.await
.map(|(_, n)| n)
.unwrap_or(0);
let is_admin = user.is_admin();
MyCasesTemplate {
slug: user.slug,
cases,
undo_count,
is_admin,
}
.render()
.map(Html)
@@ -126,6 +186,8 @@ pub async fn handle_my_cases(
pub async fn handle_case_detail(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(worker_busy): State<WorkerBusy>,
State(analyze_tx): State<AnalyzeSender>,
AxumPath(case_id): AxumPath<String>,
) -> Result<Html<String>, AppError> {
uuid::Uuid::parse_str(&case_id)
@@ -133,6 +195,8 @@ pub async fn handle_case_detail(
// IDOR guard: case_dir must live under the session's user_slug.
let user_root = config.data_path.join(&user.slug);
heal_orphans_if_idle(&user_root, &worker_busy, &analyze_tx).await;
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
@@ -149,7 +213,8 @@ pub async fn handle_case_detail(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let flags = compute_flags(&case_dir, &recordings, config.llm_configured()).await;
let busy = worker_busy.load(Ordering::Acquire);
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), busy).await;
let case_id_short = case_id.chars().take(8).collect();
CaseDetailTemplate {
@@ -186,7 +251,7 @@ pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<std::
/// recent recording filename (lexicographic ≈ chronological since
/// timestamps are ISO-8601), newest first. Soft-deleted cases (with
/// `.deleted` marker) are excluded.
async fn scan_user_cases(config: &Config, slug: &str) -> Vec<UserCaseView> {
async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<UserCaseView> {
let user_root = config.data_path.join(slug);
let llm_configured = config.llm_configured();
let mut cases = Vec::new();
@@ -230,7 +295,6 @@ async fn scan_user_cases(config: &Config, slug: &str) -> Vec<UserCaseView> {
.filter(|r| !r.failed)
.all(|r| r.transcript.is_some());
let case_id_short = case_id.chars().take(8).collect();
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
.await
.ok()
@@ -238,16 +302,15 @@ async fn scan_user_cases(config: &Config, slug: &str) -> Vec<UserCaseView> {
.filter(|s| !s.is_empty());
let has_document = any_document_exists(&case_path).await;
let analyzing = !has_document
&& tokio::fs::try_exists(case_path.join("analysis_input_v1.json"))
.await
.unwrap_or(false);
let analyzing =
!has_document && worker_busy && any_analysis_input_exists(&case_path).await;
let can_analyze = !analyzing && all_transcribed && llm_configured;
let time_hms = extract_hhmm(&most_recent);
cases.push(UserCaseView {
case_id,
case_id_short,
most_recent,
time_hms,
oneliner,
recordings_count,
analyzing,