Skip to content

feat(storage): precompute waveform peaks on the validator node - #522

Open
rickyrombo wants to merge 20 commits into
mainfrom
mjp-mediorum-waveform-peaks
Open

feat(storage): precompute waveform peaks on the validator node#522
rickyrombo wants to merge 20 commits into
mainfrom
mjp-mediorum-waveform-peaks

Conversation

@rickyrombo

@rickyrombo rickyrombo commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

What

An opt-in stage in mediorum that decodes audio the node already stores and reduces it to a 750-bucket amplitude envelope — per-bucket RMS, normalized to peak, quantized to uint8, 750 bytes a track. Clients render a waveform from that without downloading or decoding the audio.

The idea comes from a standalone prototype that pulled mp3s over the network from a single node via signed /tracks/cidstream/{cid} requests. Running it on the node removes that egress entirely.

Why mediorum and not the ETL

The ETL is a chain indexer with no blob access, so a detached process would have to fetch audio over the network — the exact cost being removed. Mediorum already owns the blobs, already runs ffmpeg, and already has a worker-pool/backlog/retry pattern to follow.

Why local-only and not replicated

Mediorum rows replicate solely by riding the core chain as MediorumOperation txs — syncCoreMediorumOps walking committed blocks is the only path from one node to another; there is no peer-to-peer gossip. The table allowlist those txs validate against (opvalidation.mediorumTableTypes) is consulted by isValidMediorumOperationTx, which runs in FinalizeBlock, where result codes fold into CometBFT's LastResultsHash.

Registering waveforms as a crudr model would therefore turn a derived rendering hint into consensus-affecting state requiring a coordinated network upgrade, and commit ~1KB per track to the chain permanently.

So each node computes its own. Every input — bucket count, sample rate, frame size — is a compile-time constant rather than config, so two nodes produce byte-identical output and a client re-fetching from a different node after a reroute sees the same waveform. Replication remains available as a purely additive follow-up if it ever earns the cost; the table is CID-keyed either way.

Where waveforms come from

Four paths, none of which pays to read back a blob it just had:

  1. Transcode hook. The finished 320 is still on local disk, so this costs no bucket read, and covers every new upload on the transcoding node. It sits after the mirror bookkeeping: it decodes a whole track and runs inline on a path HTTP handlers call synchronously, so it must not delay the node claiming to be a transcoded mirror. Failure is advisory — recorded against the waveforms row, never reaching onError, because a rendering hint must not be able to fail an upload.
  2. Replication handoff. pullFileFromHostValidated already buffers a pulled blob to disk to verify its cid, so it hands that copy to a waveform job once the bucket write commits — after commit, so the peer is never told we hold a blob we have not finished storing, and on the worker pool, so the peer is not held open for a decode. Without this a replicated blob waits for a sweep and pays a bucket read when one arrives.
  3. Backfill sweep. Walks uploads newest-first behind a keyset cursor, so the recent slice lands first rather than after a full history walk.
  4. Retry sweep. Re-attempts failed rows, reading the waveforms table only, so it is indexed and proportional to the result set rather than to catalog size.

Throughput is set by the workers, not by a timer: a sweep refills the queue and stops when it is full, so OPENAUDIO_WAVEFORM_WORKERS is the knob that matters. The retry and linking sweeps run whether or not backfill is enabled — only the history walk is gated — because those are what repair a transient failure from the live hook, and a live-only node would otherwise never retry one.

The walk restarts after an interval rather than finishing permanently. Uploads replicate from peers carrying timestamps the descending walk has already passed, so "the end of history" is only ever true for now.

Linking a row to its upload

Rows carry upload_id, and discovery correlates on it. That makes an unlinked row worse than untidy: it is invisible to discovery, so its upload looks permanently outstanding and every re-walk recomputes a waveform that is already correct — and on a live-only node, where discovery never runs, nothing ever repairs it.

The paths differ in what they know:

  • The transcode hook passes upload.ID directly for both the 320 and the preview.
  • The pull request carries the upload id the sender already had. The receiver could resolve it locally instead, but only from the uploads row the sender publishes through consensus — which arrives on a block commit, while the pull request arrived over HTTP. Resolving locally is kept as the fallback for peers that predate the field.
  • resolveWaveformUploadID covers what is left: the 320 by idx_uploads_transcode_cid_320, a preview through audio_previews to its source 320.
  • linkOrphanWaveforms is the backstop, repairing the link in place rather than recomputing, on every sweep regardless of the backfill flag.

Previews are also recorded before their blob is handed to peers. That does not close the window on its own — the row still has to clear consensus — but publishing a blob before anything can account for it is a gap worth not opening.

Serving

GET /waveform/:cid returns {cid, version, buckets, sample_rate, duration_ms, peaks}, peaks base64-encoded because Go marshals []byte that way — divide each byte by 255 for a 0..1 envelope, pair with duration_ms to render.

On a miss it redirects to a peer that has it, mirroring serveBlob. The search cannot reuse hostHasBlob: holding the blob says nothing about holding the waveform, since waveforms are not replicated and a peer may have the feature switched off. Peers are probed for the waveform itself via HEAD. The probe sets localOnly, and a node seeing localOnly answers 404 rather than forwarding — without that a probe would recurse across the network. Bounded to three probes, since this sits inline in a user request.

Serving never triggers analysis. Enqueueing from an unauthenticated GET would bypass the archive-tier guard the sweeps apply, letting anyone trigger a cold-storage retrieval on a StoreAll node with no rate limiting in front of it. Pointing at a node that already holds the answer is a better response than promising to compute one.

Auth is requireHealthy + ensureNotDelisted, deliberately without requireRegisteredSignature. A 750-byte envelope is not decodable audio, and clients draw the waveform before playback begins, so requiring a stream signature would defeat the purpose. Calling that out as an explicit product decision. Delisting still applies — a waveform is derived from the audio and disappears with it.

Previews

A preview is its own blob with its own cid, and its waveform cannot be sliced out of the track's: it is peak-normalized independently, and thirty seconds of a long track occupies too few buckets to stretch across a player. Discovery enumerates the selected preview alongside the 320. Serving needed nothing — the route is cid-addressed, so a preview waveform is servable the moment a row exists.

Placement context is carried per blob rather than per upload. The 320 is replicated with the upload's placement hosts and the preview with none, and bucketForCID reads any non-empty placement as "force primary" — so handing a preview the upload's hosts would judge every preview primary-tier and read it out of cold storage with the archive flag off.

The preview is queued where it is produced, with its file handed over, rather than from the transcode hook afterwards. Nil placement means rank alone decides its tier, and a preview cid ranks independently of the track's and of who created it — so on a StoreAll node most previews route to that node's own archive tier, where a job arriving without a local file is refused by the archive guard. On a 70-peer network at a replication factor of 4 that is roughly sixteen previews in seventeen, which would leave the recommended configuration producing almost no preview waveforms at all. Queuing it at the point of production also covers the bare-CID /generate_preview endpoint, which has no upload row to hook into.

Versioning

Every row is stamped with a version, and discovery counts only rows at the current version as present. A version change therefore makes stale rows look absent and the ordinary walk recomputes them — no separate sweep, no table rewrite. The cursor records the version it walked under, and a mismatch restarts it immediately.

The version is derived rather than hand-maintained: a fingerprint over the algorithm version plus every parameter that affects output. A hand-bumped constant invites changing waveformBuckets and silently leaving a network full of waveforms computed under the old settings.

Two consequences:

  • It is a fingerprint, not a sequence — it does not increase, so comparisons are equality, never ordering.
  • It is opaque alone, so the console reports algorithm_version, buckets, and sample_rate beside it.

Writes are on conflict (cid) do update, so a recompute replaces the row in place. A CID keeps serving its existing waveform until the new one lands, and a version change never opens a coverage gap.

Console

A section on the Storage page, beside Repair & Cleanup, since a backfill pass is the same shape as a repair run. It renders only when the feature is enabled, matching how Archive Storage appears only once an archive bucket is configured — the feature is off by default and an empty section on every node is noise.

Two run tiles (Analysis, Backfill) over stat tiles. Analysis carries the settings and which of the nested switches are on; Backfill carries the pass, its progress and its counters, with progress estimated from where the cursor sits between the oldest and newest audio upload and labelled rough, since uploads are not spread evenly across that span.

The stat tiles count uploads, not blobs. An upload yields a 320 and sometimes a preview, so a blob count and an upload count are different units and cannot be compared with each other — and mixing them is how a figure ends up in two tiles at once. Counted per upload, each analyzable upload lands in exactly one bucket and the tiles sum to the analyzable catalog: Analyzed, Never Analyzed, Partial, Not Local, Skipped Archive (only where archive storage exists), Unavailable, Failed, To Recompute. Where an upload's blobs disagree — a finished 320 beside a failed preview — the worse state wins, since that is the one an operator would act on.

Unlinked Rows sits alongside them and is shown only when non-zero: the rollup is keyed by upload and structurally cannot see rows that resolved none, so it is the one figure that says the rest is describing a subset.

A final row counts requests served, missed and redirected — nothing else reports whether the waveforms are being consumed, and the redirect count is the closest thing to a measure of how evenly they are spread across the network.

Notes for review

  • Why this doesn't extend the existing audio analyzer. analyzeAudio runs inline in the transcode worker on a 1-minute deadline and its errgroup cancels siblings on first error, so a waveform failure added there would kill BPM/key and write an error into the chain-submitted uploads row. Its WAV is also truncated to 120s — right for BPM/key, wrong for a full-track envelope. And its selector filters audio_analysis_status != 'done', which the existing catalog already satisfies, so it could never backfill.
  • All the reported figures come from one sampled pass. Sharing a snapshot is what lets them reconcile; a query per tile describes a different moment per tile. The pass is a sample rather than a per-request count because its anti-join half is proportional to the catalog and cannot be indexed away — the preview key is extracted dynamically from jsonb. The console reports the age.
  • Expected blob count mirrors waveformTargets exactly, including its requirement that a selected preview actually resolve to a blob. Deriving it from selected_preview alone lets an upload expect a row that can never be written, which leaves it short of its own expected count forever.
  • waveforms.upload_id is nullable and not a key. An upload yields two analyzable blobs once previews count, and legacy Qm content has no upload row at all, so the relationship is not 1:1. Legacy content is excluded from linking for that reason — a null there is the right answer, and retrying it would never converge.
  • audio_previews.source_c_id, not source_cid. gorm derives the column from AudioPreview.SourceCID and splits the acronym. Getting it wrong does not fail loudly: the query errors, the error is not ErrNoRows, and an empty return reads as "legacy content with no upload" — indistinguishable from success. The lookup is logged at Warn for that reason, and a test resolves a real preview.
  • Read failures split three ways. Both-buckets-NotFound → not_local; any other bucket error → unavailable on a short backoff. Collapsing them would let a transient archive outage stamp a 24h backoff across a large slice of the catalog in a single sweep. Neither increments error_count, which is what lets a job migrate to whichever node actually holds the file.
  • The archive guard sits at the read, not at enqueue. A cid's tier is not fixed — rendezvous rank shifts when the validator set changes — so a row queued while primary-tier can be read after it has become archive-tier. Checking immediately before the first bucket call makes the flag authoritative however the job arrived. It is skipped whenever the job carries the file itself — the replication handoff, the preview — because the guard exists to prevent cold-storage retrievals and there is no retrieval when the bytes are already on local disk. The 320 hook never reaches the guard for the same reason, reading its own output off disk.
  • Temp-file ownership transfers with the job. enqueueWaveformJob reports the handoff definitively — a non-blocking send either placed the job or did not — so a flag marks the transfer and the deferred cleanup still covers a copy error, a cid mismatch, a failed write, and a full queue.
  • Duration comes from the decoded sample count, not uploads.ff_probe — that column is probed from the original upload rather than the 320, and is null on older rows.
  • Short-read guard. computeWaveform verifies it fed ffmpeg the whole source. A bucket read ending early without erroring would otherwise produce a plausible waveform for a fraction of the track and mark it done.
  • Bounded memory. The accumulator merges adjacent frames and doubles the frame size on overflow, so an arbitrarily long track costs a fixed ~768KB. Sums and counts are carried separately and sqrt applied once at the bucket level, since RMS-of-RMS is wrong the moment a partial trailing frame exists.
  • ffmpeg is pinned to one thread, matching the transcoder's own cap, since backfill runs alongside transcoding and PCM decode is single-threaded in practice anyway.

Config — all default false

Var Default Effect
OPENAUDIO_WAVEFORM_ENABLED false Master switch. Off ⇒ no routine, no live hook, no writes.
OPENAUDIO_WAVEFORM_BACKFILL_ENABLED false Historical walk. Off ⇒ live and replication paths only; retries and linking still run.
OPENAUDIO_WAVEFORM_ARCHIVE_ENABLED false Include archive-tier CIDs in the walk. Off ⇒ archive_skipped.
OPENAUDIO_WAVEFORM_WORKERS 2 Worker count, and the throughput knob.

They nest, so an operator can walk the cost curve one step at a time: nothing, then new uploads, then recent history, then the cold tier. A node that sets nothing behaves exactly as it does today.

Testing

54 tests — 41 across waveform_test.go, waveform_link_test.go and waveform_rollup_test.go, plus 13 covering the console section:

  • Algorithm — silence dividing by zero, sub-second clips producing empty buckets (NaN), RMS-of-RMS after a frame merge, energy preserved across merges, and the merge path checked against a naive sample-exact reference implementation.
  • Decode — a real ffmpeg decode of a track longer than 120s, a truncated source detected, non-audio rejected.
  • Serving — stored peaks returned, HEAD without a body, localOnly refusing to forward, a cross-node 302, and a miss that provably enqueues nothing.
  • Versioning and sweeps — the fingerprint changing for each parameter independently, a first run not mistaken for a version change, a stale cursor re-walking and finding a late-arriving upload, a version change re-enqueueing stale rows, the cursor stopping at a full queue rather than skipping past it, and retries running with backfill disabled.
  • Linking — the pull request carrying the sender's upload id, a pull with no local uploads row still linking, a preview resolving through audio_previews, orphans repaired in place, and legacy content left alone.
  • Analysis targets — an original pulled from a peer replicating without being decoded, and an older peer's 320 still analyzed via the resolve fallback.
  • Archive tier — the same cid refused without a local file and analyzed with one, and the preview hook queuing a job whose handed-over file still exists.
  • Rollup — each upload counted exactly once as it moves between states, worst-state-wins across disagreeing blobs, an unproducible preview not counted as expected work, uploads with nothing analyzable excluded, and unlinked rows counted apart.
  • Ownership — the worker releasing a handed-over file even when analysis fails, and a full queue leaving no orphan.
  • Console — hidden when disabled, live-only distinguished from backfilling, run progress, pending re-backfill, every stat tile, the sample age, Skipped Archive appearing only with archive storage, and Unlinked Rows only when non-zero.

Full ./pkg/mediorum/... and ./pkg/core/console/... suites pass on freshly created databases; go build ./..., go vet, and gofmt clean.

Verified end-to-end on a 4-node devnet: the live path produced a waveform immediately after transcode with duration_ms matching a 200s track; a mirror produced byte-identical peaks; nodes with the feature off returned 404 with an empty table; select count(*) from ops where "table"='waveforms' was 0; a delisted CID returned 403.

Also verified against a live archive node running this branch. A 180s synthetic track whose amplitude steps through 1.0 / 0.5 / 0.25 / 0.75 / 0.1 / 0.6 came back as 255 / 127 / 64 / 191 / 25 / 153 — the ±1s being uint8 quantization — with duration_ms 180000, confirming a full-length decode rather than a 120s truncation.

Deferred

Legacy Qm CID discovery, and storing a peak envelope alongside RMS — wavesurfer's default rendering is per-bucket peak, so RMS reads flatter by comparison. The bulk repair path is also left on the sweeps deliberately: it streams straight into the bucket with no local copy, and it walks millions of blobs, so a decode per pull would slow the durability sweep for little gain. The version column makes each a cheap follow-up.

🤖 Generated with Claude Code

rickyrombo and others added 11 commits August 17, 2026 18:40
Adds an opt-in stage to mediorum that decodes audio the node already
stores and reduces it to a 750-bucket amplitude envelope, so clients can
render a waveform without downloading and decoding the track.

The work happens where the blobs already are. Reading the 320 from the
node's own bucket -- or, on the transcode path, straight off local disk
before the temp file is removed -- means no peer fetch and no egress.

Waveforms are deliberately local-only, not a crudr model. Mediorum rows
replicate solely by riding the core chain as MediorumOperation txs, and
the table allowlist those are validated against is consulted in
FinalizeBlock, where result codes fold into the header. Registering the
table would turn a rendering hint into consensus-affecting state and
commit roughly a kilobyte per track to the chain permanently. Each node
recomputes instead, and every input to the computation -- bucket count,
sample rate, frame size -- is a compile-time constant so mirrors agree
byte-for-byte.

Notes on the implementation:

- The existing audio analyzer is untouched. It runs inline in the
  transcode worker on a one-minute deadline and its errgroup cancels
  siblings on first error, so a waveform failure added there would kill
  BPM/key and write an error into the chain-submitted uploads row. Its
  WAV is also truncated to 120s, which is right for BPM/key and wrong
  for a full-track envelope.

- Backfill needs its own discovery sweep: the analyzer's selector
  filters on audio_analysis_status != 'done' and the whole existing
  catalog is already done.

- Read failures are split three ways. Both-buckets-NotFound is
  not_local; any other bucket error is unavailable on a short backoff.
  Collapsing them would let a transient archive outage stamp a 24h
  backoff across a large slice of the catalog in one sweep. Neither
  increments error_count, so a job still migrates to a node that has
  the file.

- On a StoreAll node the archive tier holds the long tail, which is
  usually cold storage. Archive-tier CIDs are recorded as
  archive_skipped rather than read, so an operator can see the size of
  the bill in the status endpoint before opting in.

- Duration comes from the decoded sample count, not uploads.ff_probe,
  which is probed from the original upload rather than the 320 and is
  null on older rows.

computeWaveform also verifies it fed ffmpeg the whole source: a bucket
read that ends early without erroring would otherwise yield a plausible
waveform for a fraction of the track and mark it done.

All three switches default to false, so an unconfigured node behaves
exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stead

Three review fixes.

Move the transcode hook after the mirror bookkeeping. The waveform decode
runs inline on a path HTTP handlers call synchronously, so sitting it
between the transcode finishing and the node claiming to be a transcoded
mirror delayed that claim for the length of a full-track decode. It stays
inside the scope of the deferred remove, so the file is still on disk --
there was no race with that defer, only misplaced latency.

Remove on-demand analysis from the serving path. It enqueued a job with
no placementHosts, and analyzeWaveform performs its bucket read with no
archive check, so an unauthenticated GET bypassed the archive-tier guard
the backfill sweep applies -- on a StoreAll node that means anyone could
trigger a cold-storage retrieval the sweep deliberately refuses, with no
rate limiting in front of it. Coverage is already provided by the
transcode hook and the backfill sweep, both of which respect that guard.

Redirect to a peer that already has the waveform, mirroring what
serveBlob does for blobs. Pointing at a node holding the answer is a
better response than promising to compute one, and it addresses the
ragged availability that comes with not replicating.

The peer search cannot reuse hostHasBlob: holding the blob says nothing
about holding the waveform, since waveforms are not replicated and a peer
may have the feature switched off. Peers are probed for the waveform
itself via HEAD, which the route now serves. The probe sets localOnly,
and a node seeing localOnly answers 404 rather than forwarding -- without
that a probe would recurse across the network. Rendezvous order is still
the right search order, bounded to three probes since this sits inline in
a user request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fixes that turn out to share a mechanism.

The backfill cursor latched exhausted forever. Uploads replicate from
peers, so this node keeps learning about ones with timestamps the
descending walk has already passed -- and it never went back for them,
leaving those without waveforms permanently. Reaching the end of history
now records the time, and a pass older than the re-walk interval starts
over. That is cheap despite the table size: the not-exists filter means a
batch skips straight to rows still lacking a waveform, so a converged
backfill costs one query returning nothing rather than a scan per batch.

Waveform version is now derived rather than hand-maintained. The
parameters change the output just as surely as the algorithm does, so a
hand-bumped constant invites exactly the mistake of changing
waveformBuckets and silently leaving a network full of waveforms computed
under the old settings. It is a fingerprint of the algorithm version and
every parameter that affects output, which means it is not a sequence:
comparisons are equality, never ordering, and the status endpoint now
reports the inputs alongside it so the opaque number stays debuggable.

Re-backfill then needs no separate sweep. The discovery query counts only
rows at the current version as present, so a version change makes every
stale row look absent and the ordinary walk recomputes them. The cursor
records the version it walked under, and a mismatch restarts it
immediately rather than waiting for the re-walk interval.

The cursor's version column is folded into its create statement rather
than added by a later alter, which is safe only because this branch is
unmerged: that create is "if not exists", so any database that already
ran the previous statement needs recreating rather than migrating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
not_local, unavailable and archive_skipped rows were written without a
version, so they fell back to the column default and never matched the
running one. Discovery decides an upload is outstanding by the absence of
a row at the current version, which meant those uploads stayed
permanently visible to it: every re-walk re-enqueued them, bypassing the
backoff the retry sweep was applying.

The effect was a missing blob being probed every re-walk rather than once
a day, with discovery and the retry sweep scheduling the same cid. Each
probe is only a bucket head, but on S3 those are billed, and
archive_skipped rows exist precisely to avoid touching the bucket at all.

Stamping the version hands scheduling to the retry sweep alone. Nothing
is lost: that sweep keys off status and ignores version, so a version
change still recomputes these rows once they succeed. not_local continues
not to spend the retry budget, so a blob that replicates in later still
gets a waveform however long it takes to arrive.

The column default goes with it. Every write now supplies a version, and
discovery's correctness depends on that, so a write that forgot one
should fail loudly rather than silently insert a row no sweep will ever
reconsider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The archive guard was applied where jobs are queued, which assumed a cid's
tier is fixed. It is not: rendezvous rank shifts whenever the validator
set changes, and replicateToMyBucket writes through bucketForCID, so a
blob can become archive-tier after an earlier attempt already recorded
not_local or unavailable.

The retry sweep re-queues those rows without consulting the tier, and
readBlob falls back to archive on NotFound unconditionally. A validator
joining or leaving re-ranks a large slice of the catalog at once, so that
combination could pull much of it out of cold storage with the flag
switched off -- the exact bill the flag exists to prevent.

Checking immediately before the first bucket call makes the flag
authoritative however the job arrived. It costs nothing: isArchiveCID is
rendezvous arithmetic over the in-memory host ring and touches no bucket,
which is also why the discovery-time check is worth keeping as an
optimization that avoids queueing work that would be skipped anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backfill dripped one batch of 100 per five-minute tick, a shape
copied from the audio-analysis backlog sweep. That loop exists to pick up
the occasional straggler the live path missed, and the rate suits it. It
does not suit draining a whole catalog: at 28,800 a day a 2.4M-track node
needs roughly three months, while two workers sit idle three quarters of
the time.

The sweep now refills the queue and stops when it is full, so throughput
is set by how fast the workers drain it and OPENAUDIO_WAVEFORM_WORKERS
becomes the knob that matters. It sweeps every few seconds while a
backlog exists and falls back to a long interval once caught up, so an
idle node is not querying constantly.

The cursor may now only advance over uploads actually dealt with.
Previously it advanced to the end of the batch whatever happened, and the
enqueue result was discarded, so anything a full queue rejected was
skipped until the next re-walk hours later -- silently, with no log and
no counter. Backpressure replaces dropping.

Two supporting changes. The decode pins itself to one ffmpeg thread:
transcoding already caps itself at two per worker to avoid CPU spikes,
decoding to PCM is single-threaded in practice, and backfill runs
alongside that work. And a partial index on uploads (created_at desc, id
desc) where template = 'audio' backs the keyset walk, which otherwise
falls back to an index whose second column is transcoded_at and sorts a
slice of a wide jsonb table -- worst of all during a caught-up re-walk,
which reads to the end of history to prove nothing is left. That scan is
also the one query here that grows with the catalog, so each sweep now
runs under a timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Waveform analysis had no operator-facing surface: the only way to see
whether it was running, how far it had got, or what it would cost to
widen it was a basic-auth JSON endpoint.

It lands as a section on the storage page rather than a page of its own.
A backfill pass is the same shape as a repair run -- a cursor over local
blobs with retries and failure states -- so it sits beside repair and
borrows its run-card vocabulary. A top-level nav entry would also
advertise a feature that is off by default on almost every node.

Three cards. The run reports where the walk has reached and what the
pass has queued. Stored reports the population by status. Needs Attention
separates decode failures from bucket errors, because a rising
unavailable count means storage is unhealthy rather than that audio is
missing -- the reason those are distinct statuses at all.

Two things the section is careful about, both being states operators get
stuck in. All three switches are reported, not just the master: enabled
with backfill off looks identical to enabled and working, and is the more
common confusion. And a cursor still walking under an older version is
called out, since between a version bump and the sweep noticing it, the
only visible symptom is that nothing appears to have changed.

Progress comes from where the cursor sits between the oldest and newest
audio upload, and the projection from time elapsed in the pass. Uploads
are not spread evenly across that span, so the estimate is an order of
magnitude rather than a deadline -- labelled as such. The honest
alternative is counting the rows left on every render, which is the scan
this whole design avoids. Both bounds are ends of the walk's own index,
so it costs two lookups whatever the catalog size.

The cursor gains started_at and per-pass counters to support this, making
it a run record rather than only a position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to how the section presents.

It is hidden entirely unless waveform analysis is enabled, matching how
Archive Storage only appears once an archive bucket is configured. The
feature is off by default, so an empty section on nearly every node was
noise. The cost is discoverability -- an operator no longer learns the
capability exists by reading the page -- which is the reason the previous
version rendered a "disabled" line.

Everything now sits inside a tile. The algorithm and settings were prose
under the heading, but they are data describing the output, so they read
as a tile like everything else on this page.

The layout is two run tiles over two rows of stat tiles. Analysis carries
the settings and which of the nested switches are on; Backfill carries
the pass, its progress and its counters. Statuses become one stat tile
each rather than a combined card, so unavailable sits beside the others
while its sublabel keeps saying which problem it represents -- a bucket
erroring, not audio missing.

The request tiles are new, and needed new counters. Nothing previously
reported whether anything consumes the waveforms at all. Redirects are
the useful one: waveforms are computed per node rather than replicated,
so the redirect rate is the closest thing to a measure of how evenly they
are spread across the network. While only one node runs this, redirects
stay near zero and misses stay high, which correctly reads as a coverage
problem rather than a backfill problem.

Peer probes carry localOnly and are answered without being counted as
misses, so the counts describe client-facing traffic rather than the
network searching itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
templ separates adjacent expressions with whitespace, so the percentage
and the estimate rendered as "24.2% walked , ~19m12s left" with a space
before the comma. Composing the line in Go keeps the punctuation where it
belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tstanding work

Three changes that turned out to depend on each other.

Previews were never analyzed. A preview is its own blob with its own cid,
and its waveform cannot be sliced out of the track's -- it is
peak-normalized independently, and thirty seconds of a long track occupies
too few buckets to stretch across a player. Discovery now enumerates the
selected preview alongside the 320, and the transcode hook queues it once
the preview blob exists. Serving needed nothing: the route is
cid-addressed, so a preview waveform is servable the moment a row exists.

Placement context is carried per blob rather than per upload, which
previews forced into the open. The 320 is replicated with the upload's
placement hosts and the preview with none, and bucketForCID reads any
non-empty placement as "force primary" -- so handing a preview the
upload's hosts would judge every preview primary-tier and read it out of
cold storage with the archive flag off. Each target now carries what its
own blob was written with, and the retry sweep recovers it by joining
uploads rather than assuming nil, which also fixes placement-pinned 320s
being wrongly skipped as archive-tier.

waveforms gains a nullable upload_id. Not a key and not 1:1 -- an upload
yields two analyzable blobs once previews count, and legacy Qm content has
no upload row at all. It exists so discovery can correlate on indexed text
instead of extracting jsonb from both sides, which was the expensive part
of the sweep. An upload is outstanding until every blob it produced has a
row at the current version, so a track whose preview is unanalyzed stays
in the walk.

That correlation is what makes an outstanding count affordable, and the
console gains an Unanalyzed tile. It is the one figure describing rows
absent from the table rather than present in it, so it is sampled on a
sweep and the tile reports how stale the sample is instead of implying it
is live. Analyzed now counts only the current version, so a stale row is
no longer reported as both analyzed and awaiting recompute.

The sweep loop also now runs regardless of backfill, with only the history
walk gated. Returning early stranded the retry sweep, so a transient
failure from the live transcode hook -- written with a next_attempt_at
nobody read -- was permanent on a live-only node, and the outstanding
count sat at zero while nothing had been looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rage

Rename it to Skipped Archive, and say plainly what the number is: blobs
skipped because they live in archive storage.

It only renders when the node has an archive bucket configured. Without
one, bucketForCID can never route anything to archive, so the tile was a
permanent zero on every node that does not use archive storage -- which
is most of them. The waveform archive flag is not the right condition:
the count matters precisely when archive storage exists and that flag is
off, since it is then the size of the bill for turning it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo force-pushed the mjp-mediorum-waveform-peaks branch from 47eefdb to 44fca4f Compare August 18, 2026 01:42
A blob arriving by replication got no waveform until a sweep came back
around -- typically the 24h not_local backoff, since the walk usually
reaches an upload before replication does. The peer redirect hid the gap
by bouncing clients to a node that had one, so the symptom was uneven
coverage rather than failures.

pullFileFromHostValidated already writes the blob to a temp file to check
its cid before committing, so the bytes are on local disk at the moment
the write lands. Analysis now reads that copy instead of fetching back
what was just stored, which on an S3-backed node is a billed GET per
replication.

The job is queued rather than run inline, so the peer waiting on the
request is not held for a decode. That is also why this is not teed off
the bucket write: a wedged decode must not be able to stall replication,
which is the path durability depends on. Queueing happens only after the
write commits, so the peer is never told we hold a blob we have not
finished storing.

Ownership of the temp file transfers with the job. The enqueue reports
definitively whether the send happened -- a non-blocking select either
placed it or did not -- so a flag marks the transfer and the deferred
cleanup still covers a copy error, a cid mismatch, a failed write, and a
full queue. Once accepted, the worker releases it on success, failure and
panic alike.

The archive guard is skipped on this path. It exists to keep the backfill
out of cold storage, and there is no bucket read here at all, so an
archive-tier blob that arrives by replication is analyzed for free rather
than deferred.

Replication knows only a cid, but a row without an upload_id is invisible
to discovery and its upload would stay outstanding forever, re-analyzed on
every re-walk. The upload is resolved once, by index: the 320 directly, a
preview through audio_previews to its source. Legacy Qm content resolves
to null, which is correct rather than a failure.

The bulk repair path is deliberately untouched. It streams straight into
the bucket with no local copy, and it walks millions of blobs, so the
sweeps continue to carry it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rickyrombo and others added 8 commits August 18, 2026 01:13
A waveform row written without an upload_id is invisible to discovery, which
correlates on it. The upload then looks permanently outstanding, every re-walk
recomputes a waveform that is already correct, and on a live-only node -- where
discovery never runs -- nothing ever repairs it.

Three ways that happened.

The preview lookup in resolveWaveformUploadID joined audio_previews on
source_cid, but gorm derives that column from SourceCID as source_c_id. The
query errored on every call. Because the error was not ErrNoRows it was logged
at Debug and the caller returned empty, which it reads as "legacy content with
no upload" -- so every replicated preview was orphaned, silently. The suite
never resolved a preview, so nothing caught it.

The 320 had a narrower version of the same problem. The sender publishes the
uploads row through consensus and then asks peers to pull over HTTP, so the
puller can be handed a blob whose upload row it has not synced yet, and it was
resolving against exactly that table. The pull request now carries the upload
id the sender already had; resolution remains the fallback for peers that
predate the field.

Previews were also replicated before their audio_previews row was created, so
the blob could reach a peer before anything on the network could account for
it. Creating the row first does not close that window on its own -- it still
has to clear consensus -- but it stops us widening it deliberately.

Adds linkOrphanWaveforms as the backstop, repairing the link in place rather
than recomputing. It runs regardless of the backfill flag, since a live-only
node is where an unlinked row is permanent. Legacy Qm content is excluded: a
null upload_id is the right answer there, and retrying it would never converge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stat tiles mixed two units. Analyzed counted rows in the waveforms table
while Unanalyzed counted uploads missing them, so no arithmetic over the tiles
produced the size of the catalog -- and an upload carrying a stale row was
reported three times over: as analyzed, as awaiting recompute, and as
outstanding, because the outstanding query only counted rows at the current
version.

Counting uploads throughout removes the overlap by construction. Every
analyzable upload lands in exactly one bucket, so the tiles sum to the
analyzable catalog. Where an upload's blobs disagree -- a finished 320 beside a
failed preview -- the worse state wins: that is the one worth acting on, and
filing it under both is what produced the double counting.

Two counts were wrong independently of the units. To Recompute filtered on
status = 'done', so a failed or not-local row at a stale version was counted by
nothing at all -- and since a version change makes every row stale at once, the
Failed tile would have dropped to zero the moment a parameter changed while the
failures were still there. And an upload whose selected_preview named a blob
that was never produced was expected to have two rows when only one could ever
exist, leaving it permanently short. Expectation now mirrors waveformTargets,
which requires the preview to resolve to a blob.

Everything comes from one sampled pass rather than a query per tile. Sharing a
snapshot is what lets the numbers reconcile; previously the live half and the
sampled half described different moments. The console reports the age.

Adds an Unlinked Rows tile, shown only when non-zero. The rollup is keyed by
upload and structurally cannot see rows that resolved none, so this is the one
figure that says the rest is describing a subset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every replicated blob comes through the pull handoff, not just the 320.
replicateOriginal sends the original upload file down the same path, and image
uploads travel it too, so each one cost an ffmpeg subprocess to produce a
waveform for a cid nothing will ever request. A live archive node had already
accumulated rows for two originals.

Left alone this was about to get quieter rather than louder: the pull request
now carries the sender's upload id, so those rows would arrive linked and stop
appearing as unlinked. They would also count toward their upload's done total,
and since expected is 1 for an upload with no preview, a waveform computed from
the original alone satisfies it -- reporting the upload analyzed while the 320
that clients actually request has none.

The sender knows which blob it replicated, so it says. Failing that -- an older
peer, or a sender with no upload context -- resolveWaveformUploadID matches only
a 320 or a selected preview, so a successful resolve is itself proof the blob is
a target, and its miss is what filters the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A preview was queued for analysis without its local file, so the worker read
the blob back from the bucket -- where the archive guard refused it.

Previews are rendezvous-routed with no placement, so on a StoreAll node
bucketForCID sends one to the archive tier whenever this node's rank for the
preview cid is >= ReplicationFactor. That cid ranks independently of the
track's and of who created it, so creating a preview confers no standing: on a
70-peer network with a replication factor of 4, a node lands in the top four
for roughly one preview in seventeen. The rest were recorded archive_skipped
and never analyzed. A node running the recommended default -- StoreAll with
archive analysis off -- therefore produced almost no preview waveforms at all,
which is the configuration this feature was built for.

generateAudioPreview still has the file on disk when it queues the job, so it
hands it over on the same ownership-transfer the replication handoff uses: the
worker deletes it, and the deferred cleanup covers every failure before the
transfer is taken. The transcode hook's separate enqueue goes away with it,
since the preview is now queued where it is produced -- which also covers the
bare-CID HTTP path, previously not analyzed at all.

This does not weaken the archive flag. That guard exists to prevent
cold-storage retrievals, and there is no retrieval when the bytes are already
on local disk; the 320 hook and the replication handoff make the same call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three failures on a live node turned out to be two bugs and one honest one.

The decode cap and the short-read guard contradicted each other. ffmpeg is
given -t waveformMaxDecodeSeconds, so on a longer source it exits at four
hours and the copier stops with the source unread -- which the byte comparison
cannot tell apart from a truncated blob. Nine sources between four and seven
hours were reported as corrupt, each identically at 576,159,744 bytes, exactly
four hours at 320kbps. The sample count separates the two cases, and a source
longer than we decode is now terminal rather than a failure: nothing about the
blob is wrong and no retry makes it shorter.

Retries were also not bounded by their own cap. next_attempt_at only moves when
an attempt finishes, and nothing marked a row as running, so a cid stayed
selectable for the whole of its attempt and every sweep tick re-queued it. On a
four-hour decode that is thousands of ticks: error_count reached 7 against a cap
of 3, and did so in proportion to file size, worst for exactly the sources that
were most expensive to decode. An in-flight set bounds it to one attempt per
cid, released on every exit so a cid is never retired by a panic.

Discovery also asked for more rows than could exist, expecting a preview
whenever selected_preview was set rather than when it named a blob that was
actually produced -- 55 uploads on that node. It now matches waveformTargets,
the same correction the rollup already carries.

The remaining six failures are real: blobs with no audio stream, one h263
video, and two mp3s whose demuxer wants to seek a pipe. Those keep the error
status and its retry cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ped one

waveforms stored next_attempt_at, a decision, while already stamping the fact
it was derived from. Keeping only the fact is smaller in every direction.

The column is now last_attempted_at, written before the analysis runs rather
than after. That alone removes the re-queue loop: a row whose attempt is still
going is inside its own backoff, so the sweep cannot select it. The in-flight
set added for that goes away, along with its mutex and the release-on-every-
path defer it needed -- and unlike the set, a timestamp survives a restart, so
a node killed mid-attempt no longer finds every row it was working on
instantly due again.

It also retires a sentinel that had already caused a bug. next_attempt_at
meant later when positive, already due when negative, and never when zero,
which is how a test expressing "past due" as -1m was swallowed by the terminal
check. Terminal is now a property of the status: done and too_long match no
branch of the query, and nothing has to encode never in a timestamp.

The retry query asks one status at a time so each branch is an equality on
status and a range on last_attempted_at -- what (status, last_attempted_at)
serves. A CASE over the backoff would have put an expression on the indexed
side and turned it into a scan. Measured on 200k rows with three due: BitmapOr
of three index scans, 0.058ms, 11 buffers.

No ORDER BY. Picking a row up stamps it, so eligibility clears itself and every
row is reached whatever order the batches arrive in; the heap is then read in
physical order, which is better locality than seeking by timestamp anyway.

waveformRetryBackoffUnavailable goes to 30 minutes, floored above
waveformArchiveJobTimeout: a backoff shorter than the longest an attempt can
run puts the row back in the sweep's reach while it is still working.

Backoff policy now applies to rows already written, so tuning a constant no
longer needs a migration or an UPDATE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ffmpeg was given -t 14400 as a poison-pill guard, which guarded nothing. The
accumulator halves as it fills, so memory is fixed however long the audio runs,
and the per-job context timeout already bounds wall clock -- which is the thing
worth bounding, rather than duration standing in for it.

What it did instead was fail nine real uploads on a live node, all of them
sources between four and seven hours, and then require an apparatus to cope:
a terminal status, a sentinel error, a sample-count test to tell a deliberate
stop from a truncated read, branches in both analysis paths, a rollup bucket
and a console tile. All of it existed to handle a limit that should not have
been there.

It also cost the short-read guard its meaning. That check says the source ended
early, which is worth knowing; the cap forced it to also mean we stopped it on
purpose, and the two were indistinguishable by byte count.

A seven-hour track gives 34 seconds a bucket. Coarse, but that is what long
content looks like, and someone asking for its waveform should get one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant