Companion to batch-job-queue-and-notification-stack.md,
which argues why the stack was chosen. This document describes what was
built: the modules, the contracts, the state machine, and the evidence that it
works.
Scope: a user picks N media files → each is pushed onto a durable background queue → a notification is pushed when each finishes or fails. The user can close the page, or restart the server, and nothing is lost.
browser server process (one binary)
─────── ────────────────────────────
/queue page
│ POST /api/jobs (N paths)
│ ─────────────────────────────► AppState::enqueue_jobs
│ │ INSERT INTO jobs (queued)
│ ◄── 202 {jobs:[…], rejected:[…]} ▼
│ SqliteQueue ──┐ same sessions.db
│ ▲ │
│ claim / heartbeat / │ │
│ progress / complete │ │
│ │ │
│ GET /api/jobs (poll every 2 s) ────┘ │
│ ▼
│ WorkerPool
│ (std::thread, default 1)
│ │
│ AppState::run(job) ← JobRunner
│ build_session + transcode
│ │ stage() / request_cancel()
│ │ read from build_jobs / playable_jobs
│ GET /api/jobs/events (SSE) ◄───────────────┘
│ event: job {v,job_id,state,…} SseHub (broadcast 512)
└─► toast "Ready to play"
Two ports carry the whole design, and both are injected exactly like the
existing database port (db: Arc<dyn Store>):
| Port | Trait | Local adapter | Future adapter |
|---|---|---|---|
| queue | JobQueue |
SqliteQueue |
RabbitMQ / Redis / MQTT |
| notify | Notifier |
SseHub |
rumqttc retained publish |
The application code above these traits never learns which adapter is installed.
| Path | Responsibility |
|---|---|
rust/src/jobs/mod.rs |
Job, JobSpec, JobPayload, state constants, JobQueue/JobRunner traits, Retry |
rust/src/jobs/sqlite.rs |
The jobs table, claims, leases, backoff, cancel flags, 7 unit tests |
rust/src/jobs/notify.rs |
JobEvent, Notifier, SseHub, 2 unit tests |
rust/src/jobs/worker.rs |
WorkerPool, execute, the per-job monitor thread |
rust/src/web.rs |
axum router, the legacy-route bridge, the job API + SSE route |
rust/src/server.rs |
Request/Response contract structs (no listener any more) |
rust/src/app.rs |
AppState wiring, the job API, impl JobRunner for AppState |
rust/src/main.rs |
CLI flags, queue construction, worker pool start, axum runtime |
rust/src/picker.rs |
Native multi-select file dialog per platform |
frontend/web/src/features/queue/index.tsx |
The /queue page |
frontend/web/src/providers/job-notifications.tsx |
The app-wide SSE subscription + toast stack |
frontend/web/src/lib/server-api.ts |
Typed client for the job API and the SSE feed |
Why the traits are synchronous. Handler and JobQueue are handed out as
Arc<dyn …>, and async fn in a trait is not dyn-compatible, so the async
boundary is bridged instead of propagated: the axum layer calls the blocking
Handler::handle inside tokio::task::spawn_blocking. The work being queued is
CPU-bound and has no await points anyway (FFmpeg, whisper.cpp, the wav2vec2
aligner), so a blocking pool is the honest shape — and a fixed pool of OS
threads is the scheduler, which is also how concurrency is bounded.
Everything below is native axum (route parameters use axum 0.8's {id} syntax).
POST /api/jobs
{ "media_paths": ["/abs/a.mp4", "/abs/b.mkv"], "kind": "build",
"align": false, "subtitle_path": null }
202 Accepted
{ "jobs": [{ "job_id": "00dee6c00ac4d850", "media_path": "/abs/a.mp4" }, …],
"rejected": [{ "media_path": "/abs/gone.mp4", "error": "no such file" }] }
202, not 200: the row is durable but the work has not started. Per-file
rejection is deliberate — one missing path must not cost the other nine files
their place in the queue. kind currently accepts only "build"; align
overrides the process-wide alignment default for this job, and subtitle_path
pins the cue source (otherwise locate_subtitle
resolves <stem>_full_sentence.srt or transcribes).
Re-submitting a file that is already queued or running returns that job's
existing id rather than creating a duplicate — the partial unique index
ON jobs(media_path, cache_key, kind) WHERE state IN ('queued','running')
rejects the second insert and the error is mapped back to the live id.
GET /api/jobs?state=&kind=&limit=200 → { "jobs": [ Job, … ] } newest first
GET /api/jobs/{id} → Job | 404
A Job row as serialised (note payload is #[serde(skip_serializing)] — it
can hold a subtitle path and has no business in the UI):
{
"id": "…",
"media_path": "…",
"cache_key": "…",
"kind": "build",
"state": "running",
"stage": "transcode",
"done": 0,
"total": 0,
"attempts": 1,
"max_attempts": 3,
"last_error": null,
"session_id": null,
"enqueued_at": 1792137467,
"started_at": 1792137468,
"finished_at": 1792137491,
"elapsed_secs": 23
}stage/done/total are overlaid live at serialisation time from the in-memory
maps the pipeline already keeps (build_jobs for decode/transcribe/align,
playable_jobs for the transcode) — see build_stage in app.rs.
No pipeline changes were needed to get progress out of it.
POST /api/jobs/{id}/cancel → { "job_id": "…", "state": "cancelled" }
A queued job stops outright. A running job is asked to stop and does so
at its next stage boundary: a whisper pass or a wav2vec2 window cannot be cut in
half, and pretending otherwise would report a state the pipeline is not in. The
response carries the state the job is in now, which is why the value may be
running even though cancellation has been recorded.
GET /api/jobs/events → text/event-stream
event: job
data: {"v":1,"job_id":"…","kind":"build","state":"done","media_path":"…",
"session_id":"…","stage":null,"done":0,"total":0,"attempts":1,
"error":null,"finished_at":1792137491}
: keep-alive (every 15 s)
Every connection is first sent the current snapshot (AppState::list_job_events),
then live updates. Delivery is at-least-once: the snapshot is replayed after
each reconnect, so a consumer must dedupe on (job_id, state). That pairing is
why job_id is in the payload at all. A subscriber that lags the 512-event
broadcast buffer drops old events rather than stalling the publisher; the client
re-syncs from GET /api/jobs.
POST /api/pick-file?kind=media|subtitle[&multi=1]
single → { "path": "/abs/a.mp4" } | { "path": null } (null = cancelled)
multi → { "paths": ["/abs/a.mp4", "/abs/b.mkv"] } ([] = cancelled)
Browsers hide the real path of a <input type="file">, so the page asks the
server — which runs on the same machine in the default loopback setup — to open
the OS dialog. Each platform is driven natively (zenity --multiple,
kdialog --multiple, osascript … with multiple selections allowed,
OpenFileDialog.Multiselect); all of them are asked for newline-separated output
so one parser handles single and multi alike. The route is guarded against
cross-origin callers so a random website cannot drive a local dialog.
enqueue
│
▼
┌────────┐ claim (BEGIN IMMEDIATE, oldest first,
│ queued │ gated on enqueued_at <= now for backoff)
└────────┘
│
cancel ◄─────┤
(instant) ▼
┌─────────┐ heartbeat (lease) ← monitor thread, every 500 ms
│ running │ progress (stage) ← mirrored from the pipeline
└─────────┘ cancel_requested → flips the pipeline flag
│
┌──────────────┼───────────────────┬──────────────┐
▼ ▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐
│ done │ │ queued │ │ failed │ │ cancelled │
└────────┘ │ (retry) │ │ (DLQ) │ └───────────┘
terminal └─────────┘ └──────────┘ terminal
│ ▲
└── attempts > 3 ───┘
- Retry. A failure is retried while
attempts < max_attempts(3), with5·2^attemptsseconds of backoff capped at 300 s. The backoff gate is theenqueued_at <= nowcondition in the claim query, so a requeued job simply waits. Past the cap it is dead-lettered: statefailed,last_errorkept, no further retries. A job that fails deterministically cannot loop forever. - Cancelled is not failed.
JobFailure::Cancelledis a clean terminal state with no retry and no alarm; only a real error is reported as an error. - Lease. A claim sets
lease_expires_at; the monitor thread extends it every 500 ms. If the process dies mid-job, the lease stops being extended and the worker that starts next callsreclaim_expired(also at boot), which returns the row toqueued. Because the attempt was already counted at claim time, a job that reliably kills the process still burns its attempts and dies in the DLQ rather than crash-looping. donemeans playable. The worker runs the browser-playable H.264 transcode inline before completing the job, so adonenotification means "you can press play", not "a session row exists".
~30 routes predate this work and are plain synchronous (Request) -> Response
methods on impl Handler for AppState. Rather than rewrite them, the axum router
installs a single fallback that:
- reads the body (
to_bytes, capped at 16 MB), - converts
axum::extract::Request→server::Request(method, decoded path, parsed query, headers lowercased), - calls
app.handle(&request)onspawn_blocking, - converts
server::Response→ axum, copying status, headers and body and setting noContent-Length(hyper chooses it).
Nothing in the existing handlers changed, which is why the media route's Range
handling survived verbatim — that was the acceptance test for the port, because
seeking is the one behaviour a player cannot lose:
| Request | Response |
|---|---|
Range: bytes=0-99 |
206 Partial Content, correct Content-Range |
Range: bytes=1000-1099 |
206, offset window |
Range: bytes=99999999- |
416 Range Not Satisfiable |
no Range |
200, Content-Length, Accept-Ranges: bytes |
The cost of this approach is that the media-only --no-default-features build
(~25 MB) is consciously given up: axum, tokio, tower-http, async-stream and
futures-util are now always compiled, because the queue API and the SSE feed are
part of the product rather than an optional extra.
Two transports, two jobs. The /queue page polls GET /api/jobs every 2 s
for the table, because the only source of truth for a job's state is the server's
table and a poll is trivially correct. The notification, though, is pushed:
job-notifications.tsx
holds one EventSource for the whole app and renders the toast stack.
That split matters because of where the subscription lives. A job takes minutes,
so by the time it ends the user has usually navigated away — a subscription owned
by the queue page would report nothing. The provider is therefore mounted in
app-shell.tsx, which
keeps it alive across route changes and gives it router context (the "done"
toast deep-links to /player?session=…, which needs <Link>).
Two guards keep the at-least-once feed from becoming a wall of toasts:
- Dedupe on
(job_id, state), tracked for the life of the tab. - Freshness — a terminal event whose
finished_atis more than 15 s old is treated as history and added to the seen set without being shown. Without it, every page load would re-announce every job ever run, because the SSE snapshot carries no way to distinguish "just now" from "yesterday" except the timestamp.
Toasts auto-dismiss after 10 s, show the failure reason when there is one, and show "Open in player →" when the job produced a session.
The page itself shows the queue depth (the default of one worker means five files take five times one file, which has to look deliberate rather than stuck), a live stage word per running row, per-row cancel, and the per-file rejection list from the last submit.
pnpm run dev:server # rust dev server on :8018
pnpm run dev:web # vite on :5173, proxies /api → :8018
sentence-video serve --port 8018 --data-dir ~/.local/share/sentence-video \
--workers 2 --no-browser| Setting | Default | Note |
|---|---|---|
--workers <n> / SENTENCE_VIDEO_WORKERS |
1 | Concurrency. >1 trades RAM (whisper model + weights per worker) for throughput. |
--data-dir <path> |
~/.local/share/sentence-video |
The jobs table lives in the same sessions.db as the sessions. |
| lease | 30 s | Passed from main.rs; the monitor heartbeats every 500 ms. |
max_attempts |
3 | Per job; backoff 5·2^n s, capped at 300 s. |
| idle poll | 250 ms | How quickly a queued job starts. |
| SSE keep-alive | 15 s | Keeps proxies from closing an idle stream. |
Log lines (logs.db, filter by component queue): worker started,
job started (with kind=… attempt=n/3), job finished (with the session id
and duration), job failed; will retry, job failed permanently, job cancelled.
Restart behaviour: the process can be killed at any point. On the next boot
reclaim_expired returns orphaned running rows to queued, and the pool picks
them up — the attempt was already counted, so this is a retry, not a fresh start.
Unit tests, all hermetic (temp SQLite files, no network, no media):
jobs/sqlite.rs(7): oldest-first claim, re-submit idempotency, terminal states free the unique key, cancelling a queued vs a running job, backoff → dead-letter, lease reclaim, progress/heartbeat only accepted on a running job.jobs/notify.rs(2): publish reaches a subscriber; publishing with no subscriber is not an error.picker.rs(2): dialog titles; one path per line, so single and multi selection share the parser.
End-to-end evidence gathered against a running server:
| Check | Result |
|---|---|
cargo test |
133 passed, 0 failed |
cargo check |
no warnings introduced by this work |
tsc -b |
only the 5 documented pre-existing errors |
vite build |
succeeds |
| Batch submit | 8 files → 202, rejected: [] |
| SSE stream | queued → running → done delivered, with session_id |
Media Range |
206 + Content-Range; 416 unsatisfiable; 200 full |
| Crash recovery | server hard-killed mid-job → on restart the job was reclaimed and re-run (attempts: 2), nothing orphaned |
| Browser | RUNNING row showed stage "encoding H.264", QUEUED row "waiting for a worker", then two sequential "Ready to play" toasts with "Open in player →" links, matching rows flipping to DONE |
One pre-existing Windows-only test failure was repaired along the way:
an_in_flight_build_is_reported_until_it_ends registered a build with the
POSIX-shaped /media/a.mp4 while cancel_build normalises the path that arrives
over HTTP — on Windows those two spellings differ (C:\media\a.mp4). The test now
derives both from one media::normalize_path call.
Deferred, by design (design doc §4–§6): the feature-gated mqtt (rumqttc) and
broker (lapin) notifier/queue adapters, OS-level toasts, and the k8s cutover.
AppState::with_notifier is marked as the
one seam a second transport gets installed at.
Known limits worth stating plainly:
- One process. The
SseHubis in-memory, so it federates nothing: a second instance of the server would not see the first one's events, and its workers would compete for the same table without sharing a notify path. That is exactly the case the broker adapter exists for. - SQLite is single-writer. All queue access goes through one
Mutex<Connection>, so raising--workersdoes not raise DB throughput. Contention is small — the lock is held only for short claim/update statements — but it is a ceiling. - The transcode phase has no percentage.
ensure_playable_*records presence and state only, so the queue can say what is happening (and does: stagetranscode), not how far along. The decode/transcribe/align stages do have counters and do show a moving bar. - Field naming is asymmetric. The SSE payload calls it
job_id(it is an event about a job) whileGET /api/jobsrows call itid(it is the job). The client handles both; a future payload version could unify them, which is why every event carries"v": 1. - Five in-memory maps remain.
dub,mux,platform,playableandbuildjob state still lives inAppStaterather than in the queue table. Routes that use them work across a restart only because the session cache is in SQLite; their progress is lost. Consolidating them onto the queue is the obvious next increment.