feat(worker): compute a spectrogram once, and delete it when the audio goes - #146
Merged
Merged
Conversation
TheMeinerLP
force-pushed
the
feat/worker-spectrogram-artefacts
branch
2 times, most recently
from
August 23, 2026 14:16
a0bca47 to
a5f73e8
Compare
…o goes A spectrogram is drawn per request today: a full streamed decrypt of the recording plus 600 transforms, every time somebody opens a track, keeping nothing. `spectrograms_by_default` (per guild, default false) moves that work to the worker, which draws the picture at job completion out of the plaintext WAV it already has on disk and stores it beside the audio, sealed under the same session data key. Storing it is only defensible with one rule attached, and the rule is the design: a stored spectrogram is deleted when its audio is deleted. The retention sweep now deletes both objects in the same pass and stamps `audio_deleted_at` only once both are gone, because a picture of when somebody spoke and for how long that outlived their recording's retention window would be the setting quietly creating exactly what the window exists to end. After a sweep the track is neither playable nor visualisable, as before, and that is pinned by a test. The read path prefers the artefact and falls back to drawing the track -- for a job from before the setting was on, for a guild that never turned it on, and for an artefact that is missing or unreadable -- so the endpoint's contract does not depend on which answered. Authorisation is untouched and still decided per request, before anything looks in the bucket: a cache of the payload must not become a cache of the permission. A failure to draw or store never fails the job. The transcription is the valuable part, it has already succeeded by then, and the console can draw the track itself. The FFT moves to `sturnus.application.spectrogram` so that two processes share one definition of the picture rather than growing a second one, and `CorruptRecording` moves to `sturnus.domain.errors` because two layers now raise it and `application` may not import the console.
TheMeinerLP
force-pushed
the
feat/worker-spectrogram-artefacts
branch
from
August 23, 2026 14:28
a5f73e8 to
846c677
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A spectrogram is computed per request today and kept nowhere:
GET /api/sessions/{id}/tracks/{user}/spectrogramstreams the whole ciphertext past an FFT, answers a fixed 600 × 128 matrix, and throws the work away. Every view of the same track pays the same full decrypt again.spectrograms_by_default— per guild, defaultfalse— moves that work to the worker, which draws each track's picture at job completion out of the plaintext WAV it already has on disk, and stores it beside the recording. A view then costs two small object reads and no arithmetic.Storing it is only defensible with one rule attached, and the rule is the design:
Without it, switching this on would create a retained rendering of a person's voice activity that outlives the retention window their recording was subject to — because the sweep deletes the S3 object and nothing else. The console's own design argues that a spectrogram "is less than the audio and it is not nothing"; it must not become the thing that survives. So the artefact and the audio share a lifetime, and the sweep that deletes one deletes the other in the same pass.
How the sweep deletes both
sweep_expired_audioreadsspectrogram_keyalongsides3_keyon every candidate, deletes the recording, deletes the picture if the row names one, and only then stampsaudio_deleted_at. One pass, one loop iteration, oneexcept— not a second sweep that could be forgotten, disabled, or fail on its own schedule. The stamp is the durable claim that this recording is gone, so it is written only when both objects are, and a partial failure leaves the row unstamped and retried next hour; a repeatedDELETEof an object already gone is what S3 answers successfully anyway.mark_audio_deletedclearsspectrogram_keyin the sameUPDATE, because a row that went on naming a deleted object would claim a picture exists.The key is selected, not derived. The naming rule (
sessions/{s}/speakers/{u}.spectrogram.enc) can change; the objects already in the bucket cannot, and the sweep has to delete what was written rather than what would be written today.That ordering is also why the worker records the key before it uploads the object, which is the one part of
SpectrogramStorethat is not negotiable. The reverse order has a failure mode this design cannot afford: an object in the bucket that no row names is an object nothing will ever delete. Written this way round, the worst case is the harmless one — a row naming an object that is not there, which the read path answers by drawing the track.And the property that was true before is still true, with a test on it: after the sweep the track is neither playable nor visualisable.
test_a_recording_that_is_gone_has_no_picture_eithersets the artefact up in the bucket, takes the recording away, and asserts 404 from both routes.When storing fails
It does not fail the job.
_store_spectrogramswallows every exception it can raise, logsspectrogram.failedat WARNING, and returns.A transcription that succeeded and could not be drawn has done the valuable part. It runs after
queue.complete, so by then the transcript is stored and the job isdone— letting a failure travel toprocess_one's handler would callqueue.failon an already-transcribed job and spend minutes of inference again to retry a picture the console draws for itself in a second. Both directions are tested: arecordthat fails, aputthat fails, and a track that cannot be parsed at all.Its position is fixed at both ends. Any earlier and a failure re-queues a transcribed job; any later and
process_one'sfinallyhas already deleted the plaintext. Thatfinallystill removes everything, artefact included — it is written into the same scratch directory as the WAV, and a test asserts the directory is gone afterwards.What it costs
76.8 kB of matrix per track, about 100 kB stored. The extra third is base64 inside a small self-describing JSON envelope: the artefact records the shape it was drawn at, so a build whose
COLUMNSmoved refuses it and redraws instead of rendering somebody's meeting under somebody else's axes. That is worth 25 kB.Fixed means fixed — a three-hour workshop costs exactly what a two-minute stand-up does, because the column count does not depend on the recording's length. A six-person meeting is about 0.6 MB.
For a busy guild — four such meetings every working day — a year is about 1.2 GB if nothing were ever deleted. Nothing here lives that long. An artefact lives exactly as long as its recording, so the steady state is one
audio_retention_dayswindow of them: at the default thirty days, roughly 100 MB, against the tens of gigabytes that guild's audio occupies over the same window. In storage the picture is a rounding error beside the recording. It is not a rounding error in what it says, which is why the retention rule is the interesting half of this change and the storage figure is the boring one.Backfill: no
Jobs already transcribed have no artefact and will not acquire one by this setting being switched on. There is no backfill job, and adding one would mean fetching and decrypting every recording in the bucket to redraw pictures the console can produce on demand.
The read path is what makes that a non-issue rather than a gap: it prefers the artefact and falls back to drawing the track — for a job from before the setting was on, for a guild that never turned it on, for an artefact that is missing, and for one this build cannot read. The endpoint's contract does not depend on which answered.
A re-queue does produce one, incidentally rather than by design: the job is transcribed again by a worker that reads the setting again. That is the only way an old session acquires a picture, and it costs a full re-transcription, so it is worth doing for a bad transcript and not for a spectrogram.
Turning the setting off mid-life
Existing artefacts stay. Turning it off says "stop drawing new ones", not "destroy what has been drawn".
The defence is that the retention rule above still governs every one of them: each stored picture is still deleted with its own recording, so nothing outlives the window it was created under, and turning the setting off changes what happens next rather than reaching backwards. The alternative — deleting them on a config change — would be a second deletion path for the same objects, unreviewable against the first, running at the moment somebody is editing a form. That is the wrong moment for a bulk delete of anything, and it would be the only place in this system where a settings write erases data.
What changes immediately is the cost: jobs finishing from now on store nothing, and views of tracks that already have a picture are still answered from it until retention takes both away. An operator who wants the pictures gone sooner shortens
audio_retention_days, which moves the recording and the picture together — the only way this system moves them.Authorisation did not move
_authorised_trackruns first and runs on every request: the samesession_participantquery, decided again, never cached. Only then does anything look in the bucket. A cheap answer is exactly the kind that grows a cache in front of it, and the point of the ordering is that a cache of the payload cannot become a cache of the permission.Cache-Control: private, no-storeis unchanged, on both paths, with a test on each. A stranger gets 404 whether or not an artefact exists.Running the authorisation first is also what refuses a swept track before the artefact is ever considered.
The artefact is encrypted, under the job's own wrapped data key, in the same envelope format as the recording — so the console reads it through the same
stream_wav, a master-key rotation reaches both together, and this bucket does not acquire its single readable object. The picture is a rendering of somebody's voice activity; it is behind the same authorisation rule as the audio for that reason, and storing it in the clear beside the ciphertext would have undone that at rest.Two moves, and why
The FFT and the picture's stored form now live in
sturnus.application.spectrogram. Two processes draw the same picture — the console from S3, the worker from disk — and the brief for this change was explicitly not to grow a second FFT path. A shared definition has to sit below both callers, so it takes a stream of plaintext WAV bytes, which is all either caller can promise.sturnus.console.spectrogramis what is left: the two ways the console obtains one. The static test that forbids anything on the serving path from writing plaintext to disk now covers the moved module too — a module on the serving path is on the serving path wherever it lives.CorruptRecordingmoves tosturnus.domain.errorsfor the same reason in miniature: two layers raise it now, andapplicationmay not import the console, sodomainis the only place below both.Not here
No console change — and none is needed:
settingGroups.tsanticipated this key by name and files it on the catch-all tab deliberately, where it works exactly as every other key does until somebody who knows what it is moves it. No migration;spectrogram_keyarrived with 0013 and nothing wrote it until now. No change to the picture's dimensions or the endpoint's response shape.docs/operations.mdgains §6.2.13 with the storage arithmetic, the retention rule, the no-backfill statement and the turning-it-off answer, and §6 now says the sweep deletes both.Rebased onto
mainatb831c05, past #142, #143, #144, #145, #147 and #148. 2656 tests pass (2624 onmain, so +32),mypy,ruff checkandruff format --checkclean.Three notes from those rebases. #142's
WritesARealWavarrived while this branch was open and is the same fixture this branch had added for itself, so the spectrogram tests now use its one andFakeCryptogoes back to the single-purpose fake it was — one WAV builder in the file rather than two. #147 collapsed the worker entrypoint's duplicated master-key decodes into a singleKeyWrapperbinding, so_SpectrogramArtefactstakes that binding rather than decoding the key a third time. And #144 stores its export artefacts unencrypted, which this branch does not: the picture is sealed under the job's own data key, so the bucket still holds no readable object. That gap is #144's to close and nothing here depends on it.