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
+7 -2
View File
@@ -7,6 +7,7 @@ use super::{TranscribeJob, TranscribeSender};
use crate::config::Config;
use crate::events::EventSender;
use crate::gazetteer::Gazetteer;
use crate::oneliner_locks::OnelinerLocks;
use crate::paths;
/// Walk `data_path/*/` and enqueue every recording pending transcription for
@@ -206,6 +207,7 @@ pub async fn regenerate_missing_oneliners(
config: &Config,
vocab: &Gazetteer,
events_tx: &EventSender,
locks: &OnelinerLocks,
) {
let cases = cases_needing_oneliner(data_path).await;
if cases.is_empty() {
@@ -213,7 +215,8 @@ pub async fn regenerate_missing_oneliners(
}
info!(count = cases.len(), "Regenerating missing oneliners");
for (case_dir, slug) in cases {
super::worker::update_oneliner(&case_dir, &slug, client, config, vocab, events_tx).await;
super::worker::update_oneliner(&case_dir, &slug, client, config, vocab, events_tx, locks)
.await;
}
}
@@ -228,13 +231,15 @@ pub async fn regenerate_missing_oneliners_for_user(
config: &Config,
vocab: &Gazetteer,
events_tx: &EventSender,
locks: &OnelinerLocks,
) {
let cases = cases_needing_oneliner_in(user_root).await;
if cases.is_empty() {
return;
}
for case_dir in cases {
super::worker::update_oneliner(&case_dir, slug, client, config, vocab, events_tx).await;
super::worker::update_oneliner(&case_dir, slug, client, config, vocab, events_tx, locks)
.await;
}
}
+51 -1
View File
@@ -12,6 +12,7 @@ use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
use crate::config::{Config, WhisperUserSettings};
use crate::events::{self, CaseEventKind, EventSender};
use crate::gazetteer::Gazetteer;
use crate::oneliner_locks::OnelinerLocks;
use crate::paths;
use crate::{BusyGuard, WorkerBusy};
@@ -26,6 +27,7 @@ pub async fn run(
worker_busy: WorkerBusy,
vocab: Arc<Gazetteer>,
events_tx: EventSender,
oneliner_locks: OnelinerLocks,
) {
info!("Transcription worker started");
let timeout = Duration::from_secs(config.whisper_timeout_seconds);
@@ -140,6 +142,7 @@ pub async fn run(
&config,
&vocab,
&events_tx,
&oneliner_locks,
)
.await;
}
@@ -213,14 +216,29 @@ async fn write_duration_sidecar(audio_path: &Path, probe_path: &Path) {
/// stuck on "Generating" forever.
/// - Still-`Pending` recordings present → no-op; the next transcript
/// write re-enters this function with more information.
pub(crate) async fn update_oneliner(
pub async fn update_oneliner(
case_dir: &Path,
user_slug: &str,
client: &reqwest::Client,
config: &Config,
vocab: &Gazetteer,
events_tx: &EventSender,
locks: &OnelinerLocks,
) {
// Early-Latch: a manual override is sticky. Skip the LLM call
// entirely so the doctor's edit cannot be overwritten and Ollama
// does not waste a 60s round-trip on a result that will be dropped.
// Read-only check, no lock needed.
let (existing, _) = paths::read_oneliner_state(case_dir).await;
if matches!(existing, Some(OnelinerState::Manual { .. })) {
info!(
user = %user_slug,
case = %case_dir.display(),
"oneliner manual override is sticky — skip auto-regen"
);
return;
}
let transcript = match all_transcripts_joined(case_dir).await {
Some(t) => t,
None => {
@@ -237,6 +255,19 @@ pub(crate) async fn update_oneliner(
.format(&Rfc3339)
.unwrap_or_default();
let state = OnelinerState::Empty { generated_at };
// Re-check under lock: a PUT may have arrived between
// the early-latch read and now (no LLM call here, but
// the silent-empty resolution still races with PUTs).
let _guard = locks.lock_for(case_dir).await;
let (current, _) = paths::read_oneliner_state(case_dir).await;
if matches!(current, Some(OnelinerState::Manual { .. })) {
info!(
user = %user_slug,
case = %case_dir.display(),
"manual override appeared during silent-empty resolution — drop"
);
return;
}
if let Err(e) = paths::write_oneliner_state(case_dir, &state).await {
error!(
case = %case_dir.display(),
@@ -265,6 +296,10 @@ pub(crate) async fn update_oneliner(
.format(&Rfc3339)
.unwrap_or_default();
// LLM call runs WITHOUT the lock — holding the per-case mutex for
// 60s would block any concurrent PUT for the same case. The
// post-LLM re-check (below) catches manual overrides that arrived
// during the call.
let state = match ollama::generate_oneliner(
client,
&config.ollama_url,
@@ -298,6 +333,21 @@ pub(crate) async fn update_oneliner(
}
};
// Re-check under the per-case lock: if a manual override landed
// while the LLM was thinking, drop the auto result so the doctor's
// edit always wins. The PUT handler holds the same lock around its
// write, so the read-and-write below is atomic vs. that path.
let _guard = locks.lock_for(case_dir).await;
let (current, _) = paths::read_oneliner_state(case_dir).await;
if matches!(current, Some(OnelinerState::Manual { .. })) {
info!(
user = %user_slug,
case = %case_dir.display(),
"manual override appeared during regen — dropping LLM result"
);
return;
}
if let Err(e) = paths::write_oneliner_state(case_dir, &state).await {
error!(case = %case_dir.display(), error = %e, "writing oneliner.json failed");
return;