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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user