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:
@@ -9,27 +9,17 @@ pub mod routes;
|
||||
pub mod transcribe;
|
||||
pub mod web_session;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use axum::Router;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use analyze::AnalyzeSender;
|
||||
use config::Config;
|
||||
use transcribe::TranscribeSender;
|
||||
use web_session::SessionStore;
|
||||
|
||||
/// Per-user UTC timestamp of the last successful oneliner write.
|
||||
/// Read on every `/api/oneliners` request to build the ETag; written
|
||||
/// from the transcribe worker and the boot recovery scan. Missing entry
|
||||
/// → the user has no known oneliner activity yet; the endpoint returns
|
||||
/// a full response and sets an ETag based on a `0` seed.
|
||||
pub type OnelinerWatermark = Arc<RwLock<HashMap<String, OffsetDateTime>>>;
|
||||
|
||||
/// Live "is this worker currently processing a job?" flag.
|
||||
/// Set true before each job, false after. UI uses it to distinguish a real
|
||||
/// in-flight job from a stale on-disk marker (orphan input, missing
|
||||
@@ -70,7 +60,6 @@ pub struct AppState {
|
||||
pub session_store: SessionStore,
|
||||
pub analyze_busy: AnalyzeBusy,
|
||||
pub transcribe_busy: TranscribeBusy,
|
||||
pub oneliner_watermark: OnelinerWatermark,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<Config> {
|
||||
@@ -109,12 +98,6 @@ impl FromRef<AppState> for TranscribeBusy {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for OnelinerWatermark {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.oneliner_watermark.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test/simple entrypoint: jobs pushed into either channel are dropped
|
||||
/// because the receivers are not retained. Use [`create_router_with_state`]
|
||||
/// from `main.rs` where real workers own the receivers.
|
||||
@@ -128,7 +111,6 @@ pub fn create_router(config: Arc<Config>) -> Router {
|
||||
session_store: web_session::new_store(),
|
||||
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
||||
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
||||
oneliner_watermark: Arc::new(RwLock::new(HashMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-12
@@ -12,7 +12,7 @@ use doctate_server::analyze;
|
||||
use doctate_server::config::Config;
|
||||
use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
|
||||
use doctate_server::transcribe;
|
||||
use doctate_server::{AppState, OnelinerWatermark};
|
||||
use doctate_server::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -110,13 +110,6 @@ async fn main() {
|
||||
}
|
||||
});
|
||||
|
||||
// Oneliner watermark: per-user "last successful oneliner write (UTC)".
|
||||
// Feeds the ETag on `/api/oneliners`. Shared across the transcribe
|
||||
// worker (writer) and the oneliners route handler (reader).
|
||||
let oneliner_watermark: OnelinerWatermark = Arc::new(tokio::sync::RwLock::new(
|
||||
std::collections::HashMap::new(),
|
||||
));
|
||||
|
||||
// Transcription pipeline: channel + worker + recovery scan.
|
||||
let (transcribe_tx, transcribe_rx) = transcribe::channel();
|
||||
let transcribe_busy: doctate_server::WorkerBusy =
|
||||
@@ -127,7 +120,6 @@ async fn main() {
|
||||
http_client.clone(),
|
||||
transcribe_busy.clone(),
|
||||
vocab.clone(),
|
||||
oneliner_watermark.clone(),
|
||||
));
|
||||
{
|
||||
let tx = transcribe_tx.clone();
|
||||
@@ -135,13 +127,12 @@ async fn main() {
|
||||
let client = http_client.clone();
|
||||
let cfg = config.clone();
|
||||
let vocab = vocab.clone();
|
||||
let watermark = oneliner_watermark.clone();
|
||||
tokio::spawn(async move {
|
||||
transcribe::recovery::scan_and_enqueue(&data_path, &tx).await;
|
||||
// Then catch up oneliners for cases that crashed between
|
||||
// transcript-write and oneliner-write in a previous run.
|
||||
transcribe::recovery::regenerate_missing_oneliners(
|
||||
&data_path, &client, &cfg, &vocab, &watermark,
|
||||
&data_path, &client, &cfg, &vocab,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -173,7 +164,6 @@ async fn main() {
|
||||
session_store: doctate_server::web_session::new_store(),
|
||||
analyze_busy: doctate_server::AnalyzeBusy(analyze_busy),
|
||||
transcribe_busy: doctate_server::TranscribeBusy(transcribe_busy),
|
||||
oneliner_watermark,
|
||||
};
|
||||
|
||||
let addr = format!("0.0.0.0:{}", config.server_port);
|
||||
|
||||
@@ -5,16 +5,22 @@
|
||||
//! text (or null if not yet generated), and UTC timestamps for when the
|
||||
//! case started and when its oneliner was last written.
|
||||
//!
|
||||
//! Conditional-GET: the response carries a weak ETag `W/"<unix>-<hours>"`
|
||||
//! derived from the per-user watermark. Clients that send back a matching
|
||||
//! `If-None-Match` get `304 Not Modified` without touching the filesystem.
|
||||
//! Conditional-GET: the response carries a weak ETag
|
||||
//! `W/"<fingerprint_hex>-<hours>"`. The fingerprint is a deterministic
|
||||
//! FNV-1a hash over the sorted tuples
|
||||
//! `(case_id, last_recording_at_ns, oneliner_mtime_ns)` of every
|
||||
//! *visible* case. Any listen-relevant FS change — new recording,
|
||||
//! oneliner regeneration, soft-delete, undo, upload-auto-reopen —
|
||||
//! shifts at least one tuple and the hash moves. Server restart is a
|
||||
//! no-op: same FS, same hash.
|
||||
//!
|
||||
//! The `hours` component is included so a client swapping window sizes
|
||||
//! does not get a stale 304.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::Query;
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
@@ -26,7 +32,6 @@ use tracing::warn;
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::error::AppError;
|
||||
use crate::OnelinerWatermark;
|
||||
|
||||
const DEFAULT_HOURS: u32 = 16;
|
||||
const MAX_HOURS: u32 = 168;
|
||||
@@ -38,7 +43,6 @@ pub struct OnelinersQuery {
|
||||
|
||||
pub async fn handle_oneliners(
|
||||
user: AuthenticatedUser,
|
||||
State(watermark): State<OnelinerWatermark>,
|
||||
Query(params): Query<OnelinersQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -46,18 +50,21 @@ pub async fn handle_oneliners(
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let since = now - Duration::hours(hours as i64);
|
||||
|
||||
let current_watermark = watermark.read().await.get(&user.slug).copied();
|
||||
let etag = build_etag(current_watermark, hours);
|
||||
// One scan pass yields both the case list and the fingerprint —
|
||||
// the fingerprint is the authoritative signal for "has the view
|
||||
// changed?". Body serialization is skipped if the client already
|
||||
// has this version (304).
|
||||
let scan = scan_cases(&user.data_dir, since).await;
|
||||
let etag = build_etag(scan.fingerprint, hours);
|
||||
|
||||
if client_has_matching_etag(&headers, &etag) {
|
||||
return Ok(not_modified(&etag));
|
||||
}
|
||||
|
||||
let oneliners = enumerate_recent_oneliners(&user.data_dir, since).await;
|
||||
let body = OnelinersResponse {
|
||||
as_of: now.format(&Rfc3339).unwrap_or_default(),
|
||||
window_hours: hours,
|
||||
oneliners,
|
||||
oneliners: scan.entries,
|
||||
};
|
||||
|
||||
let mut response = Json(body).into_response();
|
||||
@@ -67,9 +74,8 @@ pub async fn handle_oneliners(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_etag(watermark: Option<OffsetDateTime>, hours: u32) -> String {
|
||||
let seconds = watermark.map(|t| t.unix_timestamp()).unwrap_or(0);
|
||||
format!("W/\"{seconds}-{hours}\"")
|
||||
fn build_etag(fingerprint: u64, hours: u32) -> String {
|
||||
format!("W/\"{fingerprint:016x}-{hours}\"")
|
||||
}
|
||||
|
||||
/// Case-sensitive match against the raw `If-None-Match` header value.
|
||||
@@ -91,19 +97,36 @@ fn not_modified(etag: &str) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
/// Output of [`scan_cases`]: the sorted entry list that becomes the
|
||||
/// response body, and a fingerprint derived from the same scan.
|
||||
struct ScanResult {
|
||||
entries: Vec<OnelinerEntry>,
|
||||
fingerprint: u64,
|
||||
}
|
||||
|
||||
/// Scan `<user_data_dir>/<case_id>/` for cases whose latest recording
|
||||
/// sits inside the `[since, now]` window. Returns entries sorted by
|
||||
/// `last_recording_at` descending (most recently active first); cases
|
||||
/// without recordings fall back to `created_at`. Soft-deleted cases and
|
||||
/// non-UUID directories are filtered out.
|
||||
async fn enumerate_recent_oneliners(
|
||||
user_data_dir: &Path,
|
||||
since: OffsetDateTime,
|
||||
) -> Vec<OnelinerEntry> {
|
||||
///
|
||||
/// Alongside the entries we compute a deterministic fingerprint
|
||||
/// (`fnv1a_64`) over the sorted set of
|
||||
/// `(case_id, last_recording_at_ns, oneliner_mtime_ns)` tuples — cheap
|
||||
/// to compute during the scan, and it changes exactly when the visible
|
||||
/// list changes. No RAM-local state, so server restart is a no-op.
|
||||
async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult {
|
||||
let mut entries: Vec<OnelinerEntry> = Vec::new();
|
||||
let Ok(mut cases) = tokio::fs::read_dir(user_data_dir).await else {
|
||||
return entries;
|
||||
return ScanResult {
|
||||
entries,
|
||||
fingerprint: fnv1a_64(b""),
|
||||
};
|
||||
};
|
||||
// Parallel array keyed 1:1 with `entries` carrying the raw nanosecond
|
||||
// timestamps needed for the fingerprint. Kept separate so the
|
||||
// response DTO stays flat.
|
||||
let mut fp_tuples: Vec<(String, i128, i128)> = Vec::new();
|
||||
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
||||
let case_dir = case_entry.path();
|
||||
if !case_dir.is_dir() {
|
||||
@@ -139,6 +162,10 @@ async fn enumerate_recent_oneliners(
|
||||
let last_recording_at_str = last_recording_at.format(&Rfc3339).ok();
|
||||
let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok());
|
||||
|
||||
let last_rec_ns = last_recording_at.unix_timestamp_nanos();
|
||||
let oneliner_ns = updated_at.map(|t| t.unix_timestamp_nanos()).unwrap_or(0);
|
||||
fp_tuples.push((case_id.to_owned(), last_rec_ns, oneliner_ns));
|
||||
|
||||
entries.push(OnelinerEntry {
|
||||
case_id: case_id.to_owned(),
|
||||
oneliner: oneliner_text,
|
||||
@@ -156,9 +183,59 @@ async fn enumerate_recent_oneliners(
|
||||
let b_key = b.last_recording_at.as_deref().unwrap_or(&b.created_at);
|
||||
b_key.cmp(a_key)
|
||||
});
|
||||
entries
|
||||
|
||||
// Fingerprint: independent of entries' display order, so sort the
|
||||
// tuple list by case_id and hash.
|
||||
fp_tuples.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let fingerprint = fingerprint_tuples(&fp_tuples);
|
||||
|
||||
ScanResult {
|
||||
entries,
|
||||
fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit over the concatenation of the tuples. Stable across
|
||||
/// server restarts — no `RandomState`, no HashMap hashing. Ample
|
||||
/// resistance against accidental collisions for ETag purposes.
|
||||
fn fingerprint_tuples(tuples: &[(String, i128, i128)]) -> u64 {
|
||||
let mut h: u64 = FNV_OFFSET;
|
||||
for (case_id, last_rec_ns, oneliner_ns) in tuples {
|
||||
for b in case_id.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
// Separator so `"ab" || "12"` ≠ `"a" || "b12"`.
|
||||
h ^= b'|' as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
for b in last_rec_ns.to_le_bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
h ^= b'|' as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
for b in oneliner_ns.to_le_bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
h ^= b'\n' as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn fnv1a_64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = FNV_OFFSET;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
const FNV_OFFSET: u64 = 0xcbf29ce4_84222325;
|
||||
const FNV_PRIME: u64 = 0x00000100_000001b3;
|
||||
|
||||
/// Return `(earliest, latest)` `.m4a` mtime pair in `case_dir`.
|
||||
/// `.m4a.failed` entries count — a failed recording still marks activity.
|
||||
/// `None` on empty directory or read error. A single recording yields
|
||||
|
||||
@@ -75,7 +75,9 @@ pub async fn handle_upload(
|
||||
// Auto-reopen soft-deleted cases: a new upload clears the `.deleted`
|
||||
// marker so the case reappears in /api/oneliners and the web UI.
|
||||
// Hard-deleted cases (directory already gone) are handled transparently
|
||||
// by `resolve_case_dir` above — nothing to clear.
|
||||
// by `resolve_case_dir` above — nothing to clear. No explicit ETag
|
||||
// bump is needed: the subsequent m4a write changes `last_recording_at`,
|
||||
// which flows into the `/api/oneliners` fingerprint automatically.
|
||||
if crate::paths::is_deleted(&case_dir).await {
|
||||
tokio::fs::remove_file(case_dir.join(crate::paths::DELETE_MARKER)).await?;
|
||||
info!(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user