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
+4 -1
View File
@@ -11,7 +11,9 @@ use tower::util::ServiceExt;
use doctate_common::API_KEY_HEADER;
use doctate_server::config::{Config, User};
use doctate_server::magic_link::{MagicLinkStore, PendingMagicLink};
use doctate_server::{AnalyzeBusy, AppState, TranscribeBusy, analyze, transcribe, web_session};
use doctate_server::{
AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session,
};
const API_KEY: &str = "key-dr_a";
@@ -55,6 +57,7 @@ fn build_state() -> (AppState, Arc<Config>) {
magic_link_store,
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
events_tx: doctate_server::events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
@@ -0,0 +1,120 @@
//! Regression test: `PipelineState::heal_orphans_if_idle` must not block
//! the request handler on Ollama. Before the fix, the function awaited a
//! sequential `regenerate_missing_oneliners_for_user` loop inline, so the
//! HTTP handler stayed pinned for N × LLM-latency. The fix detaches the
//! regeneration via `tokio::spawn`, guarded by a `compare_exchange` on
//! `OnelinerHealBusy` to dedup concurrent page-loads.
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use doctate_server::config::Config;
use doctate_server::events;
use doctate_server::gazetteer::Gazetteer;
use doctate_server::{
AnalyzeBusy, OnelinerHealBusy, PipelineState, TranscribeBusy, WorkerBusy, analyze, transcribe,
};
use serde_json::json;
use tempfile::tempdir;
use tokio::time::timeout;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn write_transcript(case_dir: &Path) {
let p = case_dir.join("2026-04-16T10-00-00Z.transcript.txt");
std::fs::write(p, "Brustschmerz links, seit heute morgen.").unwrap();
}
#[tokio::test]
async fn heal_orphans_returns_fast_and_dedups_parallel_calls() {
// Five cases with a transcript but no oneliner.json. Without the fix,
// heal_orphans_if_idle would sequentially block on 5 × 200 ms = 1 s of
// mock delay. With the fix, it must return in well under that window.
let data = tempdir().unwrap();
let slug = "dr_test";
let user_root = data.path().join(slug);
std::fs::create_dir_all(&user_root).unwrap();
for i in 0..5 {
let case_dir = user_root.join(format!("case-{i:02}"));
std::fs::create_dir_all(&case_dir).unwrap();
write_transcript(&case_dir);
}
// Ollama mock: 200 ms delay per chat call. `.expect(5)` asserts at
// MockServer drop that exactly five requests arrived — dedup would
// show up as 10 without the CAS guard.
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/api/chat"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_json(json!({
"message": { "content": "Brustschmerz links" }
})),
)
.expect(5)
.mount(&mock)
.await;
let mut cfg = Config::test_default();
cfg.data_path = data.path().to_path_buf();
cfg.ollama_url = mock.uri();
let config = Arc::new(cfg);
let vocab = Arc::new(Gazetteer::empty());
let events_tx = events::channel();
let http_client = reqwest::Client::new();
let (tx_a, _rx_a) = analyze::channel();
let (tx_t, _rx_t) = transcribe::channel();
let heal_busy: WorkerBusy = Arc::new(AtomicBool::new(false));
let pipeline = PipelineState {
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
analyze_tx: tx_a,
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
transcribe_tx: tx_t,
oneliner_heal_busy: OnelinerHealBusy(heal_busy.clone()),
};
// Two back-to-back heal invocations. The first wins the CAS and
// spawns. The second must no-op. Both heal futures themselves must
// finish in a fraction of the mock delay.
let before = Instant::now();
timeout(Duration::from_millis(150), async {
pipeline
.heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx)
.await;
pipeline
.heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx)
.await;
})
.await
.expect("heal_orphans_if_idle must return without awaiting the LLM loop");
assert!(
before.elapsed() < Duration::from_millis(150),
"heal_orphans returned in {:?} — the inline regen is back",
before.elapsed()
);
// Spawn drops the busy flag on completion — poll until idle.
let poll_start = Instant::now();
while heal_busy.load(Ordering::Acquire) {
if poll_start.elapsed() > Duration::from_secs(5) {
panic!("oneliner heal spawn did not finish within 5s");
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
// Every case now has an oneliner.json.
for i in 0..5 {
let p = user_root.join(format!("case-{i:02}")).join("oneliner.json");
assert!(p.exists(), "oneliner.json missing for case-{i:02}");
}
// Mock is dropped at end of scope → `.expect(5)` verifies no
// double-firing. A failing expectation panics with the actual count.
}