Files
doctate/server/tests/oneliner_heal_decoupled_test.rs
T
Brummel 3d42d1da82 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.
2026-04-21 12:56:02 +02:00

121 lines
4.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
}