feat: Add per-case dir oneliner locks

Introduces `OnelinerLocks` to serialize read-modify-write operations on
`oneliner.json`. This prevents race conditions between the manual
override API (`PUT /api/cases/{case_id}/oneliner`) and the transcription
worker's auto-regeneration process.

The manual override now acquires a lock specific to the `case_dir`
before writing. The transcription worker's `update_oneliner` function
also acquires the same lock around its post-LLM re-read-and-write
sequence. This ensures that a doctor's manual edit always takes
precedence, even if it arrives while an auto-regeneration is in
progress.

This change also includes:
- A new `routes::oneliner_override` module for the PUT handler.
- Validation for the oneliner text length and emptiness.
- Integration tests for the override functionality, covering happy path,
  error conditions, early latching, race conditions, and parallel PUTs.
This commit is contained in:
2026-04-26 16:05:01 +02:00
parent 73fa099b51
commit c769abe3d5
17 changed files with 705 additions and 12 deletions
+12
View File
@@ -8,6 +8,7 @@ pub mod events;
pub mod gazetteer;
pub mod magic_link;
pub mod models;
pub mod oneliner_locks;
pub mod paths;
pub mod retention;
pub mod routes;
@@ -26,6 +27,7 @@ use analyze::AnalyzeSender;
use config::Config;
use gazetteer::Gazetteer;
use magic_link::MagicLinkStore;
use oneliner_locks::OnelinerLocks;
use transcribe::TranscribeSender;
use web_session::SessionStore;
@@ -78,6 +80,7 @@ pub struct PipelineState {
pub transcribe_busy: TranscribeBusy,
pub transcribe_tx: TranscribeSender,
pub oneliner_heal_busy: OnelinerHealBusy,
pub oneliner_locks: OnelinerLocks,
}
/// Shared application state. Clonable — all fields are cheap to clone
@@ -92,6 +95,7 @@ pub struct AppState {
pub analyze_busy: AnalyzeBusy,
pub transcribe_busy: TranscribeBusy,
pub oneliner_heal_busy: OnelinerHealBusy,
pub oneliner_locks: OnelinerLocks,
pub events_tx: events::EventSender,
pub http_client: reqwest::Client,
pub vocab: Arc<Gazetteer>,
@@ -145,6 +149,12 @@ impl FromRef<AppState> for OnelinerHealBusy {
}
}
impl FromRef<AppState> for OnelinerLocks {
fn from_ref(state: &AppState) -> Self {
state.oneliner_locks.clone()
}
}
impl FromRef<AppState> for PipelineState {
fn from_ref(state: &AppState) -> Self {
PipelineState {
@@ -153,6 +163,7 @@ impl FromRef<AppState> for PipelineState {
transcribe_busy: state.transcribe_busy.clone(),
transcribe_tx: state.transcribe_tx.clone(),
oneliner_heal_busy: state.oneliner_heal_busy.clone(),
oneliner_locks: state.oneliner_locks.clone(),
}
}
}
@@ -200,6 +211,7 @@ pub fn create_router_and_session_store(config: Arc<Config>) -> (Router, SessionS
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))),
oneliner_locks: OnelinerLocks::new(),
events_tx: events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(Gazetteer::empty()),