Files
doctate/server/src/main.rs
T
Brummel 76bf65be1a Refactor: Track analysis jobs via InFlight struct
This commit replaces the disk-based `analysis_input.json` marker for
in-progress analysis jobs with an in-memory
`Arc<RwLock<HashSet<PathBuf>>>`.

Previously, the existence of `analysis_input.json` was used as a signal
that a case was queued or being analyzed. This led to stale "Queued"
states after server restarts, as the disk file would persist while the
server process tracking it had vanished.

The new `InFlight` struct, held in `AppState`, provides a process-local
marker. This ensures that:

- Server restarts correctly reset the state, as the `HashSet` is
  cleared.
- Race conditions for triggering analysis are handled by the `try_claim`
  method, replacing the previous reliance on file system `O_EXCL` flags.
- The `AnalyzeJob` payload now travels with the job over the channel,
  eliminating the need for workers to re-read `analysis_input.json` from
  disk.

This change simplifies state management and improves the reliability of
analysis job tracking.
2026-05-05 20:01:07 +02:00

264 lines
9.5 KiB
Rust

use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::TraceLayer;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use doctate_server::AppState;
use doctate_server::analyze;
use doctate_server::config::{AsrBackend, Config};
use doctate_server::events;
use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
use doctate_server::settings::Settings;
use doctate_server::transcribe;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let config = Arc::new(Config::from_env());
let settings_path = std::env::var("SETTINGS_FILE").unwrap_or_else(|_| "settings.toml".into());
let settings = Arc::new(Settings::load_or_default(&settings_path));
// Logging: stdout + daily rotating log files.
let env_filter =
EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let file_appender = tracing_appender::rolling::daily(&config.log_path, "recorder.log");
let (file_writer, _guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_writer(std::io::stdout),
)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_ansi(false)
.with_writer(file_writer),
)
.init();
// Surface configuration gaps that silently disable features.
let backends = doctate_server::analyze::backend::backends();
let available = doctate_server::analyze::backend::available_backends();
info!(
total = backends.len(),
available = available.len(),
"LLM backends loaded"
);
for b in backends {
info!(
id = %b.id,
label = %b.label,
model = %b.model_id,
available = b.is_available(),
requires_api_key = b.requires_api_key,
"LLM backend"
);
}
if available.is_empty() {
warn!(
"No LLM backend available — analyze button will be hidden. \
Set IONOS_API_KEY (or another provider's env var) to enable."
);
}
info!(backend = ?config.asr_backend, "ASR backend");
if config.asr_backend == AsrBackend::Canary {
warn!(
"ASR_BACKEND=canary — Whisper container on the GPU host \
must be stopped (12 GB VRAM budget shared with Ollama; \
see docs/canary.md §2.6)."
);
}
// Shared HTTP client for both downstream pipelines.
let http_client = reqwest::Client::builder()
.build()
.expect("reqwest client build failed");
// Gazetteer: post-AI terminology normalization filter applied to
// every AI output (Whisper, oneliner, analyze). Missing / unreadable
// vocab directory is non-fatal — the pipeline runs without
// normalization.
let base_gazetteer = match Gazetteer::load_dir(&config.vocab_dir).await {
Ok(g) => {
info!(
dir = %config.vocab_dir.display(),
entries = g.len(),
"Gazetteer loaded"
);
g
}
Err(e) => {
warn!(
dir = %config.vocab_dir.display(),
error = %e,
"Gazetteer not available; running without proper-name correction"
);
Gazetteer::empty()
}
};
// Dict-veto: preserve tokens that are real German words, even if
// they sit at DL ≤ 2 from a vocab entry (e.g. `Kaktus` stays
// `Kaktus`, does not become `Lantus`). Missing / unreadable
// hunspell files are non-fatal — pipeline runs without the veto.
let vocab = Arc::new(match SpellbookDict::load(&config.hunspell_dict_path) {
Ok(dict) => {
info!(
stem = %config.hunspell_dict_path.display(),
"Hunspell dict loaded for gazetteer veto"
);
base_gazetteer.with_dict(Box::new(dict))
}
Err(e) => {
warn!(
stem = %config.hunspell_dict_path.display(),
error = %e,
"Hunspell dict unavailable; gazetteer running without dict veto"
);
base_gazetteer
}
});
// Live-update bus: background workers and handlers publish case
// events here; the SSE route at `/events` fans them out to
// subscribed browsers.
let events_tx = events::channel();
// Dedup flag shared by the startup oneliner-regen task and every
// request-driven `heal_orphans_if_idle` spawn. One-at-a-time semantics
// keep Ollama from seeing overlapping regen loops.
let oneliner_heal_busy: doctate_server::WorkerBusy =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
// Per-`case_dir` mutex registry. Shared by the PUT handler (manual
// override write) and the transcribe worker (post-LLM re-check)
// so a doctor's manual edit always wins over a concurrent
// auto-regen, no matter the timing.
let oneliner_locks = doctate_server::oneliner_locks::OnelinerLocks::new();
// Transcription pipeline: channel + worker + recovery scan.
let (transcribe_tx, transcribe_rx) = transcribe::channel();
let transcribe_busy: doctate_server::WorkerBusy =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
tokio::spawn(transcribe::worker::run(
transcribe_rx,
config.clone(),
settings.clone(),
http_client.clone(),
transcribe_busy.clone(),
vocab.clone(),
events_tx.clone(),
oneliner_locks.clone(),
));
{
let tx = transcribe_tx.clone();
let data_path = config.data_path.clone();
let client = http_client.clone();
let settings = settings.clone();
let vocab = vocab.clone();
let events = events_tx.clone();
let heal_busy = oneliner_heal_busy.clone();
let locks = oneliner_locks.clone();
tokio::spawn(async move {
transcribe::recovery::scan_and_enqueue(&data_path, &tx).await;
// Then catch up oneliners for cases that crashed between
// transcript-write and oneliner-write in a previous run.
// Hold the shared heal-busy flag while running so a concurrent
// page-load does not spawn a second parallel regen loop.
let _guard = doctate_server::BusyGuard::new(heal_busy);
transcribe::recovery::regenerate_missing_oneliners(
&data_path, &client, &settings, &vocab, &events, &locks,
)
.await;
});
}
// Analyze pipeline: channel + worker. Boot-scan is observation-only —
// the per-render `try_enqueue_all_for_user` covers auto-trigger via
// the typed `CaseAnalysisState` machine.
let (analyze_tx, analyze_rx) = analyze::channel();
let analyze_busy: doctate_server::WorkerBusy =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let analyze_in_flight = analyze::InFlight::new();
tokio::spawn(analyze::worker::run(
analyze_rx,
http_client.clone(),
analyze_busy.clone(),
analyze_in_flight.clone(),
vocab.clone(),
events_tx.clone(),
));
let server_boot_time = std::time::SystemTime::now();
{
let data_path = config.data_path.clone();
let idle_threshold = std::time::Duration::from_secs(config.analysis_idle_threshold_secs);
let in_flight = analyze_in_flight.clone();
tokio::spawn(async move {
analyze::recovery::boot_scan_state(
&data_path,
idle_threshold,
std::time::SystemTime::now(),
server_boot_time,
&in_flight,
)
.await;
});
}
let state = AppState {
config: config.clone(),
settings: settings.clone(),
transcribe_tx,
analyze_tx,
session_store: doctate_server::web_session::new_store(),
magic_link_store: doctate_server::magic_link::new_store(),
analyze_busy: doctate_server::AnalyzeBusy(analyze_busy),
transcribe_busy: doctate_server::TranscribeBusy(transcribe_busy),
oneliner_heal_busy: doctate_server::OnelinerHealBusy(oneliner_heal_busy),
oneliner_locks,
events_tx,
http_client,
vocab,
boot_time: doctate_server::BootTime(server_boot_time),
analyze_in_flight,
};
let addr = format!("0.0.0.0:{}", config.server_port);
info!(port = config.server_port, "Server starting");
let app = doctate_server::create_router_with_state(state)
.layer(TraceLayer::new_for_http())
.layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)); // 50 MB
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
eprintln!(
"Port {} is already in use. Is another doctate-server instance running?",
config.server_port
);
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to bind to {addr}: {e}");
std::process::exit(1);
}
};
if let Err(e) = axum::serve(listener, app).await {
eprintln!("Server runtime error: {e}");
std::process::exit(1);
}
}