fix: decouple oneliner-heal from request handler tasks

heal_orphans_if_idle called regenerate_missing_oneliners_for_user
inline on the request task, blocking the browser for up to N×60s
when orphans existed (observed with 16 cases after manual cleanup).

Now: CAS on new OnelinerHealBusy flag + tokio::spawn + BusyGuard
reset-on-drop. Handler returns immediately; the existing
OnelinerUpdated SSE event delivers results via the usual reload.
Startup recovery shares the flag so a concurrent page-load during
boot cannot fire a parallel regen loop against Ollama.

Regression test uses wiremock + timeout(150ms) + .expect(5) to
verify both non-blocking return and dedup of parallel invocations.
This commit is contained in:
2026-04-21 12:56:02 +02:00
parent e5c8c5e3aa
commit 3d42d1da82
5 changed files with 193 additions and 16 deletions
+42 -15
View File
@@ -11,7 +11,6 @@ use tracing::{info, warn};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
use crate::PipelineState;
use crate::analyze::{
ANALYSIS_INPUT_FILE, DOCUMENT_FILE, auto_trigger, recovery as analyze_recovery,
};
@@ -25,6 +24,7 @@ use crate::paths;
use crate::routes::case_actions::read_document;
use crate::routes::web::{RecordingView, scan_recordings};
use crate::transcribe::recovery as transcribe_recovery;
use crate::{BusyGuard, PipelineState};
/// UI-layer oneliner status for the case list. Combines the persisted
/// [`OnelinerState`] with derived transit states that only make sense
@@ -299,8 +299,8 @@ impl PipelineState {
user_root: &Path,
slug: &str,
http_client: &reqwest::Client,
config: &Config,
vocab: &Gazetteer,
config: &Arc<Config>,
vocab: &Arc<Gazetteer>,
events_tx: &EventSender,
) {
if !self.analyze_busy.0.load(Ordering::Acquire) {
@@ -309,18 +309,45 @@ impl PipelineState {
if !self.transcribe_busy.0.load(Ordering::Acquire) {
transcribe_recovery::enqueue_pending_for_user(user_root, slug, &self.transcribe_tx)
.await;
// Oneliner self-heal: if the transcribe worker is busy, it will
// write a fresh oneliner at batch-end anyway — skip to avoid a
// redundant LLM call.
transcribe_recovery::regenerate_missing_oneliners_for_user(
user_root,
slug,
http_client,
config,
vocab,
events_tx,
)
.await;
// Oneliner self-heal: if the transcribe worker is busy, it
// will write a fresh oneliner at batch-end anyway — skip to
// avoid a redundant LLM call. Otherwise, fire the regen in a
// detached task so the request handler returns immediately;
// SSE `OnelinerUpdated` events deliver results to the browser.
// The CAS on `oneliner_heal_busy` guards against concurrent
// page-loads spawning their own parallel regen loops against
// the same cases.
if self
.oneliner_heal_busy
.0
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
let user_root = user_root.to_path_buf();
let slug_owned = slug.to_owned();
let http_client = http_client.clone();
let config = Arc::clone(config);
let vocab = Arc::clone(vocab);
let events_tx = events_tx.clone();
let busy = Arc::clone(&self.oneliner_heal_busy.0);
info!(user = %slug, "oneliner heal spawned");
tokio::spawn(async move {
// `BusyGuard::Drop` resets the flag to `false` — even
// on panic inside the regen loop. The redundant
// `store(true)` inside `BusyGuard::new` is harmless;
// the CAS above already owns the flag transition.
let _guard = BusyGuard::new(busy);
transcribe_recovery::regenerate_missing_oneliners_for_user(
&user_root,
&slug_owned,
&http_client,
&config,
&vocab,
&events_tx,
)
.await;
});
}
}
}
}