Refactor ETag generation for oneliners API

Remove the per-user oneliner watermark, which was previously used to
generate ETags for the `/api/oneliners` endpoint. The watermark was
intended to track the last successful oneliner write for each user.

The ETag generation has been refactored to use a deterministic FNV-1a
hash. This hash is computed over a sorted list of tuples containing
`(case_id, last_recording_at_ns, oneliner_mtime_ns)` for all visible
cases. This approach ensures that the ETag changes whenever any
listen-relevant file system modification occurs, such as a new
recording, oneliner regeneration, soft-delete, undo, or
upload-auto-reopen. This new method is more robust and avoids the
complexity of managing per-user state.

The `OnelinerWatermark` type alias and its associated logic have been
removed from `AppState` and the relevant modules (`transcribe::worker`,
`transcribe::recovery`, `routes::oneliners`, `main`). New integration
tests have been added to verify that various delete operations (single
delete, undo delete, bulk delete) correctly trigger an ETag update,
ensuring cache invalidation.
This commit is contained in:
2026-04-19 14:47:30 +02:00
parent 5e8d2f8e58
commit a1ff410d1b
11 changed files with 425 additions and 80 deletions
+2 -6
View File
@@ -5,8 +5,6 @@ use tracing::{info, warn};
use super::{TranscribeJob, TranscribeSender};
use crate::config::Config;
use crate::gazetteer::Gazetteer;
use crate::OnelinerWatermark;
/// Walk `data_path/*/` and enqueue every recording pending transcription for
/// every user. Intended for startup; the same primitive backs the per-user
/// self-heal triggered by web handlers.
@@ -87,8 +85,7 @@ pub async fn enqueue_pending_for_user(
/// one non-empty `*.transcript.txt` but no `oneliner.txt`, paired with the
/// user-slug (parent directory name). Pure filesystem scan, no LLM calls —
/// separated from the regeneration wrapper so it can be unit-tested without
/// mocking Ollama. The slug is carried through so the caller can bump the
/// per-user watermark when regeneration succeeds.
/// mocking Ollama.
pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, String)> {
let mut out: Vec<(PathBuf, String)> = Vec::new();
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
@@ -152,7 +149,6 @@ pub async fn regenerate_missing_oneliners(
client: &reqwest::Client,
config: &Config,
vocab: &Gazetteer,
watermark: &OnelinerWatermark,
) {
let cases = cases_needing_oneliner(data_path).await;
if cases.is_empty() {
@@ -160,7 +156,7 @@ 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, watermark).await;
super::worker::update_oneliner(&case_dir, &slug, client, config, vocab).await;
}
}
+8 -12
View File
@@ -2,13 +2,12 @@ use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tracing::{error, info, warn};
use super::{ffmpeg, ollama, whisper, TranscribeReceiver};
use crate::config::{Config, WhisperUserSettings};
use crate::gazetteer::Gazetteer;
use crate::{BusyGuard, OnelinerWatermark, WorkerBusy};
use crate::{BusyGuard, WorkerBusy};
const ONELINER_TIMEOUT: Duration = Duration::from_secs(60);
@@ -20,7 +19,6 @@ pub async fn run(
client: reqwest::Client,
worker_busy: WorkerBusy,
vocab: Arc<Gazetteer>,
watermark: OnelinerWatermark,
) {
info!("Transcription worker started");
let timeout = Duration::from_secs(config.whisper_timeout_seconds);
@@ -90,7 +88,7 @@ pub async fn run(
if let Some(case_dir) = audio_path.parent()
&& !has_pending_recordings(case_dir).await
{
update_oneliner(case_dir, &job.user_slug, &client, &config, &vocab, &watermark).await;
update_oneliner(case_dir, &job.user_slug, &client, &config, &vocab).await;
}
}
@@ -133,7 +131,6 @@ pub(crate) async fn update_oneliner(
client: &reqwest::Client,
config: &Config,
vocab: &Gazetteer,
watermark: &OnelinerWatermark,
) {
let path = case_dir.join("oneliner.txt");
@@ -157,13 +154,12 @@ pub(crate) async fn update_oneliner(
if let Err(e) = tokio::fs::write(&path, &line).await {
error!(path = %path.display(), error = %e, "writing oneliner failed");
} else {
info!(path = %path.display(), chars = line.chars().count(), "Oneliner updated");
// Bump the per-user watermark so the next `/api/oneliners`
// poll sees a fresh ETag and fetches the new content.
watermark
.write()
.await
.insert(user_slug.to_string(), OffsetDateTime::now_utc());
info!(
user = %user_slug,
path = %path.display(),
chars = line.chars().count(),
"Oneliner updated"
);
}
}
Err(e) => {